Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { useAuth } from './composables/useAuth'
import { useAuth, loginAvailable } from './composables/useAuth'
import { searchAvailable } from './composables/useSearch'
import UserMenu from './components/UserMenu.vue'
import { getBaseUrl } from '@/composables/urlUtils.ts'

Expand Down Expand Up @@ -51,7 +52,12 @@ async function openAbout() {
</span>
</RouterLink>

<form class="header-search" role="search" @submit.prevent="submitSearch">
<form
v-if="searchAvailable"
class="header-search"
role="search"
@submit.prevent="submitSearch"
>
<button type="submit" class="header-search__icon" aria-label="Search">
<svg
xmlns="http://www.w3.org/2000/svg"
Expand Down Expand Up @@ -101,9 +107,11 @@ async function openAbout() {
</form>

<nav class="app-header__nav">
<RouterLink v-if="!isLoggedIn" to="/login" class="header-login-btn">Log in</RouterLink>
<RouterLink v-if="loginAvailable && !isLoggedIn" to="/login" class="header-login-btn"
>Log in</RouterLink
>

<UserMenu v-else />
<UserMenu v-else-if="isLoggedIn" />
</nav>
</div>
</header>
Expand Down
6 changes: 6 additions & 0 deletions src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ body {
flex: 1;
}

/* Fallback shown when startup fails before Vue mounts. */
.startup-error {
padding: var(--fdp-space-8);
color: var(--fdp-color-danger);
}

button,
input,
select,
Expand Down
12 changes: 9 additions & 3 deletions src/components/UserMenu.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useAuth, avatarColor, userInitials } from '../composables/useAuth'
import { useAuth, avatarColor, userInitials, getUserCurrentAvailable } from '../composables/useAuth'
import { getUsersAvailable } from '../composables/useUsers'
import IconUsers from '../assets/icons/users.svg?component'
import IconUserEdit from '../assets/icons/user-edit.svg?component'
import IconLogOut from '../assets/icons/log-out.svg?component'
Expand Down Expand Up @@ -61,7 +62,7 @@ onUnmounted(() => {
</button>

<div v-if="menuOpen" class="user-dropdown">
<template v-if="isAdmin">
<template v-if="isAdmin && getUsersAvailable">
<div class="user-dropdown__section-header">FAIR Data Point</div>
<RouterLink to="/users" class="user-dropdown__item" @click="menuOpen = false">
<IconUsers />
Expand All @@ -72,7 +73,12 @@ onUnmounted(() => {
<div class="user-dropdown__section-header">
{{ user ? `${user.firstName} ${user.lastName}` : userEmail }}
</div>
<RouterLink to="/users/current" class="user-dropdown__item" @click="menuOpen = false">
<RouterLink
v-if="getUserCurrentAvailable"
to="/users/current"
class="user-dropdown__item"
@click="menuOpen = false"
>
<IconUserEdit />
Edit profile
</RouterLink>
Expand Down
110 changes: 110 additions & 0 deletions src/composables/apiDocs.ts
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])]

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.

Although JavaScript Set preserves insertion order, in many other languages this is not guaranteed.
Maybe a comment to point this out?

Suggested change
return [...new Set([...declaredUrls, fallbackUrl])]
// Note that JavaScript Set preserves insertion order
return [...new Set([...declaredUrls, fallbackUrl])]

}

type OpenApiOperation = { operationId?: string }
type OpenApiDoc = { paths?: Record<string, Record<string, OpenApiOperation>> }

function isOpenApiDoc(doc: unknown): doc is OpenApiDoc {

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
function isOpenApiDoc(doc: unknown): doc is OpenApiDoc {
/** Duck-typing: If it looks like an OpenApiDoc, treat it as one. */
function isOpenApiDoc(doc: unknown): doc is OpenApiDoc {

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. */

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.

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 }
}
109 changes: 58 additions & 51 deletions 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 '-'}"
        )

Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { getBaseUrl } from './urlUtils'

let authToken: string | null = null

/** Stores the JWT token to be included in subsequent requests as a Bearer header. */
Expand All @@ -21,17 +19,32 @@ export async function fetchRdfTurtle(uri: string): Promise<string> {
return fetchRdf(uri, 'text/turtle')
}

