Skip to content

Resolve backend operations via the API documentation - #64

Merged
mihailefter merged 21 commits into
masterfrom
feature/34-api-docs-discovery
Sep 3, 2026
Merged

Resolve backend operations via the API documentation#64
mihailefter merged 21 commits into
masterfrom
feature/34-api-docs-discovery

Conversation

@mihailefter

@mihailefter mihailefter commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

In line with #34, the client now stops hardcoding API paths. Instead, it tries to discover the FDP's API document via dcat:endpointDescription, provided in the root Turtle response by FDP 1.22+ (see FAIRDataTeam/FAIRDataPoint#952). If no usable document is declared there, it falls back to the /v3/api-docs guess. If the document is found, requests are resolved by operationId (bindOperation) instead of hardcoded paths.

As a result, affected UI elements (buttons, menu links, forms) are only shown when the connected FDP's API document offers the corresponding operation. I also added route guards for /login, /users, /users/create, /users/:id, /users/current, and /search, so direct navigation cannot bypass the hidden UI and hit a raw operation-resolution failure.

If no usable API document is found at all, the affected UI simply stays hidden, with no explicit message shown for that case yet.

Since the discovery flow depends on the root URI from runtime config, I also added a visible startup error instead of a blank page for when that config fails to load.

The following things are still open:

  • Capability-gated route guards currently redirect silently to / with no explanation. Worth discussing whether direct navigation to an unavailable route should tell the user why (e.g. "this FDP does not support user management") rather than just bouncing home. The same applies more broadly: if the API document itself can't be found, there's no visible indication of that either, just an app that quietly offers less.

  • The API document also carries requestBody/parameters schemas (required fields, formats, enums) that could eventually drive client-side form validation instead of the current hardcoded checks.

fixes #34 (capabilities may be extended later)

@mihailefter
mihailefter requested a review from dennisvang August 18, 2026 11:38

@dennisvang dennisvang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mihailefter this looks really good. 🙂

I do have a few questions/suggestions.

Details are in the comments, but, in summary:

  • Note that cached api-docs need to be refreshed if a ResourceDefinition is created or updated. This may be good to remember when implementing the corresponding admin functionality later.
  • Looks like fdpApi.ts has some repetition that could be replaced by a generic performOperation() function? See comment for detailed example.
  • The changes are more complex than I expected, due to the async handling of operations with operationBinding etc. Would it be possible to simplify by awaiting the api-docs at the very start, (like loadClientConfig) and then handling operations synchronously? I would think that there's not much to do anyway if api-docs fail to load. I do like the async implementation, but my main concern is complexity.

Comment thread src/composables/apiDocs.ts
Comment thread src/composables/apiDocs.ts
Comment thread src/composables/apiDocs.ts Outdated

let apiDocsPromise: Promise<unknown> | null = null

/** Fetches the FDP's OpenAPI doc once per session and reuses it for all subsequent lookups. */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the API docs are (supposed to be) updated by the backend whenever a ResourceDefinition is added or changed.
That means the client should refresh the API docs after creating/editing ResourceDefinition objects.

Comment thread src/views/UserFormView.vue Outdated
const userId = computed(() => (route.params.id as string | undefined) ?? 'current')

