Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
.nitro
.cache
dist
app/generated/api-reference

# Node dependencies
node_modules
Expand Down
95 changes: 19 additions & 76 deletions app/components/ApiEndpoint.vue
Original file line number Diff line number Diff line change
@@ -1,68 +1,15 @@
<script setup lang="ts">
import type {
OpenAPIObject,
RequestBodyObject,
SchemaObject,
} from 'openapi3-ts/oas30';
import type {
DerefedOperationObject,
FlattenedOperationObject,
} from '~/types';

const openapi = inject<OpenAPIObject>('openapi')!;
import type { ApiReferenceOperation } from '~/types';

const props = defineProps<{
operation: FlattenedOperationObject<DerefedOperationObject>;
operation: ApiReferenceOperation;
}>();

const requestBodyObject = computed(() => {
if (!props.operation.requestBody) return null;

return '$ref' in props.operation.requestBody ? resolveOasRef<RequestBodyObject>(openapi, props.operation.requestBody.$ref) : props.operation.requestBody ?? null;
});

const requestBodySchema = computed(() => {
const contentSchema = requestBodyObject.value?.content?.['application/json']?.schema;

if (contentSchema) {
return flattenSchema(openapi, contentSchema);
}

return null;
});

const responseBodyObjects = computed(() => {
return Object.fromEntries(
Object.entries(props.operation.responses).map(([code, response]) => {
return [
code,
response && '$ref' in response ? resolveOasRef<RequestBodyObject>(openapi, response.$ref) : response ?? null,
];
}));
});

const flattenedResponseBodySchemas = computed(() => {
return Object.entries(responseBodyObjects.value).map(([_, response]: [string, RequestBodyObject | null]) => {
const contentSchema = response?.content?.['application/json']?.schema;

if (contentSchema) {
return flattenSchema(openapi, contentSchema);
}

return null;
});
});

const responseBodyExample = computed(() => {
const responseSchema = responseBodyObjects?.value?.['200']?.content?.['application/json']?.schema
|| responseBodyObjects?.value?.['200']?.content?.['application/text']?.schema;

if (responseSchema) {
return responseToExample(openapi, responseSchema);
}

return null;
});
const responseTabs = computed(() => props.operation.responses.map((response, index) => ({
label: response.code,
meta: response,
index,
})));