/** Fetches an OpenAPI document as JSON from the given URL. */
export async function fetchApiDocs(uri: string): Promise<unknown> {
const headers: Record<string, string> = { 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<unknown[]> {
const base = getBaseUrl()
export async function searchResources(
query: string,
url: string,
method: string,
): Promise<unknown[]> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
}
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
const response = await fetch(`${base}/search?page=0&size=20`, {
method: 'POST',
const searchUrl = new URL(url)
searchUrl.searchParams.set('page', '0')
searchUrl.searchParams.set('size', '20')
const response = await fetch(searchUrl.toString(), {
method,
headers,
body: JSON.stringify({ query }),
})
Expand All @@ -40,30 +53,27 @@ export async function searchResources(query: string): Promise<unknown[]> {
}

/** Lists all users registered on the FDP. */
export async function fetchUsers(): Promise<unknown[]> {
const base = getBaseUrl()
export async function fetchUsers(url: string): Promise<unknown[]> {
const headers: Record<string, string> = { Accept: 'application/json' }
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
const response = await fetch(`${base}/users`, { headers })
const response = await fetch(url, { headers })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json() as Promise<unknown[]>
}

/** Deletes a user by UUID. */
export async function deleteUser(uuid: string): Promise<void> {
const base = getBaseUrl()
/** Deletes a user. */
export async function deleteUser(url: string, method: string): Promise<void> {
const headers: Record<string, string> = {}
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
const response = await fetch(`${base}/users/${uuid}`, { method: 'DELETE', headers })
const response = await fetch(url, { method, headers })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
}

/** Fetches a single user's profile by UUID. */
export async function fetchUser(uuid: string): Promise<unknown> {
const base = getBaseUrl()
/** Fetches a single user's profile. */
export async function fetchUser(url: string): Promise<unknown> {
const headers: Record<string, string> = { Accept: 'application/json' }
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
const response = await fetch(`${base}/users/${uuid}`, { headers })
const response = await fetch(url, { headers })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
Expand All @@ -72,24 +82,23 @@ export async function fetchUser(uuid: string): Promise<unknown> {
* Creates a new user; body mirrors the backend's UserCreateDTO.
* Error responses are assumed to carry { message: string } (e.g. "Email '...' is already taken").
*/
export async function createUser(data: {
firstName: string
lastName: string
email: string
role: string
password: string
}): Promise<unknown> {
const base = getBaseUrl()
export async function createUser(
data: {
firstName: string
lastName: string
email: string
role: string
password: string
},
url: string,
method: string,
): Promise<unknown> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
}
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
const response = await fetch(`${base}/users`, {
method: 'POST',
headers,
body: JSON.stringify(data),
})
const response = await fetch(url, { method, headers, body: JSON.stringify(data) })
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error((body as { message?: string })?.message ?? `HTTP ${response.status}`)
Expand All @@ -99,20 +108,16 @@ export async function createUser(data: {

/** Updates a user's profile fields; body mirrors the backend's UserChangeDTO. */
export async function updateUser(
uuid: string,
data: { firstName: string; lastName: string; email: string; role: string },
url: string,
method: string,
): Promise<unknown> {
const base = getBaseUrl()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
}
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
const response = await fetch(`${base}/users/${uuid}`, {
method: 'PUT',
headers,
body: JSON.stringify(data),
})
const response = await fetch(url, { method, headers, body: JSON.stringify(data) })
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error((body as { message?: string })?.message ?? `HTTP ${response.status}`)
Expand All @@ -121,36 +126,38 @@ export async function updateUser(
}

/** Updates a user's password. */
export async function updateUserPassword(uuid: string, password: string): Promise<void> {
const base = getBaseUrl()
export async function updateUserPassword(
password: string,
url: string,
method: string,
): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
const response = await fetch(`${base}/users/${uuid}/password`, {
method: 'PUT',
headers,
body: JSON.stringify({ password }),
})
const response = await fetch(url, { method, headers, body: JSON.stringify({ password }) })
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error((body as { message?: string })?.message ?? `HTTP ${response.status}`)
}
}

/** Fetches the currently authenticated user's profile. */
export async function fetchCurrentUser(): Promise<unknown> {
const base = getBaseUrl()
export async function fetchCurrentUser(url: string): Promise<unknown> {
const headers: Record<string, string> = { Accept: 'application/json' }
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
const response = await fetch(`${base}/users/current`, { headers })
const response = await fetch(url, { headers })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}

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