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(() => {

- @@ -224,7 +279,12 @@ onMounted(() => { :hide-role="isSelf" /> - @@ -262,7 +322,12 @@ onMounted(() => {

- diff --git a/src/views/UsersView.vue b/src/views/UsersView.vue index bc9a835..216159e 100644 --- a/src/views/UsersView.vue +++ b/src/views/UsersView.vue @@ -2,6 +2,9 @@ import { ref, onMounted } from 'vue' import { RouterLink } from 'vue-router' import { fetchUsers, deleteUser as apiDeleteUser } from '../composables/fdpApi' +import { bindOperation } from '../composables/apiDocs' +import { getRootUri } from '../composables/urlUtils' +import { createUserAvailable, deleteUserAvailable } from '../composables/useUsers' import { avatarColor } from '../composables/useAuth' import type { User } from '../composables/useAuth' import IconTrash from '../assets/icons/trash.svg?component' @@ -23,7 +26,8 @@ async function loadUsers() { loading.value = true error.value = null try { - const data = await fetchUsers() + const { url } = await bindOperation(getRootUri(), 'getUsers') + const data = await fetchUsers(url) users.value = [...(data as User[])].sort((a, b) => `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), ) @@ -38,7 +42,8 @@ async function loadUsers() { async function handleDelete(user: User) { if (!window.confirm(`Are you sure you want to delete ${user.firstName} ${user.lastName}?`)) return try { - await apiDeleteUser(user.uuid) + const { url, method } = await bindOperation(getRootUri(), 'deleteUser', { uuid: user.uuid }) + await apiDeleteUser(url, method) await loadUsers() } catch (err) { error.value = err instanceof Error ? err.message : 'Unable to delete user.' @@ -52,7 +57,9 @@ onMounted(loadUsers)

Users

- + Create user + + Create user

Loading…

@@ -73,7 +80,12 @@ onMounted(loadUsers)
-
diff --git a/tests/composables/apiDocs.test.ts b/tests/composables/apiDocs.test.ts index eb803b7..338c889 100644 --- a/tests/composables/apiDocs.test.ts +++ b/tests/composables/apiDocs.test.ts @@ -147,6 +147,52 @@ describe('bindOperation', () => { 'network error', ) }) + + it('substitutes pathParams into the resolved path template', async () => { + const { fetchRdfTurtle, fetchApiDocs } = await import('../../src/composables/fdpApi') + const { bindOperation } = await import('../../src/composables/apiDocs') + + vi.mocked(fetchRdfTurtle).mockResolvedValue(` + @prefix dcat: . + dcat:endpointDescription . + `) + vi.mocked(fetchApiDocs).mockResolvedValue(JSON.parse(readFixture('api-docs.json'))) + + expect(await bindOperation('http://localhost/', 'deleteUser', { uuid: 'abc-123' })).toEqual({ + url: 'http://localhost/users/abc-123', + method: 'DELETE', + }) + }) + + it('URL-encodes path param values, not just substitutes them verbatim', async () => { + const { fetchRdfTurtle, fetchApiDocs } = await import('../../src/composables/fdpApi') + const { bindOperation } = await import('../../src/composables/apiDocs') + + vi.mocked(fetchRdfTurtle).mockResolvedValue(` + @prefix dcat: . + dcat:endpointDescription . + `) + vi.mocked(fetchApiDocs).mockResolvedValue(JSON.parse(readFixture('api-docs.json'))) + + expect( + await bindOperation('http://localhost/', 'deleteUser', { uuid: 'a/b c' }), + ).toEqual({ url: 'http://localhost/users/a%2Fb%20c', method: 'DELETE' }) + }) + + it('rejects when a required path param is missing', async () => { + const { fetchRdfTurtle, fetchApiDocs } = await import('../../src/composables/fdpApi') + const { bindOperation } = await import('../../src/composables/apiDocs') + + vi.mocked(fetchRdfTurtle).mockResolvedValue(` + @prefix dcat: . + dcat:endpointDescription . + `) + vi.mocked(fetchApiDocs).mockResolvedValue(JSON.parse(readFixture('api-docs.json'))) + + await expect(bindOperation('http://localhost/', 'deleteUser', {})).rejects.toThrow( + "Missing path parameter 'uuid'", + ) + }) }) describe('resolveOperation', () => { From 08698fdc2af27e51a8ec212aeb4524a74be46133 Mon Sep 17 00:00:00 2001 From: Mihai Lefter Date: Mon, 17 Aug 2026 18:27:47 +0200 Subject: [PATCH 10/13] Guard certain routes against direct navigation when the backend does not offer them --- src/composables/useAuth.ts | 6 +++++- src/composables/useSearch.ts | 6 +++++- src/composables/useUsers.ts | 32 +++++++++++++++++++++----------- src/router/index.ts | 9 ++++++++- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/composables/useAuth.ts b/src/composables/useAuth.ts index e221b7d..036c52f 100644 --- a/src/composables/useAuth.ts +++ b/src/composables/useAuth.ts @@ -43,12 +43,16 @@ const getUserCurrentBinding: Promise = (async () => { /** Controls whether the "Edit profile" link is shown, based on whether this FDP's OpenAPI doc actually offers fetching the current user. */ export const getUserCurrentAvailable = ref(false) -getUserCurrentBinding + +/** Resolves once getUserCurrentAvailable is known; the router guard awaits it before allowing /users/current. */ +export const getUserCurrentChecked: Promise = getUserCurrentBinding .then(() => { getUserCurrentAvailable.value = true + return true }) .catch(() => { getUserCurrentAvailable.value = false + return false }) /** Controls whether the login button is shown, based on whether this FDP's OpenAPI doc actually offers token-based login. */ diff --git a/src/composables/useSearch.ts b/src/composables/useSearch.ts index 6781d01..4046f86 100644 --- a/src/composables/useSearch.ts +++ b/src/composables/useSearch.ts @@ -20,10 +20,14 @@ export const searchBinding: Promise = (async () => { /** Controls whether the search box is shown, based on whether this FDP's OpenAPI doc actually offers full-text search. */ export const searchAvailable = ref(false) -searchBinding + +/** Resolves once searchAvailable is known; the router guard awaits it before allowing /search. */ +export const searchChecked: Promise = searchBinding .then(() => { searchAvailable.value = true + return true }) .catch(() => { searchAvailable.value = false + return false }) diff --git a/src/composables/useUsers.ts b/src/composables/useUsers.ts index 52a6acf..52eeb2c 100644 --- a/src/composables/useUsers.ts +++ b/src/composables/useUsers.ts @@ -1,32 +1,42 @@ -import { ref } from 'vue' +import { ref, type Ref } from 'vue' import { bindOperation } from './apiDocs' import { getRootUri } from './urlUtils' import { configReady } from '@/config' /** - * True if this FDP's OpenAPI doc offers operationId. No pathParams passed: bindOperation only - * substitutes them if given, so a {placeholder} is left as-is, harmless here since only whether - * it resolved matters. Awaits configReady since this evaluates before config loads (see - * config.ts). + * True if this FDP's OpenAPI doc offers operationId, in a ref for template gating, plus a + * promise a caller can await to know the check has actually completed (the router guard can't + * just read the ref synchronously, it may not have settled yet). No pathParams passed: + * bindOperation only substitutes them if given, so a {placeholder} is left as-is, harmless here + * since only whether it resolved matters. Awaits configReady since this evaluates before config + * loads (see config.ts). */ -function availabilityRef(operationId: string) { +function availability(operationId: string): { available: Ref; checked: Promise } { const available = ref(false) - ;(async () => { + const checked = (async () => { await configReady return bindOperation(getRootUri(), operationId) })() .then(() => { available.value = true + return true }) .catch(() => { available.value = false + return false }) - return available + return { available, checked } } /** Controls whether the "Users" menu link is shown, based on whether this FDP's OpenAPI doc actually offers listing users. */ -export const getUsersAvailable = availabilityRef('getUsers') +export const { available: getUsersAvailable, checked: getUsersChecked } = availability('getUsers') + /** Controls whether "create user" affordances (the list's link, the form's submit button) are shown, based on whether this FDP's OpenAPI doc actually offers creating a user. */ -export const createUserAvailable = availabilityRef('createUser') +export const { available: createUserAvailable, checked: createUserChecked } = + availability('createUser') + /** Controls whether the per-user delete button is shown, based on whether this FDP's OpenAPI doc actually offers deleting a user. */ -export const deleteUserAvailable = availabilityRef('deleteUser') +export const { available: deleteUserAvailable } = availability('deleteUser') + +/** Resolves once known whether this FDP's OpenAPI doc offers viewing another user's profile; the router guard awaits it before allowing /users/:id. */ +export const { checked: getUserChecked } = availability('getUser') diff --git a/src/router/index.ts b/src/router/index.ts index 080a012..33da491 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -5,7 +5,9 @@ import SearchView from '@/views/SearchView.vue' import NotAllowedView from '@/views/NotAllowedView.vue' import UsersView from '@/views/UsersView.vue' import UserFormView from '@/views/UserFormView.vue' -import { useAuth, loginAvailabilityChecked } from '@/composables/useAuth' +import { useAuth, loginAvailabilityChecked, getUserCurrentChecked } from '@/composables/useAuth' +import { getUsersChecked, createUserChecked, getUserChecked } from '@/composables/useUsers' +import { searchChecked } from '@/composables/useSearch' declare module 'vue-router' { interface RouteMeta { @@ -74,6 +76,11 @@ router.beforeEach(async (to) => { if (to.meta.requiresAuth && !isLoggedIn.value) return '/login' if (to.meta.requiresAdmin && !isAdmin.value) return '/not-allowed' if (to.name === 'login' && !(await loginAvailabilityChecked)) return '/' + if (to.name === 'users' && !(await getUsersChecked)) return '/' + if (to.name === 'user-create' && !(await createUserChecked)) return '/' + if (to.name === 'user-detail' && !(await getUserChecked)) return '/' + if (to.name === 'user-profile' && !(await getUserCurrentChecked)) return '/' + if (to.name === 'search' && !(await searchChecked)) return '/' }) export default router From f734256a7b6bb7c2d1641005dbe9a8e8b92b07c1 Mon Sep 17 00:00:00 2001 From: Mihai Lefter Date: Tue, 18 Aug 2026 10:53:33 +0200 Subject: [PATCH 11/13] Centralize the configReady and bindOperation --- src/composables/operationBinding.ts | 43 ++++++++++++++++++++ src/composables/useAuth.ts | 62 ++++++++--------------------- src/composables/useSearch.ts | 34 +++++----------- src/composables/useUsers.ts | 43 +++++--------------- 4 files changed, 79 insertions(+), 103 deletions(-) create mode 100644 src/composables/operationBinding.ts diff --git a/src/composables/operationBinding.ts b/src/composables/operationBinding.ts new file mode 100644 index 0000000..b058b66 --- /dev/null +++ b/src/composables/operationBinding.ts @@ -0,0 +1,43 @@ +import { ref, type Ref } from 'vue' +import { bindOperation, type OperationBinding } from './apiDocs' +import { getRootUri } from './urlUtils' +import { configReady } from '@/config' + +/** + * bindOperation, wrapped to await configReady first. Needed by module-level callers (useAuth.ts, + * useSearch.ts, useUsers.ts): they evaluate before main.ts awaits loadClientConfig(), so + * getRootUri() would throw if called immediately. Component-level call sites that only run after + * mount (e.g. UsersView.vue's loadUsers) don't need this, config is already loaded by then, they + * can call bindOperation directly. + */ +export function readyBinding( + operationId: string, + pathParams?: Record, +): Promise { + return (async () => { + await configReady + return bindOperation(getRootUri(), operationId, pathParams) + })() +} + +/** + * True if binding resolves, in a ref for template gating, plus a promise a caller can await to + * know the check has actually completed (a router guard can't just read the ref synchronously, + * it may not have settled yet). + */ +export function deriveAvailability(binding: Promise): { + available: Ref + checked: Promise +} { + const available = ref(false) + const checked = binding + .then(() => { + available.value = true + return true + }) + .catch(() => { + available.value = false + return false + }) + return { available, checked } +} diff --git a/src/composables/useAuth.ts b/src/composables/useAuth.ts index 036c52f..5f15c63 100644 --- a/src/composables/useAuth.ts +++ b/src/composables/useAuth.ts @@ -1,8 +1,6 @@ import { ref, computed } from 'vue' import { fetchToken, fetchCurrentUser, setAuthToken } from './fdpApi' -import { bindOperation, type OperationBinding } from './apiDocs' -import { getRootUri } from './urlUtils' -import { configReady } from '@/config' +import { readyBinding, deriveAvailability } from './operationBinding' // Mirrors UserDTO from the backend; role values come from the UserRole enum: ADMIN, USER. export type User = { @@ -25,53 +23,27 @@ const isAdmin = computed(() => user.value?.role === 'ADMIN') setAuthToken(token.value) -/** - * Resolved once, reused for gating (loginAvailable) and login()'s actual request. Awaits - * configReady since this evaluates before config loads (see config.ts); can reject if - * generateToken isn't offered (see apiDocs.ts's bindOperation). - */ -const generateTokenBinding: Promise = (async () => { - await configReady - return bindOperation(getRootUri(), 'generateToken') -})() +/** Resolved once, reused for gating (loginAvailable) and login()'s actual request. */ +const generateTokenBinding = readyBinding('generateToken') /** Same as generateTokenBinding, for the current-authenticated-user endpoint. */ -const getUserCurrentBinding: Promise = (async () => { - await configReady - return bindOperation(getRootUri(), 'getUserCurrent') -})() - -/** Controls whether the "Edit profile" link is shown, based on whether this FDP's OpenAPI doc actually offers fetching the current user. */ -export const getUserCurrentAvailable = ref(false) - -/** Resolves once getUserCurrentAvailable is known; the router guard awaits it before allowing /users/current. */ -export const getUserCurrentChecked: Promise = getUserCurrentBinding - .then(() => { - getUserCurrentAvailable.value = true - return true - }) - .catch(() => { - getUserCurrentAvailable.value = false - return false - }) - -/** Controls whether the login button is shown, based on whether this FDP's OpenAPI doc actually offers token-based login. */ -export const loginAvailable = ref(false) +const getUserCurrentBinding = readyBinding('getUserCurrent') + +/** + * getUserCurrentAvailable controls whether the "Edit profile" link is shown, based on whether + * this FDP's OpenAPI doc actually offers fetching the current user. getUserCurrentChecked + * resolves once that's known; the router guard awaits it before allowing /users/current. + */ +export const { available: getUserCurrentAvailable, checked: getUserCurrentChecked } = + deriveAvailability(getUserCurrentBinding) /** - * Resolves once login availability is known; the router guard awaits it before allowing /login. - * generateTokenBinding rejects if login isn't offered (or couldn't be confirmed), in which case - * this resolves to false rather than propagating the rejection. + * loginAvailable controls whether the login button is shown, based on whether this FDP's OpenAPI + * doc actually offers token-based login. loginAvailabilityChecked resolves once that's known; + * the router guard awaits it before allowing /login. */ -export const loginAvailabilityChecked: Promise = generateTokenBinding - .then(() => { - loginAvailable.value = true - return true - }) - .catch(() => { - loginAvailable.value = false - return false - }) +export const { available: loginAvailable, checked: loginAvailabilityChecked } = + deriveAvailability(generateTokenBinding) function clearSession() { token.value = null diff --git a/src/composables/useSearch.ts b/src/composables/useSearch.ts index 4046f86..a1c7012 100644 --- a/src/composables/useSearch.ts +++ b/src/composables/useSearch.ts @@ -1,33 +1,19 @@ -import { ref } from 'vue' -import { bindOperation, type OperationBinding } from './apiDocs' -import { getRootUri } from './urlUtils' -import { configReady } from '@/config' +import { readyBinding, deriveAvailability } from './operationBinding' /** * Resolved once, reused by App.vue (gates the box's visibility) and SearchView.vue (the actual - * request). Awaits configReady since this evaluates before config loads (see config.ts); can - * reject if search_1 isn't offered (see apiDocs.ts's bindOperation), already handled by - * SearchView.vue's search(). + * request). * * The operationId is 'search_1', not the more obvious 'search': the backend has two different * operations whose Java method is literally named search(), and 'search' itself was claimed by * the other one (a saved-query endpoint), confirmed against the live generated api-docs. */ -export const searchBinding: Promise = (async () => { - await configReady - return bindOperation(getRootUri(), 'search_1') -})() +export const searchBinding = readyBinding('search_1') -/** Controls whether the search box is shown, based on whether this FDP's OpenAPI doc actually offers full-text search. */ -export const searchAvailable = ref(false) - -/** Resolves once searchAvailable is known; the router guard awaits it before allowing /search. */ -export const searchChecked: Promise = searchBinding - .then(() => { - searchAvailable.value = true - return true - }) - .catch(() => { - searchAvailable.value = false - return false - }) +/** + * searchAvailable controls whether the search box is shown, based on whether this FDP's OpenAPI + * doc actually offers full-text search. searchChecked resolves once that's known; the router + * guard awaits it before allowing /search. + */ +export const { available: searchAvailable, checked: searchChecked } = + deriveAvailability(searchBinding) diff --git a/src/composables/useUsers.ts b/src/composables/useUsers.ts index 52eeb2c..416c183 100644 --- a/src/composables/useUsers.ts +++ b/src/composables/useUsers.ts @@ -1,42 +1,17 @@ -import { ref, type Ref } from 'vue' -import { bindOperation } from './apiDocs' -import { getRootUri } from './urlUtils' -import { configReady } from '@/config' - -/** - * True if this FDP's OpenAPI doc offers operationId, in a ref for template gating, plus a - * promise a caller can await to know the check has actually completed (the router guard can't - * just read the ref synchronously, it may not have settled yet). No pathParams passed: - * bindOperation only substitutes them if given, so a {placeholder} is left as-is, harmless here - * since only whether it resolved matters. Awaits configReady since this evaluates before config - * loads (see config.ts). - */ -function availability(operationId: string): { available: Ref; checked: Promise } { - const available = ref(false) - const checked = (async () => { - await configReady - return bindOperation(getRootUri(), operationId) - })() - .then(() => { - available.value = true - return true - }) - .catch(() => { - available.value = false - return false - }) - return { available, checked } -} +import { readyBinding, deriveAvailability } from './operationBinding' /** Controls whether the "Users" menu link is shown, based on whether this FDP's OpenAPI doc actually offers listing users. */ -export const { available: getUsersAvailable, checked: getUsersChecked } = availability('getUsers') +export const { available: getUsersAvailable, checked: getUsersChecked } = deriveAvailability( + readyBinding('getUsers'), +) /** Controls whether "create user" affordances (the list's link, the form's submit button) are shown, based on whether this FDP's OpenAPI doc actually offers creating a user. */ -export const { available: createUserAvailable, checked: createUserChecked } = - availability('createUser') +export const { available: createUserAvailable, checked: createUserChecked } = deriveAvailability( + readyBinding('createUser'), +) /** Controls whether the per-user delete button is shown, based on whether this FDP's OpenAPI doc actually offers deleting a user. */ -export const { available: deleteUserAvailable } = availability('deleteUser') +export const { available: deleteUserAvailable } = deriveAvailability(readyBinding('deleteUser')) /** Resolves once known whether this FDP's OpenAPI doc offers viewing another user's profile; the router guard awaits it before allowing /users/:id. */ -export const { checked: getUserChecked } = availability('getUser') +export const { checked: getUserChecked } = deriveAvailability(readyBinding('getUser')) From c4b3b8aab6255bc4e3def3b5853e41ada606a531 Mon Sep 17 00:00:00 2001 From: Mihai Lefter Date: Tue, 18 Aug 2026 12:51:32 +0200 Subject: [PATCH 12/13] Update use users and polish comments --- src/assets/main.css | 2 +- src/composables/apiDocs.ts | 19 ++++++--------- src/composables/operationBinding.ts | 14 +++++------ src/composables/useAuth.ts | 14 +++++------ src/composables/useSearch.ts | 15 ++++-------- src/composables/useUsers.ts | 37 +++++++++++++++++++++++++---- src/config.ts | 6 ++--- src/main.ts | 5 ++-- src/views/UserFormView.vue | 32 ++++++++++--------------- src/views/UsersView.vue | 16 ++++++------- tests/composables/apiDocs.test.ts | 7 +++--- tests/composables/useAuth.test.ts | 8 +++---- 12 files changed, 89 insertions(+), 86 deletions(-) diff --git a/src/assets/main.css b/src/assets/main.css index 65b46b6..de8a1f3 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -66,7 +66,7 @@ body { flex: 1; } -/* Shown in place of #app's content if the app fails to start before Vue ever mounts (see main.ts). */ +/* Fallback shown when startup fails before Vue mounts. */ .startup-error { padding: var(--fdp-space-8); color: var(--fdp-color-danger); diff --git a/src/composables/apiDocs.ts b/src/composables/apiDocs.ts index 9b349f9..6d60b47 100644 --- a/src/composables/apiDocs.ts +++ b/src/composables/apiDocs.ts @@ -3,13 +3,10 @@ import { parseTurtle, resolveSubjectUri, getNodeRefs } from './rdfUtils' import { DCAT_ENDPOINT_DESCRIPTION } from './vocabularies' /** - * Finds candidate URLs for the FDP's OpenAPI/SmartAPI document: reads dcat:endpointDescription - * from the FDP root's Turtle, and adds a /v3/api-docs guess as a fallback. Always called with the - * root URI: dcat:endpointDescription is a root-only property per the FDP spec (Section 4.2.1), so - * there's nothing to discover by walking dct:isPartOf up from elsewhere, and callers already know - * the root directly via getRootUri(), no discovery needed to find it either. - * FDP 1.22+ can declare more than one (e.g. the OpenAPI doc and the Swagger UI page) in no - * guaranteed order, and none of the returned URLs are verified to respond; callers must try each. + * 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 { const store = parseTurtle(await fetchRdfTurtle(rootUri)) @@ -94,11 +91,9 @@ function substitutePathParams(path: string, pathParams: Record): } /** - * Resolves an operationId to the URL/method to call it, substituting pathParams into any - * {name}-style placeholders in the resolved path template. No fallback: the only guessed URL in - * this module is discoverApiDocsUrls's /v3/api-docs guess, for locating the doc itself. Once we - * have the doc, resolveOperation gives a definitive answer, offered or not, so this rejects - * rather than guessing a path the backend has already told us doesn't exist. + * 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, diff --git a/src/composables/operationBinding.ts b/src/composables/operationBinding.ts index b058b66..d908c26 100644 --- a/src/composables/operationBinding.ts +++ b/src/composables/operationBinding.ts @@ -4,11 +4,9 @@ import { getRootUri } from './urlUtils' import { configReady } from '@/config' /** - * bindOperation, wrapped to await configReady first. Needed by module-level callers (useAuth.ts, - * useSearch.ts, useUsers.ts): they evaluate before main.ts awaits loadClientConfig(), so - * getRootUri() would throw if called immediately. Component-level call sites that only run after - * mount (e.g. UsersView.vue's loadUsers) don't need this, config is already loaded by then, they - * can call bindOperation directly. + * Like bindOperation, but safe for module-level callers that evaluate before main.ts has awaited + * loadClientConfig(). Waiting for configReady avoids calling getRootUri() before runtime config + * exists. */ export function readyBinding( operationId: string, @@ -21,9 +19,9 @@ export function readyBinding( } /** - * True if binding resolves, in a ref for template gating, plus a promise a caller can await to - * know the check has actually completed (a router guard can't just read the ref synchronously, - * it may not have settled yet). + * Turns a binding into both a reactive UI flag and an awaitable router-guard check. + * Rejections mean "not available" here; consumers that need the underlying error should await the + * original binding instead. */ export function deriveAvailability(binding: Promise): { available: Ref diff --git a/src/composables/useAuth.ts b/src/composables/useAuth.ts index 5f15c63..0b68f2b 100644 --- a/src/composables/useAuth.ts +++ b/src/composables/useAuth.ts @@ -23,24 +23,22 @@ const isAdmin = computed(() => user.value?.role === 'ADMIN') setAuthToken(token.value) -/** Resolved once, reused for gating (loginAvailable) and login()'s actual request. */ +/** Reused by both availability checks and the login request. */ const generateTokenBinding = readyBinding('generateToken') -/** Same as generateTokenBinding, for the current-authenticated-user endpoint. */ +/** Reused anywhere the current authenticated user endpoint is needed. */ const getUserCurrentBinding = readyBinding('getUserCurrent') /** - * getUserCurrentAvailable controls whether the "Edit profile" link is shown, based on whether - * this FDP's OpenAPI doc actually offers fetching the current user. getUserCurrentChecked - * resolves once that's known; the router guard awaits it before allowing /users/current. + * Drives the "Edit profile" affordance and the /users/current route guard from the same + * OpenAPI-backed check. */ export const { available: getUserCurrentAvailable, checked: getUserCurrentChecked } = deriveAvailability(getUserCurrentBinding) /** - * loginAvailable controls whether the login button is shown, based on whether this FDP's OpenAPI - * doc actually offers token-based login. loginAvailabilityChecked resolves once that's known; - * the router guard awaits it before allowing /login. + * Drives the login button and /login route guard from whether this FDP advertises token-based + * login in its OpenAPI document. */ export const { available: loginAvailable, checked: loginAvailabilityChecked } = deriveAvailability(generateTokenBinding) diff --git a/src/composables/useSearch.ts b/src/composables/useSearch.ts index a1c7012..2729a3c 100644 --- a/src/composables/useSearch.ts +++ b/src/composables/useSearch.ts @@ -1,19 +1,12 @@ import { readyBinding, deriveAvailability } from './operationBinding' -/** - * Resolved once, reused by App.vue (gates the box's visibility) and SearchView.vue (the actual - * request). - * - * The operationId is 'search_1', not the more obvious 'search': the backend has two different - * operations whose Java method is literally named search(), and 'search' itself was claimed by - * the other one (a saved-query endpoint), confirmed against the live generated api-docs. - */ +// The operationId is search_1, not search: the generated OpenAPI doc uses search for a different +// saved-query endpoint because both backend Java methods are named search(). export const searchBinding = readyBinding('search_1') /** - * searchAvailable controls whether the search box is shown, based on whether this FDP's OpenAPI - * doc actually offers full-text search. searchChecked resolves once that's known; the router - * guard awaits it before allowing /search. + * Drives both the header search box and /search route guard from whether this FDP advertises + * full-text search in its OpenAPI document. */ export const { available: searchAvailable, checked: searchChecked } = deriveAvailability(searchBinding) diff --git a/src/composables/useUsers.ts b/src/composables/useUsers.ts index 416c183..f37651b 100644 --- a/src/composables/useUsers.ts +++ b/src/composables/useUsers.ts @@ -1,17 +1,46 @@ +import { + fetchUsers as apiFetchUsers, + createUser as apiCreateUser, + deleteUser as apiDeleteUser, +} from './fdpApi' import { readyBinding, deriveAvailability } from './operationBinding' -/** Controls whether the "Users" menu link is shown, based on whether this FDP's OpenAPI doc actually offers listing users. */ +/** Drives the "Users" menu link and /users route guard. */ export const { available: getUsersAvailable, checked: getUsersChecked } = deriveAvailability( readyBinding('getUsers'), ) -/** Controls whether "create user" affordances (the list's link, the form's submit button) are shown, based on whether this FDP's OpenAPI doc actually offers creating a user. */ +/** Drives create-user UI and the /users/create route guard. */ export const { available: createUserAvailable, checked: createUserChecked } = deriveAvailability( readyBinding('createUser'), ) -/** Controls whether the per-user delete button is shown, based on whether this FDP's OpenAPI doc actually offers deleting a user. */ +/** Controls whether the per-user delete button is shown. */ export const { available: deleteUserAvailable } = deriveAvailability(readyBinding('deleteUser')) -/** Resolves once known whether this FDP's OpenAPI doc offers viewing another user's profile; the router guard awaits it before allowing /users/:id. */ +/** Drives the /users/:id route guard. */ export const { checked: getUserChecked } = deriveAvailability(readyBinding('getUser')) + +/** Lists all users through the OpenAPI-resolved getUsers operation. */ +export async function fetchUsers(): Promise { + const { url } = await readyBinding('getUsers') + return apiFetchUsers(url) +} + +/** Creates a user through the OpenAPI-resolved createUser operation. */ +export async function createUser(data: { + firstName: string + lastName: string + email: string + role: string + password: string +}): Promise { + const { url, method } = await readyBinding('createUser') + return apiCreateUser(data, url, method) +} + +/** Deletes a user through the OpenAPI-resolved deleteUser operation. */ +export async function deleteUser(uuid: string): Promise { + const { url, method } = await readyBinding('deleteUser', { uuid }) + await apiDeleteUser(url, method) +} diff --git a/src/config.ts b/src/config.ts index f1eb7c8..894f2d6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,10 +21,8 @@ const CONFIG_FILE_PATH = '/config.json' // and then use getClientConfig() whenever it is needed. let clientConfig: ClientConfig | undefined -// Some module-level code (e.g. in useAuth.ts) needs to call getClientConfig()-dependent functions -// as soon as its module evaluates, which happens before main.ts gets a chance to call and await -// loadClientConfig(). Such code should await configReady first, so it runs once config has -// actually finished loading instead of hitting a "not loaded yet" error every time. +// Module-level bindings can evaluate before runtime config is loaded; configReady lets them wait +// for loadClientConfig(). let resolveConfigReady: () => void export const configReady: Promise = new Promise((resolve) => { resolveConfigReady = resolve diff --git a/src/main.ts b/src/main.ts index b2a1f0d..8ad5783 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,9 +8,8 @@ import { loadClientConfig } from '@/config' const app = createApp(App) -// Load runtime configuration from a JSON file. Afterwards, we can use getClientConfig() to -// access the result at any time. Wrapped in try/catch (unlike the rest of this file) so a -// failure shows a visible error instead of leaving a blank page. +// Show a visible startup error when runtime config cannot be loaded; otherwise Vue never mounts +// and the page is blank. try { await loadClientConfig() } catch (err) { diff --git a/src/views/UserFormView.vue b/src/views/UserFormView.vue index fa61917..9fe9ff2 100644 --- a/src/views/UserFormView.vue +++ b/src/views/UserFormView.vue @@ -1,10 +1,10 @@