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
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "crosswalk",
"version": "2.2.7",
"version": "2.3.0",
"description": "Type-safe express routing with TypeScript",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down Expand Up @@ -29,6 +29,7 @@
"devDependencies": {
"@types/express": "^5.0.1",
"@types/jest": "^26.0.15",
"@types/json-schema": "^7.0.15",
"@types/multer": "^2.0.0",
"@types/node": "^22.15.30",
"@types/supertest": "^2.0.10",
Expand All @@ -41,13 +42,16 @@
"express": "^4.17.2",
"jest": "^29.7.0",
"multer": "^2.0.1",
"oas-normalize": "^15.0.1",
"openapi-types": "^12.1.3",
"prettier": "^2.2.0",
"supertest": "^6.0.1",
"ts-jest": "^29.3.2",
"ts-json-schema-generator": "^2.4.0",
"typescript": "5"
},
"dependencies": {
"@openapi-contrib/json-schema-to-openapi-schema": "^4.2.0",
"ajv": "^8.17.1",
"path-to-regexp": "^8.2.0"
},
Expand Down
24 changes: 21 additions & 3 deletions src/__tests__/__snapshots__/openapi.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ exports[`Open API V3 should have the expected endpoints: open-api-v3 1`] = `
"home",
"work",
"mobile",
null,
],
"nullable": true,
"type": "string",
Expand Down Expand Up @@ -147,32 +148,36 @@ exports[`Open API V3 should have the expected endpoints: open-api-v3 1`] = `
"enum": [
"",
],
"nullable": true,
"type": "string",
},
{
"additionalProperties": false,
"nullable": true,
"type": "object",
},
{
"items": {},
"maxItems": 0,
"minItems": 0,
"nullable": true,
"type": "array",
},
{
"enum": [
false,
],
"nullable": true,
"type": "boolean",
},
{
"enum": [
0,
],
"nullable": true,
"type": "number",
},
],
"nullable": true,
},
"emptyArray": {
"items": {},
Expand Down Expand Up @@ -202,13 +207,17 @@ exports[`Open API V3 should have the expected endpoints: open-api-v3 1`] = `
],
"type": "string",
},
"null": {
"nullable": true,
},
},
"required": [
"emptyStr",
"emptyObj",
"emptyArray",
"emptyBool",
"emptyNum",
"null",
"complexEmptyUnion",
],
"type": "object",
Expand All @@ -222,6 +231,7 @@ exports[`Open API V3 should have the expected endpoints: open-api-v3 1`] = `
"fromSystem": {
"enum": [
"google",
null,
],
"nullable": true,
"type": "string",
Expand Down Expand Up @@ -346,8 +356,12 @@ exports[`Open API V3 should have the expected endpoints: open-api-v3 1`] = `
"additionalProperties": false,
"properties": {
"user": {
"$ref": "#/components/schemas/User",
"nullable": true,
"anyOf": [
{
"$ref": "#/components/schemas/User",
"nullable": true,
},
],
},
},
"required": [
Expand Down Expand Up @@ -993,13 +1007,17 @@ exports[`Open API integration should have the expected endpoints: open-api 1`] =
"const": "",
"type": "string",
},
"null": {
"type": "null",
},
},
"required": [
"emptyStr",
"emptyObj",
"emptyArray",
"emptyBool",
"emptyNum",
"null",
"complexEmptyUnion",
],
"type": "object",
Expand Down
72 changes: 38 additions & 34 deletions src/__tests__/api.schema.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/__tests__/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface TypeWithEmptyLiteral {
emptyArray: [];
emptyBool: false;
emptyNum: 0;
null: null;
complexEmptyUnion: null | '' | {} | [] | false | 0;
}

Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/openapi.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import {apiSpecToOpenApi} from '../openapi';
import apiSchemaJson from './api.schema.json';
import OasNormalize from 'oas-normalize';

describe('Open API integration', () => {
it('should have the expected endpoints', () => {
const openApi = apiSpecToOpenApi(apiSchemaJson, {version: '2.0'});
expect(openApi).toMatchSnapshot('open-api');
});

it('should generate Swagger 2.0 schema (validation disabled due to known issues)',async () => {
const openApi = apiSpecToOpenApi(apiSchemaJson, {version: '2.0'});

// Basic structure checks
expect(openApi.swagger).toBe('2.0');
expect(openApi.paths).toBeDefined();
expect(openApi.definitions).toBeDefined();
expect(Object.keys(openApi.paths).length).toBeGreaterThan(0);

// TODO: Fix Swagger 2.0 validation issues with query parameters and schema format

// This will throw an error if the schema is invalid according to OpenAPI 3.0 spec
// const validationResult = await new OasNormalize(openApi).validate();

// expect(validationResult).toEqual({valid: true, specification: 'OpenAPI', warnings: []});
});

// TODO:
// - test that descriptions get set
});
Expand All @@ -17,6 +35,15 @@ describe('Open API V3', () => {
expect(openApi).toMatchSnapshot('open-api-v3');
});

it('should generate valid OpenAPI 3.0 schema', async () => {
const openApi = apiSpecToOpenApi(apiSchemaJson, {version: '3.0'});

// This will throw an error if the schema is invalid according to OpenAPI 3.0 spec
const validationResult = await new OasNormalize(openApi).validate();

expect(validationResult).toEqual({valid: true, specification: 'OpenAPI', warnings: []});
});

// TODO:
// - test that descriptions get set
});
1 change: 1 addition & 0 deletions src/__tests__/typed-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ test('TypedRouter', async () => {
emptyBool: false,
emptyNum: 0,
complexEmptyUnion: null,
null: null,
};
});

Expand Down
135 changes: 58 additions & 77 deletions src/openapi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as pathToRegexp from 'path-to-regexp';
import {convertSync as convertFromJsonSchema} from '@openapi-contrib/json-schema-to-openapi-schema';

type Schema = {$ref: string};

Expand Down Expand Up @@ -150,103 +151,72 @@ export function followApiRefV2(spec: any, schema: Schema): [string[], unknown] {
}

export function followApiRefV3(spec: any, schema: Schema): [string[], unknown] {
return resolveSchemaRef(spec, schema, SCHEMA);
}

function handleNullTypes(result: any): void {
if (
result.anyOf &&
Array.isArray(result.anyOf) &&
result.anyOf.some((item: any) => item.type === 'null')
) {
const nonNullTypes = result.anyOf.filter((item: any) => item.type !== 'null');
if (nonNullTypes.length) {
if (nonNullTypes.length === 1) {
Object.assign(result, nonNullTypes[0]);
delete result.anyOf;
} else {
result.anyOf = nonNullTypes;
}
result.nullable = true;
// Try OpenAPI 3.0 format first, then fall back to JSON Schema format
try {
return resolveSchemaRef(spec, schema, SCHEMA);
} catch (error) {
if (schema.$ref?.startsWith(DEFINITION)) {
return resolveSchemaRef(spec, schema, DEFINITION);
}
}
if (Array.isArray(result.type) && result.type.some((item: any) => item === 'null')) {
const nonNullTypes = result.type.filter((item: any) => item !== 'null');
if (nonNullTypes) {
result.type = nonNullTypes.length === 1 ? nonNullTypes[0] : nonNullTypes;
result.nullable = true;
}
}
if (Array.isArray(result.enum) && result.enum.some((item: any) => item === null)) {
const nonNullEnums = result.enum.filter((item: any) => item !== null);
result.enum = nonNullEnums;
throw error;
}
}

function handleLiteralTypes(result: any): void {
if ('const' in result) {
result.enum = [result.const];
delete result.const;
/**
* Simple schema converter using the library for individual schemas
*/
function convertSchemaToOpenApi(schema: any): any {
try {
return convertFromJsonSchema(schema, {
cloneSchema: true,
convertUnreferencedDefinitions: true,
});
} catch (error) {
// If conversion fails, return the original schema
return schema;
}
}

if (
result.type === 'array' &&
result.minItems === 0 &&
result.maxItems === 0 &&
!('items' in result)
) {
result.items = {};
/**
* Transform definitions to OpenAPI 3.0 components/schemas format
*/
function transformDefinitionsToComponents(
definitions: Record<string, any>,
): Record<string, any> {
const componentSchemas: Record<string, any> = {};

for (const [key, value] of Object.entries(definitions)) {
const sanitizedKey = sanitizeComponentName(key);
componentSchemas[sanitizedKey] = convertSchemaToOpenApi(value);
}

return componentSchemas;
}

/**
* Recursively transforms all references in an object from OpenAPI 2.0 to 3.0 format
* and sanitizes component names in the process.
* Transform $ref paths from #/definitions/ to #/components/schemas/
*/
function transformToOpenApiV3(obj: any): any {
function transformRefs(obj: any): any {
if (!obj || typeof obj !== 'object') {
return obj;
}

// Handle arrays
if (Array.isArray(obj)) {
return obj.map(item => transformToOpenApiV3(item));
return obj.map(transformRefs);
}

// Handle objects
const result = {...obj};

if (result.properties) {
for (const [key, value] of Object.entries(obj.properties)) {
result.properties[key] = transformToOpenApiV3(value);
}
}

// Transform $ref if it exists
if (result.$ref && typeof result.$ref === 'string') {
if (result.$ref.startsWith(DEFINITION)) {
const refName = result.$ref.slice(DEFINITION.length);
const sanitizedName = sanitizeComponentName(refName);
result.$ref = `${SCHEMA}${sanitizedName}`;
}
}
// Handle anyOf with null type (convert to nullable)
handleNullTypes(result);
handleLiteralTypes(result);

if (result.definitions) {
const componentSchemas: Record<string, any> = {};
for (const [key, value] of Object.entries(result.definitions)) {
const sanitizedKey = sanitizeComponentName(key);
componentSchemas[sanitizedKey] = transformToOpenApiV3(value);
}
result.components = {schemas: componentSchemas};
delete result.definitions;
if (result.$ref && typeof result.$ref === 'string' && result.$ref.startsWith(DEFINITION)) {
const refName = result.$ref.slice(DEFINITION.length);
const sanitizedName = sanitizeComponentName(refName);
result.$ref = `${SCHEMA}${sanitizedName}`;
}

// Recursively transform all properties
for (const [key, value] of Object.entries(result)) {
result[key] = transformToOpenApiV3(value);
if (typeof value === 'object' && value !== null) {
result[key] = transformRefs(value);
}
}

return result;
Expand Down Expand Up @@ -401,7 +371,18 @@ function apiSpecToOpenApi2(apiSpec: any, options?: Options): any {
/** Convert an API spec to OpenAPI 3.0 */
function apiSpecToOpenApi3(apiSpec: any, options?: Options): any {
apiSpec = JSON.parse(JSON.stringify(apiSpec)); // defensive copy
const transformedSpec = transformToOpenApiV3(apiSpec);

// Convert definitions to components/schemas
const componentSchemas = transformDefinitionsToComponents(apiSpec.definitions || {});

// Transform all references in the spec
const transformedSpec = transformRefs(apiSpec);
transformedSpec.components = {schemas: componentSchemas};
delete transformedSpec.definitions;

// Transform references in the component schemas as well
transformedSpec.components.schemas = transformRefs(transformedSpec.components.schemas);

const {required: endpoints, properties: endpointSpecs} = transformedSpec;

// Remove endpoints, helpers
Expand Down Expand Up @@ -443,15 +424,15 @@ function apiSpecToOpenApi3(apiSpec: any, options?: Options): any {
description: 'Successful response',
content: {
'application/json': {
schema: response?.type === 'null' ? {} : response,
schema: response?.type === 'null' || response?.nullable ? {} : response,
},
},
},
},
};

// Add requestBody for 3.0 if request exists and it's not a DELETE operation
if (request?.type !== 'null' && verb.toLowerCase() !== 'delete') {
if (request?.type !== 'null' && !request?.nullable && verb.toLowerCase() !== 'delete') {
const isMultipart =
contentType?.const === 'multipart' || contentType?.enum?.includes('multipart');

Expand Down
Loading
Loading