Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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
3 changes: 2 additions & 1 deletion packages/rtk-query-codegen-openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@
},
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.1",
"@oazapfts/resolve": "^1.0.0",
"commander": "^6.2.0",
"lodash.camelcase": "^4.3.0",
"oazapfts": "^6.4.0",
"oazapfts": "7.5.0-alpha.3",
"prettier": "^3.2.5",
"semver": "^7.3.5",
"swagger2openapi": "^7.0.4",
Expand Down
157 changes: 119 additions & 38 deletions packages/rtk-query-codegen-openapi/src/generate.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import camelCase from 'lodash.camelcase';
import path from 'node:path';
import ApiGenerator, {
getOperationName as _getOperationName,
createPropertyAssignment,
createQuestionToken,
getReferenceName,
isReference,
isValidIdentifier,
keywordType,
supportDeepObjects,
} from 'oazapfts/generate';
import type { OpenAPIV3 } from 'openapi-types';
import ts from 'typescript';
import { UNSTABLE_cg as cg } from 'oazapfts';
import { createContext, withMode, type OazapftsContext, type OnlyMode } from 'oazapfts/context';
import { getTypeFromSchema, getResponseType, getSchemaFromContent } from 'oazapfts/generate';
import { resolve, resolveArray, isReference, getReferenceName } from '@oazapfts/resolve';
import type { ObjectPropertyDefinitions } from './codegen';
import { generateCreateApiCall, generateEndpointDefinition, generateImportNode, generateTagTypes } from './codegen';
import { generateReactHooks } from './generators/react-hooks';
Expand All @@ -24,12 +18,103 @@ import type {
ParameterMatcher,
TextMatcher,
} from './types';
import { capitalize, getOperationDefinitions, getV3Doc, removeUndefined, isQuery as testIsQuery } from './utils';
import {
capitalize,
getOperationDefinitions,
getOperationName as _getOperationName,
getV3Doc,
removeUndefined,
isQuery as testIsQuery,
} from './utils';
import { factory } from './utils/factory';

const { createPropertyAssignment, createQuestionToken, keywordType, isValidIdentifier } = cg;

const generatedApiName = 'injectedRtkApi';
const v3DocCache: Record<string, OpenAPIV3.Document> = {};

function supportDeepObjects(
params: OpenAPIV3.ParameterObject[]
): OpenAPIV3.ParameterObject[] {
const res: OpenAPIV3.ParameterObject[] = [];
const merged: Record<string, any> = {};
for (const p of params) {
const m = /^(.+?)\[(.*?)\]/.exec(p.name);
if (!m) {
res.push(p);
continue;
}
const [, name, prop] = m;
let obj = merged[name];
if (!obj) {
obj = merged[name] = {
name,
in: p.in,
style: 'deepObject',
schema: {
type: 'object',
properties: {} as Record<string, any>,
},
};
res.push(obj);
}
obj.schema.properties[prop] = p.schema;
}
return res;
}

function preprocessComponents(ctx: OazapftsContext): void {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7.5.0-alpha.5 does this within createContext

const schemas = (ctx.spec as any).components?.schemas;
if (!schemas) return;

const prefix = '#/components/schemas/';

for (const name of Object.keys(schemas)) {
const schema = schemas[name];
if (isReference(schema) || typeof schema === 'boolean') continue;
if (schema.discriminator && !schema.oneOf && !schema.anyOf) {
ctx.discriminatingSchemas.add(schema);
}
}

for (const name of Object.keys(schemas)) {
const schema = schemas[name];
if (isReference(schema) || typeof schema === 'boolean' || !schema.allOf) continue;

for (const childSchema of schema.allOf) {
if (!isReference(childSchema)) continue;
const resolved = resolve<OpenAPIV3.SchemaObject>(childSchema, ctx);
if (!ctx.discriminatingSchemas.has(resolved as any)) continue;

const refBasename = childSchema.$ref.split('/').pop()!;
const discriminatingSchema = schemas[refBasename];
if (isReference(discriminatingSchema)) continue;

const discriminator = discriminatingSchema.discriminator;
if (!discriminator) continue;

const refs = Object.values(discriminator.mapping || {});
if (refs.includes(prefix + name)) continue;

if (!discriminator.mapping) {
discriminator.mapping = {};
}
discriminator.mapping[name] = prefix + name;
}
}
}

