Skip to content

Commit

Permalink
feat: add support for rendering open-api specification exports within…
Browse files Browse the repository at this point in the history
… vuepress (#109)

* feat(open-api): add open-api typescript types

closes INT-407

* feat(open-api): add display of base open-api info and component structure

closes INT-407

* feat(open-api): add display of (example) responses and base path information for open-api

closes INT-407

* feat(open-api): improve display of open-api operation details

improves display of the path/method  and adds placeholder security details

closes INT-407

* feat(open-api): display API responses using existing components

INT-407

* docs(open-api): add a test document for open-api output

INT-407

* fix(open-api): fix dark mode not working correctly for the operations block

INT-407

* docs(open-api): add json sample for MyParcel webhooks

INT-407

* fix(open-api): fix security / auth showing when it was an empty array

INT-407

* feat(open-api): add basic $ref resolution support for openapi documents

adds support to resolve $ref references to paths within the document

INT-407

* feat(open-api): add basic display of open-api schemas

INT-407

* feat(open-api): improve rendering of (response) schemas

INT-407

* feat(open-api): add display of request parameter and body information

INT-407

* feat(open-api): add consistent rendering of examples in request- and response tables

INT-407

* feat(open-api): add support for rendering request body description and schema

INT-407

* feat(open-api): wrap reponse examples and schemas in details/expand blocks

INT-407

* refactor(open-api): resolve type safety issues and create util file for OpenAPI type guards

INT-407

* fix(open-api): fix tables not showing correctly in dark mode

INT-407

* feat(open-api): add a plugin to render openapi schema files as markdown pages

adds a plugin to render json openapi files from a directory into individual pages within vuepress, with each path within as a chapter

INT-407

* refactor(open-api): load yaml files from (remote) url rather than json files from a local directory

INT-407

* feat(open-api): add and improve display of various schema requirements

INT-407

* fix(open-api): add missing global components to the indexed list

INT-407

* feat(open-api): add top-level security information

INT-407

* fix(open-api): fix example rendering "value" key if summary or description not set

* fix(open-api): don't show "Authorization" heading without any security config

* feat(open-api): add auth/security requirements for individual operations

INT-407

* feat(open-api): support display of webhooks and make paths optional

INT-407

* ci(open-api): add nightly (re)deploy to update with changes from remote openapi documents

INT-407

* feat(open-api): add servers display if set

INT-407

* fix(open-api): render all description fields as markdown

INT-407

* docs(open-api): remove all unpublishable schemas

* style(open-api): clean up formatting

* refactor(open-api): add type definition to method prop for `OpenApiOperation` component

* refactor(open-api): don't use indexes for keys

* refactor(open-api): process feedback

* refactor(open-api): clean up and improve html formatting

* Update src/.vuepress/theme/client/components/global/OpenApiSchemaInfo.vue

Co-authored-by: Remco Hörters <[email protected]>

* refactor(open-api): replace array index with value index

* fix(open-api): fix security schemes never rendering

* docs(open-api): do not publish  address API for now

* refactor(open-api): remove redundant checks for schema prop

INT-407

---------

Co-authored-by: Remco Hörters <[email protected]>
  • Loading branch information
FreekVR and Cysword authored Apr 24, 2024
1 parent a502fd5 commit 648d7a4
Show file tree
Hide file tree
Showing 21 changed files with 1,065 additions and 1 deletion.
34 changes: 34 additions & 0 deletions .github/workflows/nightly-rebuild.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: 'Nightly rebuild 🌙'

on:
schedule:
- cron: '0 0 * * *'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
upload:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3

- uses: ./.github/actions/build

- uses: actions/upload-pages-artifact@v1
with:
path: src/.vuepress/dist

deploy-ghp:
needs: upload
runs-on: ubuntu-22.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v1
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"markdown-it-multimd-table": "^4.1.3",
"mock-fs": "^5.1.2",
"only-allow": "^1.1.1",
"openapi-types": "^12.1.3",
"plop": "^4.0.0",
"postcss-import": "^15.1.0",
"prettier": "^2.8.8",
Expand All @@ -90,7 +91,8 @@
"ts-node": "^10.9.1",
"typescript": "^5.2.2",
"vite-svg-loader": "^4.0.0",
"vitest": "^0.34.6"
"vitest": "^0.34.6",
"yaml": "^2.4.1"
},
"packageManager": "[email protected]",
"volta": {
Expand Down
5 changes: 5 additions & 0 deletions src/.vuepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {viteConfig} from './viteConfig';
import {myParcelTheme} from './theme';
import {sitemapPlugin} from './plugins/sitemap';
import {parseTranslationsPlugin} from './plugins/parseTranslations';
import {openApiPlugin} from './plugins/openApi';
import {googleTagManagerPlugin} from './plugins/gtm/node';
import {DIR_CONFIG, DIR_VUEPRESS} from './dirs';
import {head} from './config/head';
Expand All @@ -25,6 +26,10 @@ export default defineUserConfig({
}),

plugins: [
openApiPlugin({
yamlUrls: [],
}),

parseTranslationsPlugin({
defaultLocale: 'en-GB',
configDir: path.resolve(DIR_CONFIG, 'navigation'),
Expand Down
77 changes: 77 additions & 0 deletions src/.vuepress/plugins/openApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {parse as parseYaml} from 'yaml';
import {createPage, type Plugin} from 'vuepress';
import {type OpenAPIV3_1 as OpenApiType} from 'openapi-types';
import {kebabCase} from 'lodash-es';
import {resolveRefs} from '../theme/client/utils/openApiHelpers';

interface OpenApiPluginConfig {
yamlUrls: string[];
}

export const openApiPlugin = (config: OpenApiPluginConfig): Plugin => ({
name: '@myparcel/vuepress-openapi',
async onInitialized(app) {
// Find all yaml files in config.yamlUrls and create a page for each of them
await Promise.all(
config.yamlUrls?.map(async (url) => {
const document = (await fetch(url).then(async (res) => parseYaml(await res.text()))) as OpenApiType.Document;
// Use the basename of the file as the slug
const resolvedDocument = resolveRefs(document);

// Now generate the page contents
app.pages.push(await createPage(app, generateOpenApiPage(resolvedDocument)));
}),
);
},
});

function generateOpenApiPage(document: OpenApiType.Document) {
return {
path: `/api-reference/${kebabCase(document.info.title)}`,
content: renderMarkdown(document),
};
}

function renderMarkdown(document: OpenApiType.Document): string {
return `\
---
title: ${document.info.title}
description: ${document.info.description}
---
Version ${document.info.version}
${document.info.description ?? ''}
${document.security ? '## Authorization' : ''}
<OpenApiSecurityRequirements
:security='${JSON.stringify(document.security ?? [])}'
:security-schemes='${JSON.stringify(document.components?.securitySchemes ?? [])}' />
${document.servers?.length ? '## Servers' : ''}
<OpenApiServers :servers='${JSON.stringify(document.servers ?? [])}' />
${renderPaths(document, document.paths, 'Endpoints')}
${renderPaths(document, document.webhooks, 'Webhooks')}
`;
}

function renderPaths(
document: OpenApiType.Document,
paths: OpenApiType.PathsObject | undefined,
heading: string,
): string {
if (!paths || !Object.keys(paths).length) return '';

let chapters = `## ${heading}`;

for (const [path, pathObj] of Object.entries(paths)) {
chapters += `
### ${path}
\n
<OpenApiPath :path='${JSON.stringify(pathObj)}' :components='${JSON.stringify(document.components)}' title='${path}' />
`;
}

return chapters;
}
34 changes: 34 additions & 0 deletions src/.vuepress/theme/client/components/global/OpenApi.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<template>
<section class="open-api">
<OpenApiInfo :info="resolvedDocument.info" />

<article
v-for="(item, path) in resolvedDocument.paths"
:key="path">
<OpenApiPath
v-if="item"
:path="item"
:components="document.components"
:title="path" />
</article>
</section>
</template>

<script setup lang="ts">
import {computed} from 'vue';
import {type OpenAPIV3_1 as OpenApiType} from 'openapi-types';
import OpenApiPath from './OpenApiPath.vue';
import OpenApiInfo from './OpenApiInfo.vue';
import { resolveRefs } from '@mptheme/client/utils/openApiHelpers';
const props = defineProps<{
document: OpenApiType.Document;
}>();
const resolvedDocument = computed(() => resolveRefs(props.document));
// Recursively loop through the entire document and replace any $ref keys with their corresponding values
type RecursiveType = {
[key: string]: unknown | RecursiveType | RecursiveType[];
};
</script>
51 changes: 51 additions & 0 deletions src/.vuepress/theme/client/components/global/OpenApiExample.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<template>
<div v-if="example">
<strong class="inline-block text-sm">{{ title || 'Example' }}:</strong>&nbsp;
<Markdown
v-if="isExampleObject(example) && example.summary"
class="text-sm"
:content="example.summary" />

<Markdown
v-if="isExampleObject(example) && example.description"
class="text-sm"
:content="example.description" />

<CodeBlock
v-if="formattedExample && isMultilineString"
:code="formattedExample" />

<code
v-else
class="p-1">
{{ formattedExample }}
</code>
</div>
</template>

<script setup lang="ts">
import {computed} from 'vue';
import {type OpenAPIV3_1 as OpenApiType} from 'openapi-types';
import {formatExample} from '@mptheme/client/utils/openApiHelpers';
import {isExampleObject} from '@mptheme/client/utils/openApiGuards';
import Markdown from '@mptheme/client/components/global/Markdown.vue';
import CodeBlock from './CodeBlock.vue';
const props = defineProps<{
title?: string;
example?: OpenApiType.ExampleObject | unknown;
}>();
const formattedExample = computed(() => {
if (!props.example) return undefined;
if (isExampleObject(props.example)) return formatExample(props.example.value);
if (typeof props.example === 'object') return formatExample(props.example);
return props.example.toString();
});
// Check the formattedExample for linebreaks, if it has those, it's a multiline string
const isMultilineString = computed(() => formattedExample.value?.includes('\n'));
</script>
20 changes: 20 additions & 0 deletions src/.vuepress/theme/client/components/global/OpenApiInfo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<section class="open-api-info">
<hgroup>
<h2>{{ info.title }}</h2>
<p>(v{{ info.version }})</p>
<Markdown
v-if="info.description"
:content="info.description" />
</hgroup>
</section>
</template>

<script setup lang="ts">
import {type OpenAPIV3_1 as OpenApiType} from 'openapi-types';
import Markdown from './Markdown.vue';
defineProps<{
info: OpenApiType.Document['info'];
}>();
</script>
54 changes: 54 additions & 0 deletions src/.vuepress/theme/client/components/global/OpenApiOperation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<template>
<header class="bg-gray-100 dark:bg-gray-800 mb-4 mt-4 open-api-operation p-3">
<strong>Endpoint:</strong>
<code
class="dark:text-black inline-block leading-none m-0 ml-3 p-1 rounded-sm text-sm"
:class="methodClass">
{{ method.toUpperCase() }}
</code>
<pre class="dark:text-gray-100 inline m-0 ml-2 p-0 text-gray-700 text-sm">{{ endpoint }}</pre>
<br />

<template v-if="securityRequirements?.length && securitySchemes">
<strong>Authentication:</strong>&nbsp;
<OpenApiSecurityRequirements
class="text-sm"
:security="securityRequirements"
:security-schemes="securitySchemes" />
</template>
</header>
</template>

<script setup lang="ts">
import {computed} from 'vue';
import {type OpenAPIV3_1 as OpenApiType} from 'openapi-types';
import OpenApiSecurityRequirements from '@mptheme/client/components/global/OpenApiSecurityRequirements.vue';
const props = defineProps<{
method: OpenApiType.HttpMethods;
endpoint: string;
securityRequirements?: OpenApiType.SecurityRequirementObject[];
securitySchemes?: Record<string, OpenApiType.SecuritySchemeObject>;
}>();
const methodClass = computed(() => {
return {
'bg-green-200': props.method === 'get',
'dark:bg-green-200': props.method === 'get',
'bg-blue-200': props.method === 'post',
'dark:bg-blue-200': props.method === 'post',
'bg-yellow-200': props.method === 'put',
'dark:bg-yellow-200': props.method === 'put',
'bg-red-200': props.method === 'delete',
'dark:bg-red-200': props.method === 'delete',
'bg-purple-200': props.method === 'patch',
'dark:bg-purple-200': props.method === 'patch',
'bg-gray-200': props.method === 'options',
'dark:bg-gray-200': props.method === 'options',
'bg-indigo-200': props.method === 'head',
'dark:bg-indigo-200': props.method === 'head',
'bg-pink-200': props.method === 'trace',
'dark:bg-pink-200': props.method === 'trace',
};
});
</script>
84 changes: 84 additions & 0 deletions src/.vuepress/theme/client/components/global/OpenApiPath.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<template>
<article class="open-api-path">
<div
v-for="(operation, method) in path"
:key="method">
<template v-if="typeof operation === 'object'">
<h3 v-if="'operationId' in operation">
{{ operation.operationId }}
</h3>

<p v-if="'summary' in operation">{{ operation.summary }}</p>

<OpenApiOperation
:method="method"
:endpoint="title"
:security-schemes="components?.securitySchemes as Record<string, OpenApiType.SecuritySchemeObject>"
:security-requirements="'security' in operation ? operation.security : undefined" />

<Markdown
v-if="'description' in operation && operation.description?.length"
:content="operation.description" />

<template v-if="'parameters' in operation">
<h4>Request parameters</h4>
<table>
<thead>
<th>Parameter</th>
<th>Location</th>
<th>Information</th>
</thead>
<tr
v-for="parameter in operation.parameters"
:key="isParameterType(parameter) ? parameter.name : parameter.$ref">
<OpenApiRequestParam
v-if="isParameterType(parameter)"
:parameter="parameter" />
</tr>
</table>
</template>
<template v-if="'requestBody' in operation && operation.requestBody">
<h4>Request body</h4>

<template v-if="operation.requestBody?.description">
<Markdown :content="operation.requestBody?.description"></Markdown>
</template>
<DetailsExpand
v-if="'content' in operation.requestBody && operation.requestBody.content"
tag="h5"
title="Schema">
<div
v-for="(content, key) in operation.requestBody.content"
:key="key">
<strong>{{ key }}</strong>
<OpenApiSchema
v-if="content.schema && (isSchemaObject(content.schema) || isArraySchemaObject(content.schema))"
:schema="content.schema" />
</div>
</DetailsExpand>
</template>

<OpenApiResponses
v-if="'responses' in operation"
:responses="operation.responses" />
</template>
</div>
</article>
</template>

<script setup lang="ts">
import {type OpenAPIV3_1 as OpenApiType} from 'openapi-types';
import {isParameterType, isArraySchemaObject, isSchemaObject} from '@mptheme/client/utils/openApiGuards';
import OpenApiSchema from './OpenApiSchema.vue';
import OpenApiResponses from './OpenApiResponses.vue';
import OpenApiRequestParam from './OpenApiRequestParam.vue';
import OpenApiOperation from './OpenApiOperation.vue';
import Markdown from './Markdown.vue';
import DetailsExpand from './DetailsExpand.vue';
defineProps<{
title: string;
path: OpenApiType.PathItemObject;
components?: OpenApiType.ComponentsObject;
}>();
</script>
Loading

0 comments on commit 648d7a4

Please sign in to comment.