type StatusCodeDescriptions = {
[key: number]: string;
Expand Down Expand Up @@ -118,14 +65,14 @@ const statusCodeDescriptions: StatusCodeDescriptions = {
v-for="param of operation.parameters"
:key="param.name"
:name="param.name"
:type="(param.schema as SchemaObject)?.type"
:type="param.schema?.type"
:ui="{
root: 'mb-0',
description: 'mt-2',
}"
class="[&_p]:my-0"
>
<MDC
<ApiInlineMarkdown
v-if="param.description"
:value="param.description"
/>
Expand All @@ -134,16 +81,16 @@ const statusCodeDescriptions: StatusCodeDescriptions = {
</div>

<div
v-if="requestBodyObject && requestBodySchema"
v-if="operation.requestBody?.schema"
class="mb-12 last:mb-0"
>
<ProseH4 :id="slugify(operation.summary!) + '-request'">
Request Body
</ProseH4>
<ProseP v-if="requestBodyObject.description">
{{ requestBodyObject.description }}
<ProseP v-if="operation.requestBody.description">
{{ operation.requestBody.description }}
</ProseP>
<ApiParams :param="requestBodySchema" />
<ApiParams :param="operation.requestBody.schema" />
</div>

<div class="mb-12 last:mb-0">
Expand All @@ -155,11 +102,7 @@ const statusCodeDescriptions: StatusCodeDescriptions = {
</ProseH4>
<UTabs
variant="link"
:items="Object.keys(responseBodyObjects).map((code, index) => ({
label: code,
meta: responseBodyObjects[code],
index,
}))"
:items="responseTabs"
:unmount-on-hide="false"
>
<template #default="{ item }">
Expand All @@ -183,23 +126,23 @@ const statusCodeDescriptions: StatusCodeDescriptions = {
{{ item.meta.description }}
</div>
<ApiParams
v-if="flattenedResponseBodySchemas[item.index]"
:param="flattenedResponseBodySchemas[item.index]!"
v-if="item.meta.schema"
:param="item.meta.schema"
/>
</template>
</UTabs>
</div>
</div>
<div class="grow sticky top-16 w-full">
<MDC
v-if="'x-codeSamples' in operation"
v-if="operation['x-codeSamples']?.length"
:key="`code-samples-${operation.method}-${operation.path}`"
:value="codeSamplesMd(operation)"
/>
<MDC
v-if="responseBodyExample"
v-if="operation.responseExample"
:key="`response-example-${operation.method}-${operation.path}`"
:value="preMd('json', 'Response Example', responseBodyExample)"
:value="preMd('json', 'Response Example', operation.responseExample)"
/>
</div>
</UPageBody>
Expand Down
34 changes: 34 additions & 0 deletions app/components/ApiInlineMarkdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
/**
* Renderer for OpenAPI parameter/response descriptions.
*
* The full <MDC> component spins up the markdown parser + Shiki per instance,
* and the API reference renders thousands of these recursively, which exhausts
* the prerender heap. The overwhelming majority of descriptions are a single
* line of plain text with no markdown, so we render those as a plain paragraph
* and avoid the parser entirely.
*
* Anything containing markdown syntax (inline code, links, emphasis, lists,
* code fences, etc.) falls back to <MDC> so its output stays correct.
*/
import { computed } from 'vue';

const props = defineProps<{
value: string;
}>();

// Defer to MDC for a faithful render when the description contains any markdown
// control character or a line break - a plain <p> would collapse paragraph
// breaks and drop list/heading structure.
const hasMarkdown = computed(() => /[`*_[\]<>#|\n]|\]\(/.test(props.value));
</script>

<template>
<MDC
v-if="hasMarkdown"
:value="value"
/>
<p v-else>
{{ value }}
</p>
</template>
2 changes: 1 addition & 1 deletion app/components/ApiParamsField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defineProps<{
}"
class="[&_p]:my-0"
>
<MDC
<ApiInlineMarkdown
v-if="param.description"
:key="`description-${param.name}`"
:value="param.description"
Expand Down
14 changes: 0 additions & 14 deletions app/constants.ts

This file was deleted.

6 changes: 2 additions & 4 deletions app/layouts/api.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
<script setup lang="ts">
const { spec: openapi } = await import('@directus/openapi');
import { apiReferenceNavigation } from '~/generated/api-reference/meta';

provide('openapi', openapi);

const oasNavigation = computed(() => mapOasNavigation(openapi));
const oasNavigation = apiReferenceNavigation;
</script>

<template>
Expand Down
43 changes: 15 additions & 28 deletions app/pages/api/[tag].vue
Original file line number Diff line number Diff line change
@@ -1,42 +1,29 @@
<script setup lang="ts">
import type { OpenAPIObject, OperationObject } from 'openapi3-ts/oas30';
import { METHODS } from '~/constants';
import type { FlattenedOperationObject, DerefedOperationObject } from '~/types';

const openapi = inject<OpenAPIObject>('openapi')!;
import type { ApiReferenceTagPage } from '~/types';

definePageMeta({
layout: 'api',
});

const route = useRoute();

const tag = computed(() => {
return openapi.tags?.find(tag => tag.name.toLowerCase() === route.params.tag);
const tagSlug = computed(() => String(route.params.tag));
const apiReferencePages = import.meta.glob<ApiReferenceTagPage>('../../generated/api-reference/tags/*.ts', {
import: 'default',
});

const operations = computed<FlattenedOperationObject<DerefedOperationObject>[]>(() => {
const operations = [];

for (const [path, pathItemObject] of Object.entries(openapi.paths)) {
for (const method of METHODS) {
if (pathItemObject[method]) {
const operationObject: OperationObject = pathItemObject[method];
const { data: apiReferencePage } = await useAsyncData(
() => `api-reference-${tagSlug.value}`,
async () => {
const loader = apiReferencePages[`../../generated/api-reference/tags/${tagSlug.value}.ts`];
return loader ? await loader() : null;
},
{ watch: [tagSlug] },
);

if (operationObject.tags?.includes(tag.value!.name)) {
operations.push({
...operationObject,
method, path,
});
}
}
}
}

return derefOperations(openapi, operations);
});
const tag = computed(() => apiReferencePage.value?.tag);
const operations = computed(() => apiReferencePage.value?.operations ?? []);

if (!tag.value) {
if (!apiReferencePage.value) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true });
}
</script>
Expand Down
6 changes: 2 additions & 4 deletions app/pages/api/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { OpenAPIObject } from 'openapi3-ts/oas30';
import { apiReferenceMeta } from '~/generated/api-reference/meta';

definePageMeta({
layout: 'api',
Expand All @@ -8,8 +8,6 @@ definePageMeta({
const route = useRoute();
const { data: page } = await useAsyncData(route.path, () => queryCollection('content').path(route.path).first());

const openapi = inject<OpenAPIObject>('openapi')!;

if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true });
}
Expand All @@ -23,7 +21,7 @@ if (!page.value) {
:ui="{ title: 'title', headline: 'headline' }"
>
<template #headline>
For Directus v{{ openapi.info.version }}+
For Directus v{{ apiReferenceMeta.version }}+
</template>
</UPageHeader>
<UPageBody prose>
Expand Down
50 changes: 45 additions & 5 deletions app/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,52 @@
import type { OperationObject, ParameterObject } from 'openapi3-ts/oas30';

export type DerefedOperationObject<O = OperationObject> = O & { parameters: ParameterObject[] };
export type FlattenedOperationObject<O = OperationObject> = O & { method: string; path: string };

export interface FlattenedParam {
name: string | undefined;
type: string | undefined;
description: string | undefined;
children?: FlattenedParam[];
anyOf?: FlattenedParam[];
}

export interface ApiReferenceCodeSample {
label: string;
lang: string;
source: string;
}

export interface ApiReferenceParameter {
name?: string;
description?: string;
schema?: {
type?: string;
};
}

export interface ApiReferenceRequestBody {
description?: string;
schema: FlattenedParam | null;
}

export interface ApiReferenceResponse {
code: string;
description?: string;
schema: FlattenedParam | null;
}

export interface ApiReferenceOperation {
method: string;
path: string;
summary?: string;
description?: string;
parameters: ApiReferenceParameter[];
requestBody: ApiReferenceRequestBody | null;
responses: ApiReferenceResponse[];
responseExample: unknown | null;
'x-codeSamples'?: ApiReferenceCodeSample[];
}

export interface ApiReferenceTagPage {
tag: {
name: string;
description?: string;
};
operations: ApiReferenceOperation[];
}
Loading