diff --git a/.github/workflows/nightly-rebuild.yml b/.github/workflows/nightly-rebuild.yml new file mode 100644 index 00000000..9fe0da14 --- /dev/null +++ b/.github/workflows/nightly-rebuild.yml @@ -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 diff --git a/package.json b/package.json index 519e2972..4f3fbd6c 100755 --- a/package.json +++ b/package.json @@ -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", @@ -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": "yarn@3.2.2", "volta": { diff --git a/src/.vuepress/config.ts b/src/.vuepress/config.ts index fcbac78e..2c88dd41 100755 --- a/src/.vuepress/config.ts +++ b/src/.vuepress/config.ts @@ -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'; @@ -25,6 +26,10 @@ export default defineUserConfig({ }), plugins: [ + openApiPlugin({ + yamlUrls: [], + }), + parseTranslationsPlugin({ defaultLocale: 'en-GB', configDir: path.resolve(DIR_CONFIG, 'navigation'), diff --git a/src/.vuepress/plugins/openApi.ts b/src/.vuepress/plugins/openApi.ts new file mode 100644 index 00000000..27989940 --- /dev/null +++ b/src/.vuepress/plugins/openApi.ts @@ -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' : ''} + + +${document.servers?.length ? '## 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 + + `; + } + + return chapters; +} diff --git a/src/.vuepress/theme/client/components/global/OpenApi.vue b/src/.vuepress/theme/client/components/global/OpenApi.vue new file mode 100644 index 00000000..5e0ad966 --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApi.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiExample.vue b/src/.vuepress/theme/client/components/global/OpenApiExample.vue new file mode 100644 index 00000000..2e780b98 --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiExample.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiInfo.vue b/src/.vuepress/theme/client/components/global/OpenApiInfo.vue new file mode 100644 index 00000000..fb2ca7e6 --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiInfo.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiOperation.vue b/src/.vuepress/theme/client/components/global/OpenApiOperation.vue new file mode 100644 index 00000000..a53ccccd --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiOperation.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiPath.vue b/src/.vuepress/theme/client/components/global/OpenApiPath.vue new file mode 100644 index 00000000..08877e25 --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiPath.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiRequestParam.vue b/src/.vuepress/theme/client/components/global/OpenApiRequestParam.vue new file mode 100644 index 00000000..503ba467 --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiRequestParam.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiResponses.vue b/src/.vuepress/theme/client/components/global/OpenApiResponses.vue new file mode 100644 index 00000000..8b612776 --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiResponses.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiSchema.vue b/src/.vuepress/theme/client/components/global/OpenApiSchema.vue new file mode 100644 index 00000000..09f20a2d --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiSchema.vue @@ -0,0 +1,164 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiSchemaInfo.vue b/src/.vuepress/theme/client/components/global/OpenApiSchemaInfo.vue new file mode 100644 index 00000000..62225667 --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiSchemaInfo.vue @@ -0,0 +1,152 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiSchemaInfoRestriction.vue b/src/.vuepress/theme/client/components/global/OpenApiSchemaInfoRestriction.vue new file mode 100644 index 00000000..1c648407 --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiSchemaInfoRestriction.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiSecurityRequirements.vue b/src/.vuepress/theme/client/components/global/OpenApiSecurityRequirements.vue new file mode 100644 index 00000000..d605387a --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiSecurityRequirements.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiSecurityScheme.vue b/src/.vuepress/theme/client/components/global/OpenApiSecurityScheme.vue new file mode 100644 index 00000000..97658281 --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiSecurityScheme.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/OpenApiServers.vue b/src/.vuepress/theme/client/components/global/OpenApiServers.vue new file mode 100644 index 00000000..1058779c --- /dev/null +++ b/src/.vuepress/theme/client/components/global/OpenApiServers.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/.vuepress/theme/client/components/global/index.ts b/src/.vuepress/theme/client/components/global/index.ts index fc4ccbdd..c8e848a8 100644 --- a/src/.vuepress/theme/client/components/global/index.ts +++ b/src/.vuepress/theme/client/components/global/index.ts @@ -62,3 +62,29 @@ export {default as Shield} from './Shield'; export {default as Stack} from './Stack.vue'; export {default as TeamMember} from './TeamMember.vue'; + +export {default as OpenApi} from './OpenApi.vue'; + +export {default as OpenApiExample} from './OpenApiExample.vue'; + +export {default as OpenApiInfo} from './OpenApiInfo.vue'; + +export {default as OpenApiOperation} from './OpenApiOperation.vue'; + +export {default as OpenApiPath} from './OpenApiPath.vue'; + +export {default as OpenApiRequestParam} from './OpenApiRequestParam.vue'; + +export {default as OpenApiResponses} from './OpenApiResponses.vue'; + +export {default as OpenApiSchema} from './OpenApiSchema.vue'; + +export {default as OpenApiSchemaInfo} from './OpenApiSchemaInfo.vue'; + +export {default as OpenApiSchemaInfoRestriction} from './OpenApiSchemaInfoRestriction.vue'; + +export {default as OpenApiSecurityRequirements} from './OpenApiSecurityRequirements.vue'; + +export {default as OpenApiSecurityScheme} from './OpenApiSecurityScheme.vue'; + +export {default as OpenApiServers} from './OpenApiServers.vue'; diff --git a/src/.vuepress/theme/client/utils/openApiGuards.ts b/src/.vuepress/theme/client/utils/openApiGuards.ts new file mode 100644 index 00000000..ca137a93 --- /dev/null +++ b/src/.vuepress/theme/client/utils/openApiGuards.ts @@ -0,0 +1,27 @@ +import {type OpenAPIV3_1 as OpenApiType} from 'openapi-types'; + +export function isParameterType( + parameter: OpenApiType.ParameterObject | OpenApiType.ReferenceObject, +): parameter is OpenApiType.ParameterObject { + return 'in' in parameter && 'schema' in parameter; +} + +// Guard to check if the schema is an OpenApiType.SchemaObject. +export const isSchemaObject = (schema: object): schema is OpenApiType.SchemaObject => { + return typeof schema === 'object'; +}; + +// Guard to check if the schema is an OpenApiType.ArraySchemaObject. +export const isArraySchemaObject = (schema: object): schema is OpenApiType.ArraySchemaObject => { + return typeof schema === 'object' && 'items' in schema; +}; + +// Guard to check if the response is an OpenApiType.ResponseObject. +export const isReponseObject = (response: object): response is OpenApiType.ResponseObject => { + return typeof response === 'object'; +}; + +// Guard to check if example is an Example object +export const isExampleObject = (example: unknown): example is OpenApiType.ExampleObject => { + return !!example && typeof example === 'object' && 'value' in example; +}; diff --git a/src/.vuepress/theme/client/utils/openApiHelpers.ts b/src/.vuepress/theme/client/utils/openApiHelpers.ts new file mode 100644 index 00000000..c82f0901 --- /dev/null +++ b/src/.vuepress/theme/client/utils/openApiHelpers.ts @@ -0,0 +1,50 @@ +import {type OpenAPIV3_1 as OpenApiType} from 'openapi-types'; +import {get} from 'lodash-es'; + +type RecursiveType = { + [key: string]: unknown | RecursiveType | RecursiveType[]; +}; + +/** + * Formats an example object, array or primitive to a string + */ +export function formatExample(example: unknown): string { + const spacing = 2; + + return JSON.stringify(example, null, spacing); +} + +/** + * Resolves all $ref keys in the document with their corresponding values + */ +export function resolveRefs(document: OpenApiType.Document): OpenApiType.Document { + const resolvedDocument = {...document}; + + function resolveRefsRecursive(obj: RecursiveType) { + if (Array.isArray(obj)) { + for (const item of obj) { + resolveRefsRecursive(item); + } + } else if (typeof obj === 'object') { + for (const key in obj) { + if (key === '$ref') { + const lookup = obj[key] as string; + // Lookup is a string that defines the path to the referenced object like '#/components/schemas/Example' + const path = lookup.split('/').slice(1); + // Find the referenced object in the document using lodash's get function + const referencedObject = get(resolvedDocument, path); + // Remove the $ref: '...' key from the object and replace it with the referenced object itself + obj = Object.assign(obj, referencedObject); + } else if (typeof obj[key] === 'object' || Array.isArray(obj[key])) { + const guarded = obj[key] as RecursiveType; + // If the value is an object or an array, recursively call this function + resolveRefsRecursive(guarded); + } + } + } + } + + resolveRefsRecursive(resolvedDocument); + + return resolvedDocument; +} diff --git a/yarn.lock b/yarn.lock index 5da7bf24..4892c7e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1002,6 +1002,7 @@ __metadata: 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 @@ -1015,6 +1016,7 @@ __metadata: vitest: ^0.34.6 vue-recaptcha: ^2.0.2 vuepress: 2.0.0-beta.67 + yaml: ^2.4.1 languageName: unknown linkType: soft @@ -7480,6 +7482,13 @@ __metadata: languageName: node linkType: hard +"openapi-types@npm:^12.1.3": + version: 12.1.3 + resolution: "openapi-types@npm:12.1.3" + checksum: 7fa5547f87a58d2aa0eba6e91d396f42d7d31bc3ae140e61b5d60b47d2fd068b48776f42407d5a8da7280cf31195aa128c2fc285e8bb871d1105edee5647a0bb + languageName: node + linkType: hard + "opener@npm:^1.5.1": version: 1.5.2 resolution: "opener@npm:1.5.2" @@ -11267,6 +11276,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.4.1": + version: 2.4.1 + resolution: "yaml@npm:2.4.1" + bin: + yaml: bin.mjs + checksum: 4c391d07a5d5e935e058babb71026c9cdc9a6fd889e35dd91b53cfb0a12691b67c6c5c740858e71345fef18cd9c13c554a6dda9196f59820d769d94041badb0b + languageName: node + linkType: hard + "yargs-parser@npm:^20.2.2": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9"