@sapporta/rest-open-api 3.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ import { SchemaObject } from 'openapi3-ts';
2
+ import { schemaObjectToParameters } from './utils';
3
+ import { z } from 'zod';
4
+ import { zodV4SchemaTransformer } from './test-helpers';
5
+
6
+ const generateSchema = (schema: z.ZodTypeAny): SchemaObject => {
7
+ return zodV4SchemaTransformer({
8
+ schema,
9
+ appRoute: {
10
+ method: 'GET',
11
+ path: '/',
12
+ responses: {},
13
+ },
14
+ id: 'test',
15
+ concatenatedPath: 'test',
16
+ type: 'query',
17
+ })!;
18
+ };
19
+
20
+ describe('utils', () => {
21
+ describe('schemaObjectToParameters', () => {
22
+ const schema: SchemaObject = {
23
+ type: 'object',
24
+ properties: { name: { type: 'string' } },
25
+ required: ['name'],
26
+ };
27
+
28
+ const parameters = schemaObjectToParameters(schema, 'query');
29
+
30
+ it('should return an array of parameters', () => {
31
+ expect(parameters).toEqual([
32
+ {
33
+ name: 'name',
34
+ in: 'query',
35
+ required: true,
36
+ schema: { type: 'string' },
37
+ },
38
+ ]);
39
+ });
40
+
41
+ it('should return an empty array if the schema is not an object', () => {
42
+ const schema: SchemaObject = { type: 'string' };
43
+ const parameters = schemaObjectToParameters(schema, 'query');
44
+ expect(parameters).toEqual([]);
45
+ });
46
+
47
+ it('should return optional parameters if the schema is not required', async () => {
48
+ const schema = generateSchema(
49
+ z.object({
50
+ name: z.string().optional(),
51
+ }),
52
+ );
53
+
54
+ const parameters = schemaObjectToParameters(schema, 'query');
55
+ expect(parameters).toEqual([
56
+ {
57
+ name: 'name',
58
+ in: 'query',
59
+ schema: { type: 'string' },
60
+ },
61
+ ]);
62
+ });
63
+
64
+ it("should inlude `style: 'deepObject'` if the schema is a deep object", () => {
65
+ const schema = generateSchema(
66
+ z.object({
67
+ parent: z.object({
68
+ child: z.string(),
69
+ }),
70
+ }),
71
+ );
72
+
73
+ const parameters = schemaObjectToParameters(schema, 'query');
74
+
75
+ expect(parameters).toEqual([
76
+ {
77
+ name: 'parent',
78
+ in: 'query',
79
+ required: true,
80
+ schema: {
81
+ additionalProperties: false,
82
+ properties: {
83
+ child: { type: 'string' },
84
+ },
85
+ required: ['child'],
86
+ type: 'object',
87
+ },
88
+ style: 'deepObject',
89
+ },
90
+ ]);
91
+ });
92
+
93
+ it('should work for jsonQuery', () => {
94
+ const schema = generateSchema(
95
+ z.object({
96
+ name: z.string(),
97
+ }),
98
+ );
99
+
100
+ const parameters = schemaObjectToParameters(schema, 'query', true);
101
+ expect(parameters).toEqual([
102
+ {
103
+ name: 'name',
104
+ in: 'query',
105
+ required: true,
106
+ content: {
107
+ 'application/json': {
108
+ schema: {
109
+ type: 'string',
110
+ },
111
+ },
112
+ },
113
+ },
114
+ ]);
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,82 @@
1
+ import { ExamplesObject, ParameterObject, SchemaObject } from 'openapi3-ts';
2
+
3
+ export const schemaToParameter = (
4
+ schema: SchemaObject,
5
+ where: 'query' | 'header' | 'path',
6
+ required: boolean,
7
+ key: string,
8
+ jsonQuery: boolean,
9
+ ): ParameterObject => {
10
+ let description: string | undefined = undefined;
11
+
12
+ if ('description' in schema) {
13
+ description = schema['description'];
14
+ delete schema['description'];
15
+ }
16
+
17
+ let examples: ExamplesObject | undefined = undefined;
18
+ if ('mediaExamples' in schema) {
19
+ examples = schema['mediaExamples'];
20
+ delete schema['mediaExamples'];
21
+ }
22
+
23
+ const isDeepObject = 'properties' in schema;
24
+
25
+ if (jsonQuery) {
26
+ return {
27
+ name: key,
28
+ in: where,
29
+ ...(description && { description }),
30
+ ...(required && { required }),
31
+ content: {
32
+ 'application/json': {
33
+ schema,
34
+ ...(examples && { examples }),
35
+ },
36
+ },
37
+ };
38
+ } else {
39
+ return {
40
+ name: key,
41
+ in: where,
42
+ ...(examples && { examples }),
43
+ ...(description && { description }),
44
+ ...(required && { required }),
45
+ ...(isDeepObject && !jsonQuery && { style: 'deepObject' }),
46
+ schema,
47
+ };
48
+ }
49
+ };
50
+
51
+ /**
52
+ * Convert a @type {SchemaObject} to an array of @type {ParameterObject}
53
+ *
54
+ * @param schema - Zod 4 JSON schema output
55
+ * @param where - The location of the parameters
56
+ * @param jsonQuery - Whether the schema is a JSON query
57
+ * @returns The parameters for the schema
58
+ */
59
+ export const schemaObjectToParameters = (
60
+ schema: SchemaObject,
61
+ where: 'query' | 'header' | 'path',
62
+ jsonQuery = false,
63
+ ): ParameterObject[] => {
64
+ const parameters: ParameterObject[] = [];
65
+
66
+ if (schema.type === 'object') {
67
+ const requiredSet = new Set(schema.required ?? []);
68
+ const properties = schema.properties;
69
+
70
+ if (!properties) {
71
+ return [];
72
+ }
73
+
74
+ for (const [key, value] of Object.entries(properties)) {
75
+ parameters.push(
76
+ schemaToParameter(value, where, requiredSet.has(key), key, jsonQuery),
77
+ );
78
+ }
79
+ }
80
+
81
+ return parameters;
82
+ };
@@ -0,0 +1,245 @@
1
+ import {
2
+ type AppRoute,
3
+ type AppRouter,
4
+ isAppRouteOtherResponse,
5
+ } from '@sapporta/rest-core';
6
+ import type {
7
+ ExamplesObject,
8
+ InfoObject,
9
+ OpenAPIObject,
10
+ OperationObject,
11
+ PathsObject,
12
+ SchemaObject,
13
+ } from 'openapi3-ts';
14
+ import {
15
+ PathSchemaResults,
16
+ RouterPath,
17
+ SchemaTransformerAsync,
18
+ SchemaTransformerSync,
19
+ } from './types';
20
+ import { performContractTraversal } from './contract-traversal';
21
+ import {
22
+ convertSchemaObjectToMediaTypeObject,
23
+ extractReferenceSchemas,
24
+ } from './utils';
25
+
26
+ declare module 'openapi3-ts' {
27
+ interface SchemaObject {
28
+ mediaExamples?: ExamplesObject;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Generate OpenAPI specification from ts-rest router
34
+ *
35
+ * @param router - The ts-rest router to generate OpenAPI from
36
+ * @param apiDoc - Base OpenAPI document configuration
37
+ * @param options - Generation options
38
+ * @param options.setOperationId - Whether to set operation IDs (true, false, or 'concatenated-path')
39
+ * @param options.jsonQuery - Enable JSON query parameters, [see](/docs/open-api#json-query-params)
40
+ * @param options.operationMapper - Function to customize OpenAPI operations. Receives the operation object, app route, and operation ID
41
+ * @returns OpenAPI specification object
42
+ */
43
+ export function generateOpenApi(
44
+ router: AppRouter,
45
+ apiDoc: Omit<OpenAPIObject, 'paths' | 'openapi'> & { info: InfoObject },
46
+ options: {
47
+ setOperationId?: boolean | 'concatenated-path';
48
+ jsonQuery?: boolean;
49
+ operationMapper?: (
50
+ operation: OperationObject,
51
+ appRoute: AppRoute,
52
+ id: string,
53
+ ) => OperationObject;
54
+ schemaTransformer: SchemaTransformerSync;
55
+ },
56
+ ): OpenAPIObject {
57
+ const paths = performContractTraversal.sync({
58
+ contract: router,
59
+ transformSchema: options?.schemaTransformer,
60
+ jsonQuery: !!options?.jsonQuery,
61
+ });
62
+
63
+ return traversedPathsToOpenApi(paths, apiDoc, {
64
+ setOperationId: options?.setOperationId,
65
+ jsonQuery: options?.jsonQuery,
66
+ operationMapper: options?.operationMapper,
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Generate OpenAPI specification from ts-rest router with custom schema transformer
72
+ *
73
+ * @param router - The ts-rest router to generate OpenAPI from
74
+ * @param apiDoc - Base OpenAPI document configuration
75
+ * @param options - Generation options
76
+ * @param options.setOperationId - Whether to set operation IDs (true, false, or 'concatenated-path')
77
+ * @param options.jsonQuery - Enable JSON query parameters, [see](/docs/open-api#json-query-params)
78
+ * @param options.operationMapper - Function to customize OpenAPI operations. Receives the operation object, app route, and operation ID
79
+ * @param options.schemaTransformer - Custom schema transformer function.
80
+ */
81
+ export async function generateOpenApiAsync(
82
+ router: AppRouter,
83
+ apiDoc: Omit<OpenAPIObject, 'paths' | 'openapi'> & { info: InfoObject },
84
+ options: {
85
+ setOperationId?: boolean | 'concatenated-path';
86
+ jsonQuery?: boolean;
87
+ operationMapper?: (
88
+ operation: OperationObject,
89
+ appRoute: AppRoute,
90
+ id: string,
91
+ ) => OperationObject;
92
+ schemaTransformer: SchemaTransformerAsync;
93
+ },
94
+ ): Promise<OpenAPIObject> {
95
+ const paths = await performContractTraversal.async({
96
+ contract: router,
97
+ transformSchema: options.schemaTransformer,
98
+ jsonQuery: !!options.jsonQuery,
99
+ });
100
+
101
+ return traversedPathsToOpenApi(paths, apiDoc, {
102
+ setOperationId: options.setOperationId,
103
+ jsonQuery: options.jsonQuery,
104
+ operationMapper: options.operationMapper,
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Inner function to be reused by both sync and async functions
110
+ */
111
+ const traversedPathsToOpenApi = (
112
+ paths: Array<RouterPath & { schemaResults: PathSchemaResults }>,
113
+ apiDoc: Omit<OpenAPIObject, 'paths' | 'openapi'> & { info: InfoObject },
114
+ options: {
115
+ setOperationId?: boolean | 'concatenated-path';
116
+ jsonQuery?: boolean;
117
+ operationMapper?: (
118
+ operation: OperationObject,
119
+ appRoute: AppRoute,
120
+ id: string,
121
+ ) => OperationObject;
122
+ },
123
+ ) => {
124
+ const mapMethod = {
125
+ GET: 'get',
126
+ POST: 'post',
127
+ PUT: 'put',
128
+ DELETE: 'delete',
129
+ PATCH: 'patch',
130
+ };
131
+
132
+ const operationIds = new Map<string, string[]>();
133
+
134
+ const referenceSchemas: { [id: string]: SchemaObject } = {};
135
+
136
+ const pathObject: PathsObject = {};
137
+
138
+ for (const path of paths) {
139
+ if (options.setOperationId === true) {
140
+ const existingOp = operationIds.get(path.id);
141
+ if (existingOp) {
142
+ throw new Error(
143
+ `Route '${path.id}' already defined under ${existingOp.join('.')}`,
144
+ );
145
+ }
146
+ operationIds.set(path.id, path.paths);
147
+ }
148
+
149
+ const _bodySchema = path.schemaResults.body;
150
+ const bodySchema =
151
+ _bodySchema && typeof _bodySchema === 'object' && 'title' in _bodySchema
152
+ ? extractReferenceSchemas(
153
+ path.schemaResults.body as SchemaObject,
154
+ referenceSchemas,
155
+ )
156
+ : path.schemaResults.body;
157
+
158
+ const responses: Record<string, SchemaObject> = {};
159
+
160
+ for (const [statusCode, response] of Object.entries(path.route.responses)) {
161
+ const contentType = isAppRouteOtherResponse(response)
162
+ ? response.contentType
163
+ : 'application/json';
164
+
165
+ const responseSchemaObject = path.schemaResults.responses[statusCode];
166
+ const responseSchemaObjectWithReferences = responseSchemaObject
167
+ ? extractReferenceSchemas(responseSchemaObject, referenceSchemas)
168
+ : null;
169
+
170
+ responses[statusCode] = {
171
+ ...(responseSchemaObjectWithReferences
172
+ ? {
173
+ content: {
174
+ [contentType]: {
175
+ ...convertSchemaObjectToMediaTypeObject(
176
+ responseSchemaObjectWithReferences,
177
+ ),
178
+ },
179
+ },
180
+ }
181
+ : {}),
182
+ };
183
+ }
184
+
185
+ const contentType =
186
+ path.route?.method !== 'GET' && 'contentType' in path.route
187
+ ? path.route?.contentType ?? 'application/json'
188
+ : 'application/json';
189
+
190
+ const pathOperation: OperationObject = {
191
+ description: path.route.description,
192
+ summary: path.route.summary,
193
+ deprecated: path.route.deprecated,
194
+ tags: path.paths,
195
+ parameters: [
196
+ ...path.schemaResults.path,
197
+ ...path.schemaResults.headers,
198
+ ...path.schemaResults.query,
199
+ ],
200
+ ...(options.setOperationId
201
+ ? {
202
+ operationId:
203
+ options.setOperationId === 'concatenated-path'
204
+ ? [...path.paths, path.id].join('.')
205
+ : path.id,
206
+ }
207
+ : {}),
208
+ ...(bodySchema
209
+ ? {
210
+ requestBody: {
211
+ description: 'Body',
212
+ content: {
213
+ [contentType]: {
214
+ ...convertSchemaObjectToMediaTypeObject(bodySchema),
215
+ },
216
+ },
217
+ },
218
+ }
219
+ : {}),
220
+ responses,
221
+ };
222
+
223
+ pathObject[path.path] = {
224
+ ...pathObject[path.path],
225
+ [mapMethod[path.route.method]]: options.operationMapper
226
+ ? options.operationMapper(pathOperation, path.route, path.id)
227
+ : pathOperation,
228
+ };
229
+ }
230
+
231
+ if (Object.keys(referenceSchemas).length) {
232
+ apiDoc['components'] = {
233
+ schemas: {
234
+ ...referenceSchemas,
235
+ },
236
+ ...apiDoc['components'],
237
+ };
238
+ }
239
+
240
+ return {
241
+ openapi: '3.0.2',
242
+ paths: pathObject,
243
+ ...apiDoc,
244
+ };
245
+ };
@@ -0,0 +1,109 @@
1
+ import { AppRoute } from '@sapporta/rest-core';
2
+ import { ParameterObject, ResponseObject, SchemaObject } from 'openapi3-ts';
3
+
4
+ type SchemaTransformerArgs = {
5
+ /**
6
+ * The schema to transform.
7
+ */
8
+ schema: unknown;
9
+ /**
10
+ * The app route.
11
+ */
12
+ appRoute: AppRoute;
13
+ /**
14
+ * The key of the route in a contract e.g. `getPokemon` or `createPokemon`.
15
+ */
16
+ id: string;
17
+ /**
18
+ * The concatenated path of the route. e.g. `pokemon.getPokemon` or `pokemon.createPokemon`.
19
+ */
20
+ concatenatedPath: string;
21
+ /**
22
+ * Where the schema is used, can be used to conditionally transform the schema.
23
+ */
24
+ type: 'body' | 'response' | 'query' | 'header' | 'path';
25
+ };
26
+
27
+ /**
28
+ * Sync schema transformer.
29
+ *
30
+ * This will be invoked for all schemas, e.g. every query, pathParam, body, etc.
31
+ *
32
+ * You should guard against your schema type here, and return null if it's not the schema you want to transform.
33
+ */
34
+ export type SchemaTransformerSync = (
35
+ args: SchemaTransformerArgs,
36
+ ) => SchemaObject | null;
37
+
38
+ /**
39
+ * Async schema transformer.
40
+ *
41
+ * This will be invoked for all schemas, e.g. every query, pathParam, body, etc.
42
+ *
43
+ * You should guard against your schema type here, and return null if it's not the schema you want to transform.
44
+ */
45
+ export type SchemaTransformerAsync = (
46
+ args: SchemaTransformerArgs,
47
+ ) => Promise<SchemaObject | null>;
48
+
49
+ export type SchemaTransformer = SchemaTransformerSync | SchemaTransformerAsync;
50
+
51
+ /**
52
+ * We need to have many functions that are the same, but some are sync and some are async.
53
+ *
54
+ * This helper lets us make this trivial and avoids having different types for each function.
55
+ *
56
+ * @hidden
57
+ */
58
+ export type AsyncAndSyncHelper<T, TFuncSyncArgs, TFuncAsyncArgs, TReturn> = {
59
+ sync: (args: T & TFuncSyncArgs) => TReturn;
60
+ async: (args: T & TFuncAsyncArgs) => Promise<TReturn>;
61
+ };
62
+
63
+ /**
64
+ * @hidden
65
+ */
66
+ export type GetSyncFunction<THelper> = THelper extends AsyncAndSyncHelper<
67
+ any,
68
+ any,
69
+ any,
70
+ any
71
+ >
72
+ ? THelper['sync']
73
+ : never;
74
+
75
+ /**
76
+ * @hidden
77
+ */
78
+ export type GetAsyncFunction<THelper> = THelper extends AsyncAndSyncHelper<
79
+ any,
80
+ any,
81
+ any,
82
+ any
83
+ >
84
+ ? THelper['async']
85
+ : never;
86
+
87
+ /**
88
+ * @hidden
89
+ */
90
+ export type RouterPath = {
91
+ id: string;
92
+ path: string;
93
+ route: AppRoute;
94
+ paths: string[];
95
+ };
96
+
97
+ /**
98
+ * @hidden
99
+ */
100
+ export type PathSchemaResults = {
101
+ path: ParameterObject[];
102
+ headers: ParameterObject[];
103
+ query: ParameterObject[];
104
+ body: SchemaObject | null;
105
+ /**
106
+ * Status code to response.
107
+ */
108
+ responses: Record<string, SchemaObject>;
109
+ };
@@ -0,0 +1,92 @@
1
+ import { SchemaObject, MediaTypeObject, ReferenceObject } from 'openapi3-ts';
2
+
3
+ export const convertSchemaObjectToMediaTypeObject = (
4
+ input: SchemaObject,
5
+ ): MediaTypeObject => {
6
+ const { mediaExamples: examples, ...schema } = input;
7
+
8
+ return {
9
+ schema,
10
+ ...(examples && { examples }),
11
+ };
12
+ };
13
+
14
+ export const extractReferenceSchemas = (
15
+ schema: SchemaObject,
16
+ referenceSchemas: { [id: string]: SchemaObject },
17
+ ): SchemaObject => {
18
+ if (schema.allOf) {
19
+ schema.allOf = schema.allOf?.map((subSchema) =>
20
+ extractReferenceSchemas(subSchema, referenceSchemas),
21
+ );
22
+ }
23
+
24
+ if (schema.anyOf) {
25
+ schema.anyOf = schema.anyOf?.map((subSchema) =>
26
+ extractReferenceSchemas(subSchema, referenceSchemas),
27
+ );
28
+ }
29
+
30
+ if (schema.oneOf) {
31
+ schema.oneOf = schema.oneOf?.map((subSchema) =>
32
+ extractReferenceSchemas(subSchema, referenceSchemas),
33
+ );
34
+ }
35
+
36
+ if (schema.not) {
37
+ schema.not = extractReferenceSchemas(schema.not, referenceSchemas);
38
+ }
39
+
40
+ if (schema.items) {
41
+ schema.items = extractReferenceSchemas(schema.items, referenceSchemas);
42
+ }
43
+
44
+ if (schema.properties) {
45
+ schema.properties = Object.entries(schema.properties).reduce<{
46
+ [p: string]: SchemaObject | ReferenceObject;
47
+ }>((prev, [propertyName, schema]) => {
48
+ prev[propertyName] = extractReferenceSchemas(schema, referenceSchemas);
49
+ return prev;
50
+ }, {});
51
+ }
52
+
53
+ if (schema.additionalProperties) {
54
+ schema.additionalProperties =
55
+ typeof schema.additionalProperties != 'boolean'
56
+ ? extractReferenceSchemas(schema.additionalProperties, referenceSchemas)
57
+ : schema.additionalProperties;
58
+ }
59
+
60
+ if (schema.title) {
61
+ const nullable = schema.nullable;
62
+ schema.nullable = undefined;
63
+ if (schema.title in referenceSchemas) {
64
+ if (
65
+ JSON.stringify(referenceSchemas[schema.title]) !==
66
+ JSON.stringify(schema)
67
+ ) {
68
+ throw new Error(
69
+ `Schema title '${schema.title}' already exists with a different schema`,
70
+ );
71
+ }
72
+ } else {
73
+ referenceSchemas[schema.title] = schema;
74
+ }
75
+
76
+ if (nullable) {
77
+ schema = {
78
+ nullable: true,
79
+ allOf: [
80
+ {
81
+ $ref: `#/components/schemas/${schema.title}`,
82
+ },
83
+ ],
84
+ };
85
+ } else {
86
+ schema = {
87
+ $ref: `#/components/schemas/${schema.title}`,
88
+ };
89
+ }
90
+ }
91
+ return schema;
92
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "forceConsistentCasingInFileNames": true,
6
+ "strict": true,
7
+ "noImplicitOverride": true,
8
+ "noPropertyAccessFromIndexSignature": true,
9
+ "noImplicitReturns": true,
10
+ "noFallthroughCasesInSwitch": true
11
+ },
12
+ "files": [],
13
+ "include": [],
14
+ "references": [
15
+ {
16
+ "path": "./tsconfig.lib.json"
17
+ },
18
+ {
19
+ "path": "./tsconfig.spec.json"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../../dist/out-tsc",
5
+ "declaration": true,
6
+ "types": []
7
+ },
8
+ "include": ["**/*.ts"],
9
+ "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"]
10
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../../dist/out-tsc",
5
+ "module": "commonjs",
6
+ "types": ["jest", "node"]
7
+ },
8
+ "include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"]
9
+ }
package/typedoc.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": ["../../../typedoc.base.json"],
3
+ "entryPoints": ["src/index.ts"],
4
+ "tsconfig": "./tsconfig.lib.json"
5
+ }