diff --git a/packages/openapi-generator/README.md b/packages/openapi-generator/README.md index 2038b449..7f0353f0 100644 --- a/packages/openapi-generator/README.md +++ b/packages/openapi-generator/README.md @@ -265,6 +265,28 @@ dev-portal. const route = h.httpRoute({ ... }) ``` +#### 6.1.5.1 Public Routes + +To explicitly mark a route as public (producing `x-internal: false`), use the `@public` +tag. This is useful when your CI pipeline enforces that all endpoints explicitly declare +their visibility. + +```typescript +/** + * This is the summary + * This is description line 1 + * This is description line 2 + * + * @public + * @operationId v2.sample.route + * @tag Wallet + */ +const route = h.httpRoute({ ... }) +``` + +> **Note:** Using both `@public` and `@private` on the same route or field will cause an +> error. + #### 6.1.6 Unstable Routes If you are working on an endpoint that is unstable, or not completely implemented yet, @@ -468,8 +490,11 @@ const SampleSchema = t.type({ These are some tags that you can use in your schema JSDocs are custom to this generator. -- `@private` allows you to mark any field as in any schema as private. The final spec - will have `x-internal: true` for schemas with the `@private` tag. +- `@private` allows you to mark any field in any schema as private. The final spec will + have `x-internal: true` for schemas with the `@private` tag. +- `@public` allows you to explicitly mark any field in any schema as public. The final + spec will have `x-internal: false` for schemas with the `@public` tag. Using both + `@public` and `@private` on the same field will cause an error. - `@deprecated` allows to mark any field in any schema as deprecated. The final spec will include `deprecated: true` in the final specificaiton. @@ -479,9 +504,11 @@ import * as t from 'io-ts'; const Schema = t.type({ /** @private */ privateField: t.string, + /** @public */ + publicField: t.string, /** @deprecated */ deprecatedField: t.string, - publicNonDeprecatedField: t.string, + untaggedField: t.string, }); ``` diff --git a/packages/openapi-generator/src/openapi.ts b/packages/openapi-generator/src/openapi.ts index c1c2e08b..3b4e6d5f 100644 --- a/packages/openapi-generator/src/openapi.ts +++ b/packages/openapi-generator/src/openapi.ts @@ -319,6 +319,10 @@ export function schemaToOpenAPI( const deprecated = keys.includes('deprecated') || !!schema.deprecated; const isPrivate = keys.includes('private'); + const isPublic = keys.includes('public'); + if (isPrivate && isPublic) { + throw new Error('Cannot use both @public and @private on the same schema field'); + } const description = schema.comment?.description ?? schema.description; const defaultOpenAPIObject = { @@ -343,7 +347,7 @@ export function schemaToOpenAPI( ...(writeOnly ? { writeOnly: true } : {}), ...(format ? { format } : {}), ...(title ? { title } : {}), - ...(isPrivate ? { 'x-internal': true } : {}), + ...(isPrivate ? { 'x-internal': true } : isPublic ? { 'x-internal': false } : {}), }; return defaultOpenAPIObject; @@ -362,6 +366,12 @@ function routeToOpenAPI( const operationId = jsdoc.tags?.operationId; const tag = jsdoc.tags?.tag ?? ''; const isInternal = jsdoc.tags?.private !== undefined; + const isPublic = jsdoc.tags?.public !== undefined; + if (isInternal && isPublic) { + throw new Error( + `Cannot use both @public and @private on route ${route.method.toUpperCase()} ${route.path}`, + ); + } const isUnstable = jsdoc.tags?.unstable !== undefined; const example = jsdoc.tags?.example; @@ -369,6 +379,7 @@ function routeToOpenAPI( 'operationId', 'summary', 'private', + 'public', 'unstable', 'example', 'tag', @@ -406,7 +417,11 @@ function routeToOpenAPI( ...(jsdoc.description !== undefined ? { description: jsdoc.description } : {}), ...(operationId !== undefined ? { operationId } : {}), ...(tag !== '' ? { tags: [tag] } : {}), - ...(isInternal ? { 'x-internal': true } : {}), + ...(isInternal + ? { 'x-internal': true } + : isPublic + ? { 'x-internal': false } + : {}), ...(isUnstable ? { 'x-unstable': true } : {}), ...(Object.keys(unknownTagsObject).length > 0 ? { 'x-unknown-tags': unknownTagsObject } @@ -419,8 +434,9 @@ function routeToOpenAPI( delete schema.description; } - const isPrivate = schema && 'x-internal' in schema; - if (isPrivate) { + const hasInternalFlag = schema && 'x-internal' in schema; + const internalValue = hasInternalFlag ? schema['x-internal'] : undefined; + if (hasInternalFlag) { delete schema['x-internal']; } @@ -430,7 +446,7 @@ function routeToOpenAPI( ? { description: p.schema.comment.description } : {}), in: p.type, - ...(isPrivate ? { 'x-internal': true } : {}), + ...(hasInternalFlag ? { 'x-internal': internalValue } : {}), ...(p.required ? { required: true } : {}), ...(p.explode ? { style: 'form', explode: true } : {}), schema: schema as any, // TODO: Something to disallow arrays diff --git a/packages/openapi-generator/test/openapi/base.test.ts b/packages/openapi-generator/test/openapi/base.test.ts index db7409c1..252dde7c 100644 --- a/packages/openapi-generator/test/openapi/base.test.ts +++ b/packages/openapi-generator/test/openapi/base.test.ts @@ -1,3 +1,16 @@ +import * as E from 'fp-ts/lib/Either'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + convertRoutesToOpenAPI, + parsePlainInitializer, + parseSource, + parseRoute, + Project, + type Route, + type Schema, +} from '../../src'; +import { SourceFile } from '../../src/sourceFile'; import { testCase } from './testHarness'; const SIMPLE = ` @@ -978,3 +991,129 @@ testCase('multiple routes with methods', MULTIPLE_ROUTES_WITH_METHODS, { schemas: {}, }, }); + +const PUBLIC_ROUTE = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * A public route + * + * @public + * @operationId api.v1.public + * @tag Public Routes + */ +export const publicRoute = h.httpRoute({ + path: '/public/foo', + method: 'GET', + request: h.httpRequest({ + query: { + foo: t.string, + }, + }), + response: { + 200: t.string + }, +}); +`; + +testCase('public route', PUBLIC_ROUTE, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/public/foo': { + get: { + summary: 'A public route', + operationId: 'api.v1.public', + tags: ['Public Routes'], + 'x-internal': false, + parameters: [ + { + in: 'query', + name: 'foo', + required: true, + schema: { + type: 'string', + }, + }, + ], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, +}); + +const CONFLICTING_ROUTE = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * A conflicting route + * + * @public + * @private + * @operationId api.v1.conflict + * @tag Conflict Routes + */ +export const conflictRoute = h.httpRoute({ + path: '/conflict/foo', + method: 'GET', + request: h.httpRequest({ + query: { + foo: t.string, + }, + }), + response: { + 200: t.string + }, +}); +`; + +test('conflicting public and private tags on route throws error', async () => { + const sourceFile = await parseSource('./index.ts', CONFLICTING_ROUTE); + if (sourceFile === undefined) { + throw new Error('Failed to parse source file'); + } + const files: Record = { './index.ts': sourceFile }; + const project = new Project(files); + const routes: Route[] = []; + const schemas: Record = {}; + for (const symbol of sourceFile.symbols.declarations) { + if (symbol.init !== undefined) { + const routeSchemaE = parsePlainInitializer(project, sourceFile, symbol.init); + if (E.isLeft(routeSchemaE)) continue; + if (symbol.comment !== undefined) { + routeSchemaE.right.comment = symbol.comment; + } + const result = parseRoute(project, routeSchemaE.right); + if (E.isLeft(result)) { + schemas[symbol.name] = routeSchemaE.right; + } else { + routes.push(result.right); + } + } + } + + assert.throws( + () => + convertRoutesToOpenAPI({ title: 'Test', version: '1.0.0' }, [], routes, schemas), + /Cannot use both @public and @private/, + ); +}); diff --git a/packages/openapi-generator/test/openapi/jsdoc.test.ts b/packages/openapi-generator/test/openapi/jsdoc.test.ts index 39efd86c..1d873ef2 100644 --- a/packages/openapi-generator/test/openapi/jsdoc.test.ts +++ b/packages/openapi-generator/test/openapi/jsdoc.test.ts @@ -1,3 +1,16 @@ +import * as E from 'fp-ts/lib/Either'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + convertRoutesToOpenAPI, + parsePlainInitializer, + parseSource, + parseRoute, + Project, + type Route, + type Schema, +} from '../../src'; +import { SourceFile } from '../../src/sourceFile'; import { testCase } from './testHarness'; const TITLE_TAG = ` @@ -1360,3 +1373,178 @@ testCase('route with example object', ROUTE_WITH_ANY_AND_FORMAT, { schemas: {}, }, }); + +const ROUTE_WITH_PUBLIC_PROPERTIES = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +const SampleType = t.type({ + foo: t.string, + /** @public */ + bar: t.string, +}); + +export const route = h.httpRoute({ + path: '/foo', + method: 'GET', + request: h.httpRequest({ + params: { + /** @public */ + path: t.string + }, + query: { + /** @public */ + query: t.string + }, + body: SampleType + }), + response: { + 200: SampleType + }, +}); +`; + +testCase( + 'route with public properties in request query, params, body, and response', + ROUTE_WITH_PUBLIC_PROPERTIES, + { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/foo': { + get: { + parameters: [ + { + 'x-internal': false, + description: '', + in: 'query', + name: 'query', + required: true, + schema: { + type: 'string', + }, + }, + { + 'x-internal': false, + description: '', + in: 'path', + name: 'path', + required: true, + schema: { + type: 'string', + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + properties: { + bar: { + 'x-internal': false, + type: 'string', + }, + foo: { + type: 'string', + }, + }, + required: ['foo', 'bar'], + type: 'object', + }, + }, + }, + }, + responses: { + '200': { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SampleType', + }, + }, + }, + description: 'OK', + }, + }, + }, + }, + }, + components: { + schemas: { + SampleType: { + properties: { + bar: { + 'x-internal': false, + type: 'string', + }, + foo: { + type: 'string', + }, + }, + required: ['foo', 'bar'], + title: 'SampleType', + type: 'object', + }, + }, + }, + }, +); + +const ROUTE_WITH_CONFLICTING_FIELD_TAGS = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +const SampleType = t.type({ + /** + * @public + * @private + */ + conflictField: t.string, +}); + +export const route = h.httpRoute({ + path: '/foo', + method: 'GET', + request: h.httpRequest({ + body: SampleType + }), + response: { + 200: t.string + }, +}); +`; + +test('conflicting public and private tags on schema field throws error', async () => { + const sourceFile = await parseSource('./index.ts', ROUTE_WITH_CONFLICTING_FIELD_TAGS); + if (sourceFile === undefined) { + throw new Error('Failed to parse source file'); + } + const files: Record = { './index.ts': sourceFile }; + const project = new Project(files); + const routes: Route[] = []; + const schemas: Record = {}; + for (const symbol of sourceFile.symbols.declarations) { + if (symbol.init !== undefined) { + const routeSchemaE = parsePlainInitializer(project, sourceFile, symbol.init); + if (E.isLeft(routeSchemaE)) continue; + if (symbol.comment !== undefined) { + routeSchemaE.right.comment = symbol.comment; + } + const result = parseRoute(project, routeSchemaE.right); + if (E.isLeft(result)) { + schemas[symbol.name] = routeSchemaE.right; + } else { + routes.push(result.right); + } + } + } + + assert.throws( + () => + convertRoutesToOpenAPI({ title: 'Test', version: '1.0.0' }, [], routes, schemas), + /Cannot use both @public and @private/, + ); +});