Skip to content
Draft
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion packages/fets/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,9 @@ export type OASModel<
// Later suggest using json-machete
export type FixJSONSchema<T> = RemoveExclusiveMinimumAndMaximum<
FixAdditionalPropertiesForAllOf<
FixMissingAdditionalProperties<FixMissingTypeObject<FixExtraRequiredFields<T>>>
FixAnyOfOneOfRequiredFields<
FixMissingAdditionalProperties<FixMissingTypeObject<FixExtraRequiredFields<T>>>
>
>
>;

Expand All @@ -255,6 +257,20 @@ type FixAdditionalPropertiesForAllOf<T> = T extends { allOf: any[] }
}
: T;

// Strips 'required' from schemas that appear as items inside anyOf/oneOf.
// This prevents TypeScript error TS2615 ("Type of property X circularly references itself
// in mapped type") which occurs when json-schema-to-ts processes circular schemas with
// required fields through anyOf/oneOf. The outer schema's required array is preserved.
type FixAnyOfOneOfRequiredFields<T> = T extends { anyOf: any[] }
? Omit<T, 'anyOf'> & {
anyOf: Call<Tuples.Map<Objects.Omit<'required'>>, T['anyOf']>;
}
: T extends { oneOf: any[] }
? Omit<T, 'oneOf'> & {
oneOf: Call<Tuples.Map<Objects.Omit<'required'>>, T['oneOf']>;
}
: T;

// Detects if a value looks like a JSON Schema object (rather than a properties mapping).
// Used to avoid treating the "properties" mapping object itself as a JSON Schema when
// a property happens to be named "properties".
Expand Down
50 changes: 50 additions & 0 deletions packages/fets/tests/client/anyof-circular-ref-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createClient, OASModel, type NormalizeOAS } from 'fets';
import type anyOfCircularRefOAS from './fixtures/example-anyof-circular-ref-oas';

// This resolves anyOf circular reference correctly
type NormalizedOAS = NormalizeOAS<typeof anyOfCircularRefOAS>;

// OASModel should work for the schemas
type FilterGroupModel = OASModel<NormalizedOAS, 'FilterGroup'>;

Check failure on line 8 in packages/fets/tests/client/anyof-circular-ref-test.ts

View workflow job for this annotation

GitHub Actions / type-check

Type of property 'filters' circularly references itself in mapped type '{ [KEY in keyof { operator: { type: "string"; enum: ["AND", "OR"]; }; filters: { type: "array"; items: Omit<{ anyOf: [Omit<Omit<{ $id: "#/components/schemas/FilterGroup"; type: "object"; required: ["operator", "filters"]; properties: ...; }, "required"> & { ...; }, "additionalProperties"> & { ...; }, Omit<...> & { ....'.
type SoloFilterModel = OASModel<NormalizedOAS, 'SoloFilter'>;
type RequestBodyModel = OASModel<NormalizedOAS, 'RequestBody'>;

Check failure on line 10 in packages/fets/tests/client/anyof-circular-ref-test.ts

View workflow job for this annotation

GitHub Actions / type-check

Type of property 'filters' circularly references itself in mapped type '{ [KEY in keyof { operator: { type: "string"; enum: ["AND", "OR"]; }; filters: { type: "array"; items: Omit<{ anyOf: [Omit<Omit<{ $id: "#/components/schemas/FilterGroup"; type: "object"; required: ["operator", "filters"]; properties: ...; }, "required"> & { ...; }, "additionalProperties"> & { ...; }, Omit<...> & { ....'.

const filterGroup = {} as FilterGroupModel;

// Should be able to navigate the anyOf circular structure
const nestedOperator = filterGroup.filters?.[0];
type NestedFilterType = typeof nestedOperator;
let nestedFilterVar: NestedFilterType;
nestedFilterVar = { field: 'targetIp' };
nestedFilterVar = { operator: 'AND', filters: [] };
console.log(nestedFilterVar);

const soloFilter = {} as SoloFilterModel;
const fieldValue = soloFilter.field;
type FieldType = typeof fieldValue;
let fieldVar: FieldType;
fieldVar = 'some-field';
// @ts-expect-error - fieldVar is a string
fieldVar = 42;
console.log(fieldVar);

const requestBody = {} as RequestBodyModel;
const filters = requestBody.filters;
type FiltersType = typeof filters;
let filtersVar: FiltersType;
filtersVar = [{ field: 'targetIp' }];
filtersVar = [{ operator: 'AND', filters: [] }];
console.log(filtersVar);

const client = createClient<NormalizedOAS>({ endpoint: 'http://example.com' });

// This should work without TypeScript circular reference error
// even when passing a body variable instead of a literal
const simpleFilters = [{ field: 'targetIp', comparator: 'eq', value: '1.2.3.4' }];
const body = {
filters: simpleFilters,
};

// Both variable and literal forms should work
void client['/test'].post({ json: body });

Check failure on line 49 in packages/fets/tests/client/anyof-circular-ref-test.ts

View workflow job for this annotation

GitHub Actions / type-check

Type of property 'filters' circularly references itself in mapped type '{ [KEY in keyof { operator: { type: "string"; enum: ["AND", "OR"]; }; filters: { type: "array"; items: Omit<{ anyOf: [Omit<Omit<{ $id: "#/components/schemas/FilterGroup"; type: "object"; required: ["operator", "filters"]; properties: ...; }, "required"> & { ...; }, "additionalProperties"> & { ...; }, Omit<...> & { ....'.
void client['/test'].post({ json: { filters: simpleFilters } });
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export default {
openapi: '3.0.0',
info: {
version: '1',
title: 'AnyOf Circular Ref - OpenAPI 3.0',
description: 'This is a sample with anyOf circular reference (FilterGroup referencing itself)',
termsOfService: 'http://swagger.io/terms/',
},
paths: {
'/test': {
post: {
requestBody: {
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/RequestBody',
},
},
},
},
responses: {
'200': {
description: 'ok',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
},
},
},
},
},
},
},
},
},
components: {
schemas: {
RequestBody: {
type: 'object',
properties: {
filters: {
type: 'array',
items: {
anyOf: [
{ $ref: '#/components/schemas/FilterGroup' },
{ $ref: '#/components/schemas/SoloFilter' },
],
},
},
},
},
FilterGroup: {
type: 'object',
required: ['operator', 'filters'],
properties: {
operator: {
type: 'string',
enum: ['AND', 'OR'],
},
filters: {
type: 'array',
items: {
anyOf: [
{ $ref: '#/components/schemas/FilterGroup' },
{ $ref: '#/components/schemas/SoloFilter' },
],
},
},
},
},
SoloFilter: {
type: 'object',
required: ['field'],
properties: {
field: {
type: 'string',
},
},
},
},
},
} as const;
Loading