From d61bfabd6831d696b45f3620e1606d25e52cb5bc Mon Sep 17 00:00:00 2001
From: Mihai Lefter
Date: Tue, 14 Jul 2026 20:53:14 +0200
Subject: [PATCH 01/13] Start resolving endpoint URLs from the OpenAPI docs
---
src/composables/apiDocs.ts | 79 +++++++++++++++++++++++++++++++
src/composables/fdpApi.ts | 21 ++++++--
src/composables/useAuth.ts | 6 ++-
tests/composables/useAuth.test.ts | 4 +-
4 files changed, 103 insertions(+), 7 deletions(-)
create mode 100644 src/composables/apiDocs.ts
diff --git a/src/composables/apiDocs.ts b/src/composables/apiDocs.ts
new file mode 100644
index 0000000..08827b3
--- /dev/null
+++ b/src/composables/apiDocs.ts
@@ -0,0 +1,79 @@
+import { fetchRdfTurtle, fetchApiDocs } from './fdpApi'
+import { parseTurtle, resolveSubjectUri, getParentUri, getNodeRefs } from './rdfUtils'
+import { DCAT_ENDPOINT_DESCRIPTION } from './vocabularies'
+
+/**
+ * Resolves the URL of the FDP's OpenAPI/SmartAPI document. Follows dct:isPartOf up from the
+ * given resource until reaching the FDP root (no further parent), reads
+ * dcat:endPointDescription there, and falls back to a same-origin /v3/api-docs guess if the
+ * root doesn't declare one. The returned URL is not verified to actually respond; callers are
+ * responsible for handling a failed or invalid fetch.
+ */
+export async function discoverApiDocsUrl(uri: string): Promise {
+ let currentUri = uri
+ let store = parseTurtle(await fetchRdfTurtle(currentUri))
+ let subjectUri = resolveSubjectUri(store, currentUri)
+
+ let parentUri = subjectUri ? getParentUri(store, subjectUri) : null
+ while (parentUri) {
+ currentUri = parentUri
+ store = parseTurtle(await fetchRdfTurtle(currentUri))
+ subjectUri = resolveSubjectUri(store, currentUri)
+ parentUri = subjectUri ? getParentUri(store, subjectUri) : null
+ }
+
+ const endpointDescription = subjectUri
+ ? getNodeRefs(store, subjectUri, DCAT_ENDPOINT_DESCRIPTION)[0]
+ : undefined
+
+ return endpointDescription ?? new URL('/v3/api-docs', currentUri).toString()
+}
+
+type OpenApiOperation = { operationId?: string }
+type OpenApiDoc = { paths?: Record> }
+
+/**
+ * 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
+}
+
+/**
+ * Resolves an operationId to a full URL and HTTP method by discovering and fetching the FDP's
+ * OpenAPI document. Falls back to fallbackPath/fallbackMethod if discovery, fetching, or
+ * resolution fails for any reason.
+ */
+export async function resolveOperationUrl(
+ rootUri: string,
+ operationId: string,
+ fallbackPath: string,
+ fallbackMethod: string,
+): Promise<{ url: string; method: string }> {
+ try {
+ const docsUrl = await discoverApiDocsUrl(rootUri)
+ const doc = await fetchApiDocs(docsUrl)
+ const operation = resolveOperation(doc, operationId)
+ if (operation) {
+ return { url: new URL(operation.path, rootUri).toString(), method: operation.method }
+ }
+ } catch {
+ // fall through to fallback
+ }
+ return { url: new URL(fallbackPath, rootUri).toString(), method: fallbackMethod }
+}
diff --git a/src/composables/fdpApi.ts b/src/composables/fdpApi.ts
index 397faf9..08543e7 100644
--- a/src/composables/fdpApi.ts
+++ b/src/composables/fdpApi.ts
@@ -21,6 +21,15 @@ export async function fetchRdfTurtle(uri: string): Promise {
return fetchRdf(uri, 'text/turtle')
}
+/** Fetches an OpenAPI document as JSON from the given URL. */
+export async function fetchApiDocs(uri: string): Promise {
+ const headers: Record = { Accept: 'application/json' }
+ if (authToken) headers['Authorization'] = `Bearer ${authToken}`
+ const response = await fetch(uri, { headers })
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
+ return response.json()
+}
+
/** Searches resources via the FDP full-text search endpoint. */
// TODO: currently limited to the first 20 results; consider pagination or a larger page size.
export async function searchResources(query: string): Promise {
@@ -147,10 +156,14 @@ export async function fetchCurrentUser(): Promise {
}
/** Authenticates with the FDP and returns a JWT token. */
-export async function fetchToken(email: string, password: string): Promise {
- const base = getBaseUrl()
- const response = await fetch(`${base}/tokens`, {
- method: 'POST',
+export async function fetchToken(
+ email: string,
+ password: string,
+ url: string,
+ method: string,
+): Promise {
+ const response = await fetch(url, {
+ method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
diff --git a/src/composables/useAuth.ts b/src/composables/useAuth.ts
index 7ae7f61..47495c2 100644
--- a/src/composables/useAuth.ts
+++ b/src/composables/useAuth.ts
@@ -1,5 +1,7 @@
import { ref, computed } from 'vue'
import { fetchToken, fetchCurrentUser, setAuthToken } from './fdpApi'
+import { resolveOperationUrl } from './apiDocs'
+import { getBaseUrl } from './urlUtils'
// Mirrors UserDTO from the backend; role values come from the UserRole enum: ADMIN, USER.
export type User = {
@@ -67,7 +69,9 @@ export function userInitials(email: string | null): string {
export function useAuth() {
async function login(email: string, password: string): Promise {
- const newToken = await fetchToken(email, password)
+ const rootUri = `${getBaseUrl()}/`
+ const { url, method } = await resolveOperationUrl(rootUri, 'generateToken', '/tokens', 'POST')
+ const newToken = await fetchToken(email, password, url, method)
setAuthToken(newToken)
try {
const currentUser = (await fetchCurrentUser()) as User
diff --git a/tests/composables/useAuth.test.ts b/tests/composables/useAuth.test.ts
index 9cfb88e..ea6d001 100644
--- a/tests/composables/useAuth.test.ts
+++ b/tests/composables/useAuth.test.ts
@@ -99,12 +99,12 @@ describe('login', () => {
vi.stubGlobal('fetch', mockFetch)
const { login } = useAuth()
await login('user@example.com', 'secret')
- expect(mockFetch).toHaveBeenNthCalledWith(1, 'http://localhost/tokens', {
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com', password: 'secret' }),
})
- expect(mockFetch).toHaveBeenNthCalledWith(2, 'http://localhost/users/current', {
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost/users/current', {
headers: { Accept: 'application/json', Authorization: 'Bearer efIobn394nvJJFJ30...' },
})
})
From f8dec4acb6ea40b0eece9aa714ce951433b40509 Mon Sep 17 00:00:00 2001
From: Mihai Lefter
Date: Fri, 17 Jul 2026 21:47:29 +0200
Subject: [PATCH 02/13] Cache the api docs and gate login on its availability
---
src/App.vue | 6 ++++--
src/composables/apiDocs.ts | 33 +++++++++++++++++++++++++++++++--
src/composables/fdpApi.ts | 5 ++---
src/composables/useAuth.ts | 38 +++++++++++++++++++++++++++++++++-----
src/router/index.ts | 5 +++--
5 files changed, 73 insertions(+), 14 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index 6565b05..1ad62dd 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,7 +1,7 @@
@@ -203,7 +253,12 @@ onMounted(() => {
-