Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expand JSON Schema coverage: arbitrary identifiers, better array type support #3

Merged
merged 1 commit into from
Jun 27, 2024
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
25 changes: 19 additions & 6 deletions src/analysis/GraphAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ const depsShallow = (schema: OpenApiSchema): Set<Ref> => {
if ('$ref' in schema) { // Case: OpenApi.ReferenceObject
return new Set([schema.$ref]);
} else { // Case: OpenApi.SchemaObject
if ('items' in schema) { // Case: OpenApi.ArraySchemaObject
return depsShallow(schema.items);
if (schema.type === 'array' || 'items' in schema) { // Case: OpenApi.ArraySchemaObject
if ('items' in schema) {
return depsShallow(schema.items);
} else { // Array of unknown
return new Set();
}
} else { // Case: OpenApi.NonArraySchemaObject
if ('allOf' in schema && typeof schema.allOf !== 'undefined') {
return new Set(schema.allOf.flatMap(subschema => [...depsShallow(subschema)]));
Expand All @@ -57,6 +61,7 @@ const depsShallow = (schema: OpenApiSchema): Set<Ref> => {
}

switch (schema.type) {
case undefined: // Any type
case 'null':
case 'string':
case 'number':
Expand Down Expand Up @@ -86,8 +91,12 @@ const depsDeep = (schema: OpenApiSchema, resolve: Resolve, visited: Set<Ref>): D
if (visited.has(schema.$ref)) { return { [schema.$ref]: 'recurse' }; }
return { [schema.$ref]: depsDeep(resolve(schema.$ref), resolve, new Set([...visited, schema.$ref])) };
} else { // Case: OpenApi.SchemaObject
if ('items' in schema) { // Case: OpenApi.ArraySchemaObject
return depsDeep(schema.items, resolve, visited);
if (schema.type === 'array' || 'items' in schema) { // Case: OpenApi.ArraySchemaObject
if ('items' in schema) {
return depsDeep(schema.items, resolve, visited);
} else { // Array of unknown
return {};
}
} else { // Case: OpenApi.NonArraySchemaObject
if ('allOf' in schema && typeof schema.allOf !== 'undefined') {
return Object.assign({}, ...schema.allOf.flatMap(subschema => depsDeep(subschema, resolve, visited)));
Expand All @@ -98,6 +107,8 @@ const depsDeep = (schema: OpenApiSchema, resolve: Resolve, visited: Set<Ref>): D
}

switch (schema.type) {
case undefined: // Any type
return {};
case 'null':
case 'string':
case 'number':
Expand Down Expand Up @@ -249,8 +260,8 @@ const _isObjectSchema = (schema: OpenApiSchema, resolve: Resolve, visited: Set<R
}
return _isObjectSchema(resolve(schema.$ref), resolve, new Set([...visited, schema.$ref]));
} else { // Case: OpenApi.SchemaObject
if ('items' in schema) { // Case: OpenApi.ArraySchemaObject
return _isObjectSchema(schema.items, resolve, visited);
if (schema.type === 'array' || 'items' in schema) { // Case: OpenApi.ArraySchemaObject
return false;
} else { // Case: OpenApi.NonArraySchemaObject
if ('allOf' in schema && typeof schema.allOf !== 'undefined') {
return schema.allOf.flatMap(subschema => _isObjectSchema(subschema, resolve, visited)).every(Boolean);
Expand All @@ -261,6 +272,8 @@ const _isObjectSchema = (schema: OpenApiSchema, resolve: Resolve, visited: Set<R
}

switch (schema.type) {
case undefined: // Any type
return false; // Possibly an object, but we cannot know
case 'null':
case 'string':
case 'number':
Expand Down
39 changes: 21 additions & 18 deletions src/generation/effSchemGen/schemaGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,26 @@ import { type OpenAPIV3_1 as OpenApi } from 'openapi-types';
import { OpenApiSchemaId, type OpenApiRef, type OpenApiSchema } from '../../util/openapi.ts';

import * as GenSpec from '../generationSpec.ts';
import { isObjectSchema } from '../../analysis/GraphAnalyzer.ts';
import { isObjectSchema, schemaIdFromRef } from '../../analysis/GraphAnalyzer.ts';
import { type GenResult, GenResultUtil } from './genUtil.ts';


const id = GenResultUtil.encodeIdentifier;

export type Context = {
schemas: Record<string, OpenApiSchema>,
hooks: GenSpec.GenerationHooks,
isSchemaIdBefore: (schemaId: OpenApiSchemaId) => boolean,
};

export const generateForUnknownSchema = (ctx: Context, schema: OpenApi.NonArraySchemaObject): GenResult => {
return {
code: `S.Unknown`,
refs: [],
comments: GenResultUtil.commentsFromSchemaObject(schema),
};
};

export const generateForNullSchema = (ctx: Context, schema: OpenApi.NonArraySchemaObject): GenResult => {
return {
code: `S.Null`,
Expand All @@ -32,11 +42,11 @@ export const generateForStringSchema = (ctx: Context, schema: OpenApi.NonArraySc
let refs: GenResult['refs'] = [];
const code = ((): string => {
if (Array.isArray(schema.enum)) {
if (!schema.enum.every(value => typeof value === 'string')) {
if (!schema.enum.every(value => typeof value === 'string' || typeof value === 'number')) {
throw new TypeError(`Unknown enum value, expected string array: ${JSON.stringify(schema.enum)}`);
}
return dedent`S.Literal(
${schema.enum.map((value: string) => JSON.stringify(value) + ',').join('\n')}
${schema.enum.map((value: string | number) => JSON.stringify(String(value)) + ',').join('\n')}
)`;
}

Expand Down Expand Up @@ -293,17 +303,13 @@ export const generateForArraySchema = (ctx: Context, schema: OpenApi.ArraySchema

export const generateForReferenceObject = (ctx: Context, schema: OpenApi.ReferenceObject): GenResult => {
// FIXME: make this logic customizable (allow a callback to resolve a `$ref` string to a `Ref` instance?)
const matches = schema.$ref.match(/^#\/components\/schemas\/([a-zA-Z0-9_$]+)/);
if (!matches) {
throw new Error(`Reference format not supported: ${schema.$ref}`);
}

const schemaId = matches[1];
if (typeof schemaId === 'undefined') { throw new Error('Should not happen'); }
const schemaId = schemaIdFromRef(schema.$ref);

// If the referenced schema ID is topologically after the current one, wrap it in `S.suspend` for lazy eval
const shouldSuspend = !ctx.isSchemaIdBefore(schemaId);
const code = shouldSuspend ? `S.suspend((): S.Schema<_${schemaId}, _${schemaId}Encoded> => ${schemaId})` : schemaId;
const code = shouldSuspend
? `S.suspend((): S.Schema<_${id(schemaId)}, _${id(schemaId)}Encoded> => ${id(schemaId)})`
: id(schemaId);

return { code, refs: [`./${schemaId}.ts`], comments: GenResultUtil.initComments() };
};
Expand All @@ -317,8 +323,8 @@ export const generateForSchema = (ctx: Context, schema: OpenApiSchema): GenResul
if ('$ref' in schema) { // Case: OpenApi.ReferenceObject
return generateForReferenceObject(ctx, schema);
} else { // Case: OpenApi.SchemaObject
if ('items' in schema && schema.type === 'array') { // Case: OpenApi.ArraySchemaObject
return generateForArraySchema(ctx, schema);
if (schema.type === 'array' || 'items' in schema) { // Case: OpenApi.ArraySchemaObject
return generateForArraySchema(ctx, { items: {}, ...schema } as OpenApi.ArraySchemaObject);
} else if (isNonArraySchemaType(schema)) { // Case: OpenApi.NonArraySchemaObject
if ('allOf' in schema && typeof schema.allOf !== 'undefined') {
const schemasHead: undefined | OpenApiSchema = schema.allOf[0];
Expand Down Expand Up @@ -483,7 +489,7 @@ export const generateForSchema = (ctx: Context, schema: OpenApiSchema): GenResul
})
.join('\n')
}
);
)
`;
return {
code,
Expand All @@ -496,17 +502,14 @@ export const generateForSchema = (ctx: Context, schema: OpenApiSchema): GenResul
type SchemaType = 'array' | OpenApi.NonArraySchemaObjectType;
const type: undefined | OpenApi.NonArraySchemaObjectType | Array<SchemaType> = schema.type;

if (typeof type === 'undefined') {
throw new TypeError(`Missing 'type' in schema`);
}

const hookResult: null | GenResult = ctx.hooks.generateSchema?.(schema) ?? null;

let result: GenResult;
if (hookResult !== null) {
result = hookResult;
} else {
switch (type) {
case undefined: result = generateForUnknownSchema(ctx, schema); break;
case 'null': result = generateForNullSchema(ctx, schema); break;
case 'string': result = generateForStringSchema(ctx, schema); break;
case 'number': result = generateForNumberSchema(ctx, schema); break;
Expand Down