Skip to content

enhance(json-schema): handle discriminator mapping #5206

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

Merged
merged 3 commits into from
Apr 3, 2023
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
32 changes: 22 additions & 10 deletions packages/loaders/json-schema/src/directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,20 @@ export const DiscriminatorDirective = new GraphQLDirective({
field: {
type: GraphQLString,
},
mapping: {
type: ObjMapScalar,
},
},
});

export function processDiscriminatorAnnotations(
interfaceType: GraphQLInterfaceType,
fieldName: string,
) {
interfaceType.resolveType = root => root[fieldName];
export function processDiscriminatorAnnotations({
interfaceType,
discriminatorFieldName,
}: {
interfaceType: GraphQLInterfaceType;
discriminatorFieldName: string;
}) {
interfaceType.resolveType = root => root[discriminatorFieldName];
}

export const ResolveRootDirective = new GraphQLDirective({
Expand Down Expand Up @@ -557,7 +563,10 @@ export function processDirectives({
for (const directiveAnnotation of directiveAnnotations) {
switch (directiveAnnotation.name) {
case 'discriminator':
processDiscriminatorAnnotations(type, directiveAnnotation.args.field);
processDiscriminatorAnnotations({
interfaceType: type,
discriminatorFieldName: directiveAnnotation.args.field,
});
break;
}
}
Expand All @@ -566,6 +575,7 @@ export function processDirectives({
const directiveAnnotations = getDirectives(schema, type);
let statusCodeTypeNameIndexMap: Record<number, string>;
let discriminatorField: string;
let discriminatorMapping: Record<string, string>;
for (const directiveAnnotation of directiveAnnotations) {
switch (directiveAnnotation.name) {
case 'statusCodeTypeName':
Expand All @@ -575,14 +585,16 @@ export function processDirectives({
break;
case 'discriminator':
discriminatorField = directiveAnnotation.args.field;
discriminatorMapping = directiveAnnotation.args.mapping;
break;
}
}
type.resolveType = getTypeResolverFromOutputTCs(
type.getTypes(),
type.resolveType = getTypeResolverFromOutputTCs({
possibleTypes: type.getTypes(),
discriminatorField,
statusCodeTypeNameIndexMap,
);
discriminatorMapping,
statusCodeTypeNameMap: statusCodeTypeNameIndexMap,
});
}
if (isEnumType(type)) {
const directiveAnnotations = getDirectives(schema, type);
Expand Down
7 changes: 7 additions & 0 deletions packages/loaders/json-schema/src/getComposerFromJSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,10 +737,17 @@ export function getComposerFromJSONSchema(
}
if (subSchema.discriminator?.propertyName) {
schemaComposer.addDirective(DiscriminatorDirective);
const mappingByName: Record<string, string> = {};
for (const discriminatorValue in subSchema.discriminator.mapping) {
const ref = subSchema.discriminator.mapping[discriminatorValue];
const typeName = ref.replace('#/components/schemas/', '');
mappingByName[discriminatorValue] = typeName;
}
directives.push({
name: 'discriminator',
args: {
field: subSchema.discriminator.propertyName,
mapping: mappingByName,
},
});
}
Expand Down
20 changes: 14 additions & 6 deletions packages/loaders/json-schema/src/getTypeResolverFromOutputTCs.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { GraphQLObjectType, GraphQLTypeResolver } from 'graphql';
import { createGraphQLError } from '@graphql-tools/utils';

export function getTypeResolverFromOutputTCs(
possibleTypes: readonly GraphQLObjectType[],
discriminatorField?: string,
statusCodeTypeNameMap?: Record<string, string>,
): GraphQLTypeResolver<any, any> {
export function getTypeResolverFromOutputTCs({
possibleTypes,
discriminatorField,
discriminatorMapping,
statusCodeTypeNameMap,
}: {
possibleTypes: readonly GraphQLObjectType[];
discriminatorField?: string;
discriminatorMapping?: Record<string, string>;
statusCodeTypeNameMap?: Record<string, string>;
}): GraphQLTypeResolver<any, any> {
return function resolveType(data: any) {
if (data.__typename) {
return data.__typename;
} else if (discriminatorField != null && data[discriminatorField]) {
return data[discriminatorField];
const discriminatorValue = data[discriminatorField];
return discriminatorMapping?.[discriminatorValue] || discriminatorValue;
}
if (data.$statusCode && statusCodeTypeNameMap) {
const typeName =
Expand All @@ -19,6 +26,7 @@ export function getTypeResolverFromOutputTCs(
return typeName;
}
}

// const validationErrors: Record<string, ErrorObject[]> = {};
const dataKeys =
typeof data === 'object'
Expand Down
1 change: 1 addition & 0 deletions packages/loaders/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
},
"devDependencies": {
"@graphql-tools/utils": "9.2.1",
"@whatwg-node/fetch": "0.8.4",
"@whatwg-node/router": "0.3.0",
"graphql-yoga": "3.8.0",
"json-bigint-patch": "0.0.8"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Discriminator Mapping should generate correct schema: discriminator-mapping 1`] = `
"schema {
query: Query
}

directive @oneOf on OBJECT | INTERFACE

directive @discriminator(field: String, mapping: ObjMap) on INTERFACE | UNION

directive @globalOptions(sourceName: String, endpoint: String, operationHeaders: ObjMap, queryStringOptions: ObjMap, queryParams: ObjMap) on OBJECT

directive @httpOperation(path: String, operationSpecificHeaders: ObjMap, httpMethod: HTTPMethod, isBinary: Boolean, requestBaseBody: ObjMap, queryParamArgMap: ObjMap, queryStringOptionsByParam: ObjMap) on FIELD_DEFINITION

type Query @globalOptions(sourceName: "test") {
pets_by_id(id: String!): Pet @httpOperation(path: "/pets/{args.id}", operationSpecificHeaders: "{\\"accept\\":\\"application/json\\"}", httpMethod: GET)
}

union Pet @discriminator(field: "petType", mapping: "{\\"Dog\\":\\"DogDifferent\\",\\"Cat\\":\\"Cat\\"}") = Cat | DogDifferent

type Cat {
petType: String
cat_exclusive: String
}

type DogDifferent {
petType: String
dog_exclusive: String
}

scalar ObjMap

enum HTTPMethod {
GET
HEAD
POST
PUT
DELETE
CONNECT
OPTIONS
TRACE
PATCH
}"
`;
Original file line number Diff line number Diff line change
Expand Up @@ -49649,7 +49649,7 @@ directive @example(value: ObjMap) repeatable on FIELD_DEFINITION | OBJECT | INPU

directive @oneOf on OBJECT | INTERFACE

directive @discriminator(field: String) on INTERFACE | UNION
directive @discriminator(field: String, mapping: ObjMap) on INTERFACE | UNION

directive @globalOptions(sourceName: String, endpoint: String, operationHeaders: ObjMap, queryStringOptions: ObjMap, queryParams: ObjMap) on OBJECT

Expand Down Expand Up @@ -49692,7 +49692,7 @@ type TicketMessageGet {
Author: PersonGet @link(defaultRootType: "Mutation", defaultField: "TicketMessagesUpdateTicketMessage")
}

union PersonGet @discriminator(field: "_resolveType") = CompanyGet | UserGet
union PersonGet @discriminator(field: "_resolveType", mapping: "{\\"user\\":\\"UserGet\\",\\"company\\":\\"CompanyGet\\"}") = CompanyGet | UserGet

type CompanyGet {
_resolveType: company_const!
Expand Down Expand Up @@ -50340,7 +50340,7 @@ directive @resolveRoot on FIELD_DEFINITION

directive @example(value: ObjMap) repeatable on FIELD_DEFINITION | OBJECT | INPUT_OBJECT | ENUM | SCALAR

directive @discriminator(field: String) on INTERFACE | UNION
directive @discriminator(field: String, mapping: ObjMap) on INTERFACE | UNION

directive @dictionary on FIELD_DEFINITION

Expand Down Expand Up @@ -53360,7 +53360,7 @@ type PageBeanCustomFieldContextDefaultValue {
values: [CustomFieldContextDefaultValue]
}

union CustomFieldContextDefaultValue @discriminator(field: "type") = CustomFieldContextDefaultValueCascadingOption | CustomFieldContextDefaultValueMultipleOption | CustomFieldContextDefaultValueSingleOption | CustomFieldContextSingleUserPickerDefaults | CustomFieldContextDefaultValueMultiUserPicker | CustomFieldContextDefaultValueSingleGroupPicker | CustomFieldContextDefaultValueMultipleGroupPicker | CustomFieldContextDefaultValueDate | CustomFieldContextDefaultValueDateTime | CustomFieldContextDefaultValueURL | CustomFieldContextDefaultValueProject | CustomFieldContextDefaultValueFloat | CustomFieldContextDefaultValueLabels | CustomFieldContextDefaultValueTextField | CustomFieldContextDefaultValueTextArea | CustomFieldContextDefaultValueReadOnly | CustomFieldContextDefaultValueSingleVersionPicker | CustomFieldContextDefaultValueMultipleVersionPicker | CustomFieldContextDefaultValueForgeStringField | CustomFieldContextDefaultValueForgeMultiStringField | CustomFieldContextDefaultValueForgeObjectField | CustomFieldContextDefaultValueForgeDateTimeField | CustomFieldContextDefaultValueForgeGroupField | CustomFieldContextDefaultValueForgeMultiGroupField | CustomFieldContextDefaultValueForgeNumberField | CustomFieldContextDefaultValueForgeUserField | CustomFieldContextDefaultValueForgeMultiUserField
union CustomFieldContextDefaultValue @discriminator(field: "type", mapping: "{\\"option.cascading\\":\\"CustomFieldContextDefaultValueCascadingOption\\",\\"option.multiple\\":\\"CustomFieldContextDefaultValueMultipleOption\\",\\"option.single\\":\\"CustomFieldContextDefaultValueSingleOption\\",\\"single.user.select\\":\\"CustomFieldContextSingleUserPickerDefaults\\",\\"multi.user.select\\":\\"CustomFieldContextDefaultValueMultiUserPicker\\",\\"grouppicker.single\\":\\"CustomFieldContextDefaultValueSingleGroupPicker\\",\\"grouppicker.multiple\\":\\"CustomFieldContextDefaultValueMultipleGroupPicker\\",\\"datepicker\\":\\"CustomFieldContextDefaultValueDate\\",\\"datetimepicker\\":\\"CustomFieldContextDefaultValueDateTime\\",\\"url\\":\\"CustomFieldContextDefaultValueURL\\",\\"project\\":\\"CustomFieldContextDefaultValueProject\\",\\"float\\":\\"CustomFieldContextDefaultValueFloat\\",\\"labels\\":\\"CustomFieldContextDefaultValueLabels\\",\\"textfield\\":\\"CustomFieldContextDefaultValueTextField\\",\\"textarea\\":\\"CustomFieldContextDefaultValueTextArea\\",\\"readonly\\":\\"CustomFieldContextDefaultValueReadOnly\\",\\"version.single\\":\\"CustomFieldContextDefaultValueSingleVersionPicker\\",\\"version.multiple\\":\\"CustomFieldContextDefaultValueMultipleVersionPicker\\",\\"forge.string\\":\\"CustomFieldContextDefaultValueForgeStringField\\",\\"forge.string.list\\":\\"CustomFieldContextDefaultValueForgeMultiStringField\\",\\"forge.object\\":\\"CustomFieldContextDefaultValueForgeObjectField\\",\\"forge.datetime\\":\\"CustomFieldContextDefaultValueForgeDateTimeField\\",\\"forge.group\\":\\"CustomFieldContextDefaultValueForgeGroupField\\",\\"forge.group.list\\":\\"CustomFieldContextDefaultValueForgeMultiGroupField\\",\\"forge.number\\":\\"CustomFieldContextDefaultValueForgeNumberField\\",\\"forge.user\\":\\"CustomFieldContextDefaultValueForgeUserField\\",\\"forge.user.list\\":\\"CustomFieldContextDefaultValueForgeMultiUserField\\"}") = CustomFieldContextDefaultValueCascadingOption | CustomFieldContextDefaultValueMultipleOption | CustomFieldContextDefaultValueSingleOption | CustomFieldContextSingleUserPickerDefaults | CustomFieldContextDefaultValueMultiUserPicker | CustomFieldContextDefaultValueSingleGroupPicker | CustomFieldContextDefaultValueMultipleGroupPicker | CustomFieldContextDefaultValueDate | CustomFieldContextDefaultValueDateTime | CustomFieldContextDefaultValueURL | CustomFieldContextDefaultValueProject | CustomFieldContextDefaultValueFloat | CustomFieldContextDefaultValueLabels | CustomFieldContextDefaultValueTextField | CustomFieldContextDefaultValueTextArea | CustomFieldContextDefaultValueReadOnly | CustomFieldContextDefaultValueSingleVersionPicker | CustomFieldContextDefaultValueMultipleVersionPicker | CustomFieldContextDefaultValueForgeStringField | CustomFieldContextDefaultValueForgeMultiStringField | CustomFieldContextDefaultValueForgeObjectField | CustomFieldContextDefaultValueForgeDateTimeField | CustomFieldContextDefaultValueForgeGroupField | CustomFieldContextDefaultValueForgeMultiGroupField | CustomFieldContextDefaultValueForgeNumberField | CustomFieldContextDefaultValueForgeUserField | CustomFieldContextDefaultValueForgeMultiUserField

"The default value for a cascading select custom field."
type CustomFieldContextDefaultValueCascadingOption {
Expand Down Expand Up @@ -56817,7 +56817,7 @@ type WorkflowRules {
}

"The workflow transition rule conditions tree."
union WorkflowCondition @discriminator(field: "nodeType") = WorkflowSimpleCondition | WorkflowCompoundCondition
union WorkflowCondition @discriminator(field: "nodeType", mapping: "{\\"simple\\":\\"WorkflowSimpleCondition\\",\\"compound\\":\\"WorkflowCompoundCondition\\"}") = WorkflowSimpleCondition | WorkflowCompoundCondition

"A workflow transition rule condition. This object returns \`nodeType\` as \`simple\`."
type WorkflowSimpleCondition {
Expand Down Expand Up @@ -90529,7 +90529,7 @@ exports[`Schemas Pet should generate the correct schema: Pet 1`] = `
query: Query
}

directive @discriminator(field: String) on INTERFACE | UNION
directive @discriminator(field: String, mapping: ObjMap) on INTERFACE | UNION

directive @globalOptions(sourceName: String, endpoint: String, operationHeaders: ObjMap, queryStringOptions: ObjMap, queryParams: ObjMap) on OBJECT

Expand Down
70 changes: 70 additions & 0 deletions packages/loaders/openapi/tests/discriminator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { execute, GraphQLSchema, parse } from 'graphql';
import { printSchemaWithDirectives } from '@graphql-tools/utils';
import { Response } from '@whatwg-node/fetch';
import { loadGraphQLSchemaFromOpenAPI } from '../src/loadGraphQLSchemaFromOpenAPI.js';

describe('Discriminator Mapping', () => {
let createdSchema: GraphQLSchema;
beforeAll(async () => {
createdSchema = await loadGraphQLSchemaFromOpenAPI('test', {
source: './fixtures/discriminator-mapping.yml',
cwd: __dirname,
ignoreErrorResponses: true,
async fetch(url) {
if (url === 'pets/1') {
return Response.json({
petType: 'Dog',
dog_exclusive: 'DOG_EXCLUSIVE',
});
}
if (url === 'pets/2') {
return Response.json({
petType: 'Cat',
cat_exclusive: 'CAT_EXCLUSIVE',
});
}
return new Response(null, {
status: 404,
});
},
// It is not possible to provide a union type with File scalar
});
});
it('should generate correct schema', () => {
expect(printSchemaWithDirectives(createdSchema)).toMatchSnapshot('discriminator-mapping');
});
it('should handle discriminator mapping', async () => {
const query = /* GraphQL */ `
query {
dog: pets_by_id(id: "1") {
__typename
... on DogDifferent {
petType
}
}
cat: pets_by_id(id: "2") {
__typename
... on Cat {
petType
}
}
}
`;
const result = await execute({
schema: createdSchema,
document: parse(query),
});
expect(result).toEqual({
data: {
dog: {
__typename: 'DogDifferent',
petType: 'Dog',
},
cat: {
__typename: 'Cat',
petType: 'Cat',
},
},
});
});
});
47 changes: 47 additions & 0 deletions packages/loaders/openapi/tests/fixtures/discriminator-mapping.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
openapi: 3.0.0
info:
version: 1.0.0
title: Swagger Petstore
license:
name: MIT
paths:
/pets/{id}:
get:
parameters:
- name: id
required: true
in: path
schema:
type: string
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'

components:
schemas:
Pet:
oneOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/DogDifferent'
discriminator:
propertyName: petType
mapping:
Dog: '#/components/schemas/DogDifferent'
Cat: '#/components/schemas/Cat'
Cat:
type: object
properties:
petType:
type: string
cat_exclusive:
type: string
DogDifferent:
type: object
properties:
petType:
type: string
dog_exclusive:
type: string