/**
* Self-service profile routes use current-user operations. Admin routes use uuid-based user

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not immediately clear to me what "self-service" and self refer to.
Does it refer to current user?

Comment thread src/composables/fdpApi.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like there's quite a bit of repetition in this file.

Perhaps you could define a generic method performOperation(<operation-id>, <data>) that performs the actual request, based on operation details, and call that method from the relevant locations.

Using searchResources() as an example, the following code from SearchView.vue

    const { url, method } = await searchBinding
    results.value = (await searchResources(q, url, method)) as SearchResult[]

could then be replaced by something like (please excuse the sloppy pseudo-code):

    operationResult = (await performOperation(
        <search-operation-id>, 
        <object-containing-query-string-and-other-relevant-data>
    )) as OperationResult
    results.value = ... // extract SearchResult[] from operationResult

A similar approach applies to all the other functions.

To illustrate the idea, here's an example from one of my Python-based FDP clients (synchronous instead of async):

class APIClient(object):

    ...

    def release_schema_version(
        self, uuid: str, version: str, description: str = "", public: bool = False
    ) -> OperationResult:
        """Creates a metadata-schema-version by releasing the metadata-schema-draft"""
        # minimal post body
        metadata_schema_version = {
            "description": description,
            "published": public,
            "version": version,
        }

        # perform operation
        return self.perform_operation(
            key="releaseSchemaVersion", uuid=uuid, json=metadata_schema_version
        )

    ...

    def perform_operation(self, key: str, **kwargs) -> OperationResult:
        """
        Performs an operation defined in the OpenAPI docs.

        Path parameters must be speficied as kwargs, e.g. uuid=<string>. Additional
        kwargs, if any, are passed on to the requests method call, e.g. json=<dict>.
        """
        # get operation info
        try:
            operation = self.api_operations[key]
            logger.info("performing operation: %s", key)
        except KeyError as e:
            logger.error("unknown operation: %s", key)
            self.list_operations()
            raise e

        # remove path parameters from kwargs and format uri
        path_parameters = {
            parameter_name: kwargs.pop(parameter_name, None)
            for parameter_name in self._get_api_parameters(
                operation=operation, param_type="path"
            )
        }
        url = self.url + operation["uri"].format(**path_parameters)

        # perform request
        response = getattr(self.session, operation["method"])(url=url, **kwargs)

        # handle response
        if response.ok:
            # handle content type
            content_type = response.headers.get("content-type")
            if "json" in content_type:
                content = response.json()
            elif content_type.startswith("text"):
                # same as response.content.decode("utf-8")
                content = response.text
            else:
                logger.warning("unknown content-type: %s", content_type)
                content = response.content
            logger.info("operation successful: %s", key)
            logger.debug("result: %s", content)
            return OperationResult(
                location=response.headers.get("location"),
                content_type=content_type,
                content=content,
            )
        self._log_api_operation_requirements(operation=operation)
        raise Exception(
            f"operation failed: {key}\n\t"
            f"request: {response.request.method} {response.request.url} "
            f"{response.request.body}\n\t"
            f"response: {response.content or '-'}"
        )

Comment thread src/composables/useAuth.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/router/index.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/main.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great improvement. :)

Comment thread tests/config.test.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice! :)

@dennisvang

dennisvang commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

From the PR description above:

The API document also carries requestBody/parameters schemas (required fields, formats, enums) that could eventually drive client-side form validation instead of the current hardcoded checks.

@mihailefter i think the current PR is a great start. This advanced stuff can be done at a later stage. Perhaps good to keep this in mind when implementing the admin functionality for "resource definitions" and "metadata schemas?"

@dennisvang dennisvang added the feature New feature or feature request label Aug 31, 2026
@mihailefter

Copy link
Copy Markdown
Collaborator Author

Hi @dennisvang! Thanks for the detailed review. I thought about your main question, and also addressed the other comments.

On the async design, since api-docs are not required for the whole app, and plain resource browsing (/ and /:resourceType/:id) works through LDP links and can still function without api-docs, I did not add a global api-docs resolution step before mounting the app. loadClientConfig() is different, since without config the app cannot do anything.

You were right that an unreachable api-docs candidate should not be able to hang operation resolution indefinitely, so I added a timeout. If api-docs cannot be resolved, those features now become unavailable instead of waiting forever.

I also agreed with the complexity concern. readyBinding and deriveAvailability are gone, replaced by one shared reactive api-docs store and a single bindOperation() helper for action code. isOperationOffered() now reads from that same store for the synchronous availability checks used by the UI and route guards.

On the fdpApi.ts repetition: I did not go with a fully generic performOperation(operationId, data). Looking at every function, almost all of them have some specific behavior beyond the basic request/response cycle: createUser, updateUser, and updateUserPassword read a { message } field from the response body on failure, fetchToken has a status-specific error message, and searchResources injects query params before the request. Folding all of that into a generic wrapper felt harder to follow than the explicit code, so instead I added two small private helpers, authHeaders() for the bearer-token header and request() for the common fetch-plus-basic-error path, that only cover the part that is genuinely identical across the functions.

For cache invalidation, I added refreshApiDocs() as the hook for re-resolving api-docs after backend changes. It is not wired to anything yet because there is no ResourceDefinition admin UI in this client today, but it is there for when that lands.

I also applied the inline suggestions directly and clarified the self / self-service wording.

@dennisvang dennisvang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mihailefter thanks for the detailed response.

Do you think we could move all the bindOperation() calls into fdpApi.ts?
That would allow us to get rid of the url and method arguments in the fdpApi functions, and I think it is more coherent, because the actual operation ids belong to the fdp api.

See suggestion in #75

For example, useAuth.ts would become

export function useAuth() {
  async function login(email: string, password: string): Promise<void> {
    const newToken = await fetchToken(email, password)
    setAuthToken(newToken)
    try {
      const currentUser = (await fetchCurrentUser()) as User
    ...
    }
  }

and the corresponding functions in fdpApi.ts would become

/** Fetches the currently authenticated user's profile. */
export async function fetchCurrentUser(): Promise<unknown> {
  const url = (await bindOperation('getUserCurrent')).url
  const response = await request(url, { headers: { Accept: 'application/json' } })
  return response.json()
}

/** Authenticates with the FDP and returns a JWT token. */
export async function fetchToken(email: string, password: string): Promise<string> {
  const { url, method } = await bindOperation('generateToken')
  const response = await fetch(url, {
    method,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  })
  ...
}

I'm also wondering of we should just ignore the PUT/DELETE endpoints for the current user, and only use the current user GET to obtain the uuid. This would remove the need for selfOrUuidOperation.
Or, perhaps better, move the selfOrUuidOperation helper to fdpApi.ts, for example

function userOperation(
  currentUserOperationId: string,
  uuidUserOperationId: string,
  uuid?: string,
): Promise<OperationBinding> {
  return !uuid
    ? bindOperation(currentUserOperationId)
    : bindOperation(uuidUserOperationId, { uuid })
}

and then, for example

/** Fetches a single user's profile. */
export async function fetchUser(uuid?: string): Promise<unknown> {
  const { url } = await userOperation('getUserCurrent', 'getUser', uuid)
  const response = await request(url, { headers: { Accept: 'application/json' } })
  return response.json()
}

// editing another user's profile (/users/:id), and editing the signed-in
// user's profile (/users/current, isSelf), which also hides the role field.
const isCreate = computed(() => route.name === 'user-create')
const isSelf = computed(() => route.name === 'user-profile')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const isSelf = computed(() => route.name === 'user-profile')
const isCurrent = computed(() => route.name === 'user-current')

Comment thread src/router/index.ts
},
{
path: '/users/current',
name: 'user-profile',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
name: 'user-profile',
name: 'user-current',

@mihailefter
mihailefter merged commit 178f777 into master Sep 3, 2026
15 of 17 checks passed
@dennisvang
dennisvang deleted the feature/34-api-docs-discovery branch September 3, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or feature request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Act like a REST client

2 participants