-
Notifications
You must be signed in to change notification settings - Fork 0
Resolve backend operations via the API documentation #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
d61bfab
f8dec4a
f38cb50
1e3e80a
d7a030f
c3a45c9
8226872
5854476
ff846f3
0a6262b
08698fd
f734256
c4b3b8a
404dccc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,110 @@ | ||||||||
| import { fetchRdfTurtle, fetchApiDocs } from './fdpApi' | ||||||||
| import { parseTurtle, resolveSubjectUri, getNodeRefs } from './rdfUtils' | ||||||||
| import { DCAT_ENDPOINT_DESCRIPTION } from './vocabularies' | ||||||||
|
|
||||||||
| /** | ||||||||
| * Returns candidate OpenAPI/SmartAPI document URLs from the FDP root. The spec defines | ||||||||
| * dcat:endpointDescription on the root, and FDP 1.22+ may declare multiple values, such as both | ||||||||
| * the OpenAPI document and Swagger UI. A /v3/api-docs guess is kept as the only path fallback for | ||||||||
| * older or incomplete roots; callers still have to try the candidates. | ||||||||
| */ | ||||||||
| export async function discoverApiDocsUrls(rootUri: string): Promise<string[]> { | ||||||||
| const store = parseTurtle(await fetchRdfTurtle(rootUri)) | ||||||||
| const subjectUri = resolveSubjectUri(store, rootUri) | ||||||||
| const declaredUrls = subjectUri ? getNodeRefs(store, subjectUri, DCAT_ENDPOINT_DESCRIPTION) : [] | ||||||||
| const fallbackUrl = new URL('/v3/api-docs', rootUri).toString() | ||||||||
| return [...new Set([...declaredUrls, fallbackUrl])] | ||||||||
| } | ||||||||
|
|
||||||||
| type OpenApiOperation = { operationId?: string } | ||||||||
| type OpenApiDoc = { paths?: Record<string, Record<string, OpenApiOperation>> } | ||||||||
|
|
||||||||
| function isOpenApiDoc(doc: unknown): doc is OpenApiDoc { | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| return typeof doc === 'object' && doc !== null && 'paths' in doc | ||||||||
| } | ||||||||
|
|
||||||||
| let apiDocsPromise: Promise<unknown> | null = null | ||||||||
|
|
||||||||
| /** Fetches the FDP's OpenAPI doc once per session and reuses it for all subsequent lookups. */ | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||
| async function getCachedApiDocs(rootUri: string): Promise<unknown> { | ||||||||
| if (!apiDocsPromise) { | ||||||||
| apiDocsPromise = resolveApiDocs(rootUri).catch((err) => { | ||||||||
| apiDocsPromise = null | ||||||||
| throw err | ||||||||
| }) | ||||||||
| } | ||||||||
| return apiDocsPromise | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Fetches the FDP's OpenAPI document, trying each URL from discoverApiDocsUrls in turn and | ||||||||
| * keeping the first one that actually parses as an OpenAPI document (has a paths object). | ||||||||
| * Throws if none of the candidates resolve to one. | ||||||||
| */ | ||||||||
| async function resolveApiDocs(rootUri: string): Promise<unknown> { | ||||||||
| const candidates = await discoverApiDocsUrls(rootUri) | ||||||||
| for (const url of candidates) { | ||||||||
| try { | ||||||||
| const doc = await fetchApiDocs(url) | ||||||||
| if (isOpenApiDoc(doc)) return doc | ||||||||
| } catch { | ||||||||
| // try the next candidate | ||||||||
| } | ||||||||
| } | ||||||||
| throw new Error(`No usable OpenAPI document found among candidates: ${candidates.join(', ')}`) | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Finds the path and HTTP method for a given operationId in an already-fetched OpenAPI document. | ||||||||
| * Returns null if the document has no matching operation. | ||||||||
| */ | ||||||||
| export function resolveOperation( | ||||||||
| doc: unknown, | ||||||||
| operationId: string, | ||||||||
| ): { path: string; method: string } | null { | ||||||||
| const paths = (doc as OpenApiDoc | null)?.paths | ||||||||
| if (!paths) return null | ||||||||
|
|
||||||||
| for (const [path, methods] of Object.entries(paths)) { | ||||||||
| for (const [method, operation] of Object.entries(methods)) { | ||||||||
| if (operation.operationId === operationId) { | ||||||||
| return { path, method: method.toUpperCase() } | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| return null | ||||||||
| } | ||||||||
|
|
||||||||
| export type OperationBinding = { url: string; method: string } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Substitutes {name}-style placeholders in a path template with values from pathParams. | ||||||||
| * @example substitutePathParams('/users/{uuid}', { uuid: 'abc' }) // -> '/users/abc' | ||||||||
| */ | ||||||||
| function substitutePathParams(path: string, pathParams: Record<string, string>): string { | ||||||||
| return path.replace(/\{([^}]+)\}/g, (_placeholder, name: string) => { | ||||||||
| const value = pathParams[name] | ||||||||
| if (value === undefined) throw new Error(`Missing path parameter '${name}' for '${path}'`) | ||||||||
| return encodeURIComponent(value) | ||||||||
| }) | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Resolves an operationId to the URL/method advertised by the OpenAPI document. | ||||||||
| * No endpoint-path fallback is attempted here: after the document is found, a missing operation | ||||||||
| * means this FDP does not offer it. | ||||||||
| */ | ||||||||
| export async function bindOperation( | ||||||||
| rootUri: string, | ||||||||
| operationId: string, | ||||||||
| pathParams?: Record<string, string>, | ||||||||
| ): Promise<OperationBinding> { | ||||||||
| const doc = await getCachedApiDocs(rootUri) | ||||||||
| const operation = resolveOperation(doc, operationId) | ||||||||
| if (!operation) { | ||||||||
| throw new Error(`Operation '${operationId}' is not offered by this FDP's OpenAPI doc`) | ||||||||
| } | ||||||||
| const path = pathParams ? substitutePathParams(operation.path, pathParams) : operation.path | ||||||||
| return { url: new URL(path, rootUri).toString(), method: operation.method } | ||||||||
| } | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Using 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 operationResultA 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 '-'}"
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Although JavaScript
Setpreserves insertion order, in many other languages this is not guaranteed.Maybe a comment to point this out?