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
33 changes: 30 additions & 3 deletions packages/openapi-generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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,
});
```

Expand Down
26 changes: 21 additions & 5 deletions packages/openapi-generator/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
Expand All @@ -362,13 +366,20 @@ 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;

const knownTags = new Set([
'operationId',
'summary',
'private',
'public',
'unstable',
'example',
'tag',
Expand Down Expand Up @@ -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 }
Expand All @@ -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'];
}

Expand All @@ -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
Expand Down
139 changes: 139 additions & 0 deletions packages/openapi-generator/test/openapi/base.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
Expand Down Expand Up @@ -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<string, SourceFile> = { './index.ts': sourceFile };
const project = new Project(files);
const routes: Route[] = [];
const schemas: Record<string, Schema> = {};
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/,
);
});
Loading
Loading