function getTypeFromResponse(
ctx: OazapftsContext,
response: OpenAPIV3.ResponseObject | OpenAPIV3.ReferenceObject,
onlyMode?: OnlyMode
): ts.TypeNode {
const resolved = resolve(response, ctx);
if (!resolved.content) return keywordType.void;
const schema = getSchemaFromContent(resolved.content);
return getTypeFromSchema(onlyMode ? withMode(ctx, onlyMode) : ctx, schema);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7.5.0-alpha.5 exposes this under oazapfts/generate


function defaultIsDataResponse(code: string, includeDefault: boolean) {
if (includeDefault && code === 'default') {
return true;
Expand Down Expand Up @@ -89,9 +174,9 @@ function withQueryComment<T extends ts.Node>(node: T, def: QueryArgDefinition, h

function getPatternFromProperty(
property: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
apiGen: ApiGenerator
ctx: OazapftsContext
): string | null {
const resolved = apiGen.resolve(property);
const resolved = resolve(property, ctx);
if (!resolved || typeof resolved !== 'object' || !('pattern' in resolved)) return null;
if (resolved.type !== 'string') return null;
const pattern = resolved.pattern;
Expand All @@ -101,15 +186,15 @@ function getPatternFromProperty(
function generateRegexConstantsForType(
typeName: string,
schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
apiGen: ApiGenerator
ctx: OazapftsContext
): ts.VariableStatement[] {
const resolvedSchema = apiGen.resolve(schema);
const resolvedSchema = resolve(schema, ctx);
if (!resolvedSchema || !('properties' in resolvedSchema) || !resolvedSchema.properties) return [];

const constants: ts.VariableStatement[] = [];

for (const [propertyName, property] of Object.entries(resolvedSchema.properties)) {
const pattern = getPatternFromProperty(property, apiGen);
const pattern = getPatternFromProperty(property, ctx);
if (!pattern) continue;

const constantName = camelCase(`${typeName} ${propertyName} Pattern`);
Expand Down Expand Up @@ -174,17 +259,14 @@ export async function generateApi(
) {
const v3Doc = (v3DocCache[spec] ??= await getV3Doc(spec, httpResolverOptions));

const apiGen = new ApiGenerator(v3Doc, {
unionUndefined,
const ctx = createContext(v3Doc as any, {
useEnumType,
unionUndefined,
mergeReadWriteOnly,
useUnknown,
});

// temporary workaround for https://github.com/oazapfts/oazapfts/issues/491
if (apiGen.spec.components?.schemas) {
apiGen.preprocessComponents(apiGen.spec.components.schemas);
}
preprocessComponents(ctx);

const operationDefinitions = getOperationDefinitions(v3Doc).filter(operationMatches(filterEndpoints));

Expand Down Expand Up @@ -258,18 +340,18 @@ export async function generateApi(
),
...Object.values(interfaces),
...(outputRegexConstants
? apiGen.aliases.flatMap((alias) => {
? ctx.aliases.flatMap((alias) => {
if (!ts.isInterfaceDeclaration(alias) && !ts.isTypeAliasDeclaration(alias)) return [alias];

const typeName = alias.name.escapedText.toString();
const schema = v3Doc.components?.schemas?.[typeName];
if (!schema) return [alias];

const regexConstants = generateRegexConstantsForType(typeName, schema, apiGen);
const regexConstants = generateRegexConstantsForType(typeName, schema, ctx);
return regexConstants.length > 0 ? [alias, ...regexConstants] : [alias];
})
: apiGen.aliases),
...apiGen.enumAliases,
: ctx.aliases),
...ctx.enumAliases,
...(hooks
? [
generateReactHooks({
Expand Down Expand Up @@ -318,21 +400,21 @@ export async function generateApi(
const tags = tag ? getTags({ verb, pathItem }) : undefined;
const isQuery = testIsQuery(verb, overrides);

const returnsJson = apiGen.getResponseType(responses) === 'json';
const returnsJson = getResponseType(ctx, responses) === 'json';
let ResponseType: ts.TypeNode = factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword);
if (returnsJson) {
const returnTypes = Object.entries(responses || {})
.map(
([code, response]) =>
[
code,
apiGen.resolve(response),
apiGen.getTypeFromResponse(response, 'readOnly') ||
resolve(response, ctx),
getTypeFromResponse(ctx, response, 'readOnly') ||
factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword),
] as const
)
.filter(([status, response]) =>
isDataResponse(status, includeDefault, apiGen.resolve(response), responses || {})
isDataResponse(status, includeDefault, resolve(response, ctx), responses || {})
)
.filter(([_1, _2, type]) => type !== keywordType.void)
.map(([code, response, type]) =>
Expand All @@ -359,9 +441,8 @@ export async function generateApi(
).name
);

const operationParameters = apiGen.resolveArray(operation.parameters);
const pathItemParameters = apiGen
.resolveArray(pathItem.parameters)
const operationParameters = resolveArray(ctx, operation.parameters);
const pathItemParameters = resolveArray(ctx, pathItem.parameters)
.filter((pp) => !operationParameters.some((op) => op.name === pp.name && op.in === pp.in));

const parameters = supportDeepObjects([...pathItemParameters, ...operationParameters]).filter(
Expand Down Expand Up @@ -395,16 +476,16 @@ export async function generateApi(
origin: 'param',
name,
originalName: param.name,
type: apiGen.getTypeFromSchema(isReference(param) ? param : param.schema, undefined, 'writeOnly'),
type: getTypeFromSchema(withMode(ctx, 'writeOnly'), isReference(param) ? param : param.schema),
required: param.required,
param,
};
}

if (requestBody) {
const body = apiGen.resolve(requestBody);
const schema = apiGen.getSchemaFromContent(body.content);
const type = apiGen.getTypeFromSchema(schema);
const body = resolve(requestBody, ctx);
const schema = getSchemaFromContent(body.content);
const type = getTypeFromSchema(ctx, schema);
const schemaName = camelCase(
(type as any).name ||
getReferenceName(schema) ||
Expand All @@ -417,7 +498,7 @@ export async function generateApi(
origin: 'body',
name,
originalName: schemaName,
type: apiGen.getTypeFromSchema(schema, undefined, 'writeOnly'),
type: getTypeFromSchema(withMode(ctx, 'writeOnly'), schema),
required: true,
body,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ts from 'typescript';
import { getOperationName } from 'oazapfts/generate';
import { getOperationName } from '../utils';
import { capitalize, isQuery } from '../utils';
import type { OperationDefinition, EndpointOverrides, ConfigFile } from '../types';
import { getOverrides } from '../generate';
Expand Down
15 changes: 14 additions & 1 deletion packages/rtk-query-codegen-openapi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,23 @@ export function parseConfig(fullConfig: ConfigFile) {
/**
* Enforces `oazapfts` to use the same TypeScript version as this module itself uses.
* That should prevent enums from running out of sync if both libraries use different TS versions.
*
* In oazapfts v7, TypeScript is a peerDependency so this is typically a no-op,
* but we keep it for safety in environments with complex dependency resolution.
*/
function enforceOazapftsTsVersion<T>(cb: () => T): T {
const ozTsPath = require.resolve('typescript', { paths: [require.resolve('oazapfts')] });
let ozTsPath: string;
try {
ozTsPath = require.resolve('typescript', { paths: [require.resolve('oazapfts')] });
} catch {
// In oazapfts v7+, TypeScript is a peerDependency and may resolve to the
// same path. If resolution fails, just run the callback directly.
return cb();
}
const tsPath = require.resolve('typescript');
if (ozTsPath === tsPath) {
return cb();
}
const originalEntry = require.cache[ozTsPath];
try {
require.cache[ozTsPath] = require.cache[tsPath];
Expand Down
25 changes: 25 additions & 0 deletions packages/rtk-query-codegen-openapi/src/utils/getOperationName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import lodashCamelCase from 'lodash.camelcase';
import { UNSTABLE_cg as cg } from 'oazapfts';

const { isValidIdentifier } = cg;

export function getOperationName(
verb: string,
path: string,
operationId?: string
): string {
if (operationId) {
const normalized = operationId.replace(/[^\w\s]/g, ' ');
let camelCased = lodashCamelCase(normalized);
if (camelCased) {
camelCased = camelCased.replace(/^[^a-zA-Z_$]+/, '');
if (camelCased && isValidIdentifier(camelCased)) {
return camelCased;
}
}
}
const pathStr = path
.replace(/\{(.+?)\}/, 'by $1')
.replace(/\{(.+?)\}/, 'and $1');
return lodashCamelCase(`${verb} ${pathStr}`);
}
1 change: 1 addition & 0 deletions packages/rtk-query-codegen-openapi/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './capitalize';
export * from './getOperationDefinitions';
export * from './getOperationName';
export * from './getV3Doc';
export * from './isQuery';
export * from './isValidUrl';
Expand Down
14 changes: 14 additions & 0 deletions packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,20 @@ describe('openapi spec', () => {
});
});

describe('useEnumType option', () => {
it('generates TypeScript enums when useEnumType is true', async () => {
const api = await generateEndpoints({
unionUndefined: true,
schemaFile: resolve(__dirname, 'fixtures/petstore.json'),
apiFile: './fixtures/emptyApi.ts',
useEnumType: true,
filterEndpoints: ['findPetsByStatus'],
});

expect(api).toMatch(/enum\s+\w+/);
});
});

describe('query parameters', () => {
it('parameters overridden in swagger should also be overridden in the code', async () => {
const api = await generateEndpoints({
Expand Down
Loading