From 62ded69cc76649e6a2f8c7e3b0ba0a27f0bf7888 Mon Sep 17 00:00:00 2001 From: Andrea Carraro Date: Fri, 15 Sep 2023 20:40:15 +0200 Subject: [PATCH] feat: introduce plugins (#69) * feat: introduce plugins * feat: add fastifyTypeProviderPlugin plugin --- .changeset/smooth-buckets-taste.md | 5 ++ README.md | 9 +- docs/plugins.md | 75 ++++++++++++++++ src/index.ts | 1 + src/plugins/fastifyTypeProviderPlugin.ts | 61 +++++++++++++ src/types.ts | 5 ++ .../plugins/fastifyTypeProviderPlugin.test.ts | 86 +++++++++++++++++++ test/refHandling-import.test.ts | 8 ++ test/test-utils/importFresh.ts | 5 +- vitest.config.mts | 1 + 10 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 .changeset/smooth-buckets-taste.md create mode 100644 docs/plugins.md create mode 100644 src/plugins/fastifyTypeProviderPlugin.ts create mode 100644 test/plugins/fastifyTypeProviderPlugin.test.ts diff --git a/.changeset/smooth-buckets-taste.md b/.changeset/smooth-buckets-taste.md new file mode 100644 index 0000000..eed8c2e --- /dev/null +++ b/.changeset/smooth-buckets-taste.md @@ -0,0 +1,5 @@ +--- +'openapi-ts-json-schema': minor +--- + +Add `fastifyTypeProviderPlugin` plugin diff --git a/README.md b/README.md index baddc42..69410e2 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,18 @@ Beside generating the expected schema files under `outputPath`, `openapiToTsJson } ``` +## Plugins + +`metaData` output can be used to dynamically generate extra custom output based on the generated schemas. + +`openapi-ts-json-schema` currently ships with one plugin specifically designed to better integrate with [Fastify](https://fastify.dev/). + +Read more about plugins in the [dedicated readme](./docs/plugins.md). + ## Todo - Consider merging "operation" and "path" parameters definition - Consider removing required `definitionPathsToGenerateFrom` option in favour of exporting the whole OpenAPI definitions based on the structure defined in specs -- Explore how to preserve `$ref` values and rely on [`json-schema-to-ts` references generic to](https://www.npmjs.com/package/json-schema-to-ts#references) infer types [ci-badge]: https://github.com/toomuchdesign/openapi-ts-json-schema/actions/workflows/ci.yml/badge.svg [ci]: https://github.com/toomuchdesign/openapi-ts-json-schema/actions/workflows/ci.yml diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..7f12184 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,75 @@ +# Plugins + +## Fastify type provider plugin + +This plugin generate the necessary connective tissue to optimally integrate `openapi-ts-json-schema` output with Fastify's [`json-schema-to-ts` type provider](https://github.com/fastify/fastify-type-provider-json-schema-to-ts) preserving JSON schemas `$ref`s. + +The plugin generates a `fastifyTypeProvider.ts` file under `outputPath` exposing: + +- `referenceSchemas`: an array containing all the `$ref` schemas found with relevant `$id` property ready to be registered with [`fastify.addSchema`](https://fastify.dev/docs/latest/Reference/Server/#addschema) +- `References` TS type specifically built to enable `json-schema-to-ts` to resolve `$ref` schema types + +Generate TypeScript JSON schemas: + +```ts +import { + openapiToTsJsonSchema, + fastifyTypeProviderPlugin, +} from 'openapi-ts-json-schema'; + +const { outputPath, metaData } = await openapiToTsJsonSchema({ + openApiSchema: path.resolve(fixtures, 'path/to/open-api-spec.yaml'), + outputPath: 'path/to/generated/schemas', + definitionPathsToGenerateFrom: ['components.schemas', 'paths'], + refHandling: 'keep', +}); + +await fastifyTypeProviderPlugin({ outputPath, metaData }); +``` + +Setup `Fastify` and `json-schema-to-ts` type provider: + +```ts +import fastify from 'fastify'; +import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { + References, + referenceSchemas, +} from 'path/to/generated/schemas/fastifyTypeProvider'; + +// Enable @fastify/type-provider-json-schema-to-ts to resolve all found `$ref` schema types +const server = + fastify().withTypeProvider< + JsonSchemaToTsProvider> + >(); + +/** + * Register `$ref` schemas individually so that they `$ref`s get resolved runtime. + * This also enables @fastify.swagger to re-expose the schemas as shared components. + */ +referenceSchemas.forEach((schema) => { + fastify.addSchema(schema); +}); + +// Reference the shared schema like the following +fastify.get( + '/profile', + { + schema: { + body: { + type: 'object', + properties: { + user: { + $ref: '#/components/schemas/User', + }, + }, + required: ['user'], + }, + } as const, + }, + (req) => { + // givenName and familyName will be correctly typed as strings! + const { givenName, familyName } = req.body.user; + }, +); +``` diff --git a/src/index.ts b/src/index.ts index 3fd60dd..285b071 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export { openapiToTsJsonSchema } from './openapiToTsJsonSchema'; +export { default as fastifyTypeProviderPlugin } from './plugins/fastifyTypeProviderPlugin'; diff --git a/src/plugins/fastifyTypeProviderPlugin.ts b/src/plugins/fastifyTypeProviderPlugin.ts new file mode 100644 index 0000000..f364ccc --- /dev/null +++ b/src/plugins/fastifyTypeProviderPlugin.ts @@ -0,0 +1,61 @@ +import { makeRelativePath, formatTypeScript, saveFile } from '../utils'; +import type { Plugin, SchemaMetaData } from '../types'; + +const FILE_NAME = 'fastifyTypeProvider.ts'; + +const fastifyTypeProviderPlugin: Plugin = async ({ outputPath, metaData }) => { + const refSchemaMetaData: SchemaMetaData[] = []; + metaData.schemas.forEach((schema) => { + if (schema.isRef) { + refSchemaMetaData.push(schema); + } + }); + + const schemas = refSchemaMetaData.map( + ({ schemaAbsoluteImportPath, schemaUniqueName, schemaId }) => { + return { + importPath: makeRelativePath({ + fromDirectory: outputPath, + to: schemaAbsoluteImportPath, + }), + schemaUniqueName, + schemaId, + }; + }, + ); + + let output = ''; + + schemas.forEach((schema) => { + output += `\n import ${schema.schemaUniqueName} from "${schema.importPath}";`; + }); + + output += '\n\n'; + + schemas.forEach((schema) => { + output += `\n const ${schema.schemaUniqueName}WithId = {...${schema.schemaUniqueName}, $id: "${schema.schemaId}"} as const;`; + }); + + // Generate a TS tuple type containing the types of all $ref schema found + output += `\n\n + export type References = [ + ${schemas + .map((schema) => `typeof ${schema.schemaUniqueName}WithId`) + .join(',')} + ];`; + + // Generate an array af all $ref schema + // @TODO make selected schemas configurable + output += `\n\n + export const referenceSchemas = [ + ${schemas.map((schema) => `${schema.schemaUniqueName}WithId`).join(',')} + ];`; + + const formattedOutput = await formatTypeScript(output); + await saveFile({ + path: [outputPath, FILE_NAME], + data: formattedOutput, + }); +}; + +export default fastifyTypeProviderPlugin; diff --git a/src/types.ts b/src/types.ts index 517915b..7f9c19e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,3 +27,8 @@ export type SchemaMetaDataMap = Map< string, // Schema file relative path SchemaMetaData >; + +export type Plugin = (args: { + outputPath: string; + metaData: { schemas: SchemaMetaDataMap }; +}) => Promise; diff --git a/test/plugins/fastifyTypeProviderPlugin.test.ts b/test/plugins/fastifyTypeProviderPlugin.test.ts new file mode 100644 index 0000000..16e6893 --- /dev/null +++ b/test/plugins/fastifyTypeProviderPlugin.test.ts @@ -0,0 +1,86 @@ +import path from 'path'; +import fs from 'fs/promises'; +import { describe, it, expect } from 'vitest'; +import { openapiToTsJsonSchema } from '../../src'; +import { fastifyTypeProviderPlugin } from '../../src'; +import { importFresh } from '../test-utils'; +import { formatTypeScript } from '../../src/utils'; + +const fixtures = path.resolve(__dirname, '../fixtures'); + +describe('fastifyTypeProviderPlugin plugin', () => { + it('generates expected file', async () => { + const { outputPath, metaData } = await openapiToTsJsonSchema({ + openApiSchema: path.resolve(fixtures, 'complex/specs.yaml'), + outputPath: path.resolve( + fixtures, + 'complex/schemas-autogenerated-fastifyTypeProviderPlugin', + ), + definitionPathsToGenerateFrom: ['components.months', 'paths'], + refHandling: 'keep', + silent: true, + }); + + await fastifyTypeProviderPlugin({ outputPath, metaData }); + + const actualAsText = await fs.readFile( + path.resolve(outputPath, 'fastifyTypeProvider.ts'), + { + encoding: 'utf8', + }, + ); + + // @TODO find a better way to assert against generated types + const expectedAsText = await formatTypeScript(` + import componentsSchemasAnswer from "./components/schemas/Answer"; + import componentsMonthsJanuary from "./components/months/January"; + import componentsMonthsFebruary from "./components/months/February"; + + const componentsSchemasAnswerWithId = { + ...componentsSchemasAnswer, + $id: "#/components/schemas/Answer", + } as const; + const componentsMonthsJanuaryWithId = { + ...componentsMonthsJanuary, + $id: "#/components/months/January", + } as const; + const componentsMonthsFebruaryWithId = { + ...componentsMonthsFebruary, + $id: "#/components/months/February", + } as const; + + export type References = [ + typeof componentsSchemasAnswerWithId, + typeof componentsMonthsJanuaryWithId, + typeof componentsMonthsFebruaryWithId, + ]; + + export const referenceSchemas = [ + componentsSchemasAnswerWithId, + componentsMonthsJanuaryWithId, + componentsMonthsFebruaryWithId, + ]`); + + expect(actualAsText).toBe(expectedAsText); + + // Ref schemas for fastify.addSchema + const answerSchema = await importFresh( + path.resolve(outputPath, 'components/schemas/Answer'), + ); + const januarySchema = await importFresh( + path.resolve(outputPath, 'components/months/January'), + ); + const februarySchema = await importFresh( + path.resolve(outputPath, 'components/months/February'), + ); + const actualParsed = await importFresh( + path.resolve(outputPath, 'fastifyTypeProvider'), + ); + + expect(actualParsed.referenceSchemas).toEqual([ + { ...answerSchema.default, $id: '#/components/schemas/Answer' }, + { ...januarySchema.default, $id: '#/components/months/January' }, + { ...februarySchema.default, $id: '#/components/months/February' }, + ]); + }); +}); diff --git a/test/refHandling-import.test.ts b/test/refHandling-import.test.ts index 74bf5cb..402ed02 100644 --- a/test/refHandling-import.test.ts +++ b/test/refHandling-import.test.ts @@ -25,6 +25,10 @@ describe('refHandling option === "import"', () => { it('Generates expected schema', async () => { const { outputPath } = await openapiToTsJsonSchema({ openApiSchema: path.resolve(fixtures, 'complex/specs.yaml'), + outputPath: path.resolve( + fixtures, + 'complex/schemas-autogenerated-refHandling-import', + ), definitionPathsToGenerateFrom, silent: true, refHandling: 'import', @@ -111,6 +115,10 @@ describe('refHandling option === "import"', () => { it('Generates expected $ref schemas', async () => { const { outputPath } = await openapiToTsJsonSchema({ openApiSchema: path.resolve(fixtures, 'complex/specs.yaml'), + outputPath: path.resolve( + fixtures, + 'complex/schemas-autogenerated-refHandling-import', + ), definitionPathsToGenerateFrom: ['paths'], silent: true, refHandling: 'import', diff --git a/test/test-utils/importFresh.ts b/test/test-utils/importFresh.ts index 7959021..752a4cb 100644 --- a/test/test-utils/importFresh.ts +++ b/test/test-utils/importFresh.ts @@ -1,4 +1,7 @@ -// https://github.com/small-tech/import-fresh +/** + * It doesn't work for sub-imports + * https://github.com/small-tech/import-fresh + */ export async function importFresh(absolutePathToModule: string) { const cacheBustingModulePath = `${absolutePathToModule}?update=${Date.now()}`; return await import(cacheBustingModulePath); diff --git a/vitest.config.mts b/vitest.config.mts index c5d369f..215d78b 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -7,6 +7,7 @@ export default defineConfig({ sequence: { hooks: 'stack', concurrent: false, + shuffle: true, }, coverage: { provider: 'istanbul',