@apollo/federation-internals
Version:
Apollo Federation internal utilities
470 lines (450 loc) • 15.6 kB
text/typescript
import { DirectiveLocation } from 'graphql';
import {
CorePurpose,
FeatureDefinition,
FeatureDefinitions,
FeatureUrl,
FeatureVersion,
} from './coreSpec';
import {
CoreFeature,
InputObjectType,
isInputObjectType,
isNonNullType,
ListType,
NamedType,
NonNullType,
ScalarType,
Schema,
} from '../definitions';
import { registerKnownFeature } from '../knownCoreFeatures';
import {
createDirectiveSpecification,
createScalarTypeSpecification,
ensureSameTypeKind,
InputFieldSpecification,
TypeSpecification,
} from '../directiveAndTypeSpecification';
import { ERRORS } from '../error';
import { sameType } from '../types';
import { assert } from '../utils';
import { valueEquals, valueToString } from '../values';
export const connectIdentity = 'https://specs.apollo.dev/connect';
const CONNECT = 'connect';
const SOURCE = 'source';
const URL_PATH_TEMPLATE = 'URLPathTemplate';
const JSON_SELECTION = 'JSONSelection';
const CONNECT_HTTP = 'ConnectHTTP';
const CONNECT_BATCH = 'ConnectBatch';
const CONNECTOR_ERRORS = "ConnectorErrors";
const SOURCE_HTTP = "SourceHTTP";
const HTTP_HEADER_MAPPING = 'HTTPHeaderMapping';
export class ConnectSpecDefinition extends FeatureDefinition {
constructor(
version: FeatureVersion,
readonly minimumFederationVersion: FeatureVersion,
) {
super(
new FeatureUrl(connectIdentity, CONNECT, version),
minimumFederationVersion,
);
function lookupFeatureTypeInSchema<T extends NamedType>(name: string, kind: T['kind'], schema: Schema, feature?: CoreFeature): T {
assert(feature, `Shouldn't be added without being attached to a @connect spec`);
const typeName = feature.typeNameInSchema(name);
const type = schema.typeOfKind<T>(typeName, kind);
assert(type, () => `Expected "${typeName}" to be defined`);
return type;
}
/* scalar URLPathTemplate */
this.registerType(
createScalarTypeSpecification({ name: URL_PATH_TEMPLATE }),
);
/* scalar JSONSelection */
this.registerType(createScalarTypeSpecification({ name: JSON_SELECTION }));
/*
input ConnectorErrors {
message: JSONSelection
extensions: JSONSelection
}
*/
this.registerType(
createInputObjectTypeSpecification({
name: CONNECTOR_ERRORS,
inputFieldsFct: (schema, feature) => {
const jsonSelectionType =
lookupFeatureTypeInSchema<ScalarType>(JSON_SELECTION, 'ScalarType', schema, feature);
return [
{
name: 'message',
type: jsonSelectionType
},
{
name: 'extensions',
type: jsonSelectionType
},
]
}
})
);
/*
input HTTPHeaderMapping {
name: String!
from: String
value: String
}
*/
this.registerType(
createInputObjectTypeSpecification({
name: HTTP_HEADER_MAPPING,
inputFieldsFct: (schema) => [
{
name: 'name',
type: new NonNullType(schema.stringType())
},
{
name: 'from',
type: schema.stringType()
},
{
name: 'value',
type: schema.stringType()
},
]
})
);
/*
input ConnectBatch {
maxSize: Int
}
*/
this.registerType(
createInputObjectTypeSpecification({
name: CONNECT_BATCH,
inputFieldsFct: (schema) => [
{
name: 'maxSize',
type: schema.intType()
}
]
})
)
/*
input SourceHTTP {
baseURL: String!
headers: [HTTPHeaderMapping!]
# added in v0.2
path: JSONSelection
queryParams: JSONSelection
}
*/
this.registerType(
createInputObjectTypeSpecification({
name: SOURCE_HTTP,
inputFieldsFct: (schema, feature) => {
const jsonSelectionType =
lookupFeatureTypeInSchema<ScalarType>(JSON_SELECTION, 'ScalarType', schema, feature);
const httpHeaderMappingType =
lookupFeatureTypeInSchema<InputObjectType>(HTTP_HEADER_MAPPING, 'InputObjectType', schema, feature);
return [
{
name: 'baseURL',
type: new NonNullType(schema.stringType())
},
{
name: 'headers',
type: new ListType(new NonNullType(httpHeaderMappingType))
},
{
name: 'path',
type: jsonSelectionType
},
{
name: 'queryParams',
type: jsonSelectionType
}
];
}
})
);
/*
input ConnectHTTP {
GET: URLPathTemplate
POST: URLPathTemplate
PUT: URLPathTemplate
PATCH: URLPathTemplate
DELETE: URLPathTemplate
body: JSONSelection
headers: [HTTPHeaderMapping!]
# added in v0.2
path: JSONSelection
queryParams: JSONSelection
}
*/
this.registerType(
createInputObjectTypeSpecification({
name: CONNECT_HTTP,
inputFieldsFct: (schema, feature) => {
const urlPathTemplateType =
lookupFeatureTypeInSchema<ScalarType>(URL_PATH_TEMPLATE, 'ScalarType', schema, feature);
const jsonSelectionType =
lookupFeatureTypeInSchema<ScalarType>(JSON_SELECTION, 'ScalarType', schema, feature);
const httpHeaderMappingType =
lookupFeatureTypeInSchema<InputObjectType>(HTTP_HEADER_MAPPING, 'InputObjectType', schema, feature);
return [
{
name: 'GET',
type: urlPathTemplateType
},
{
name: 'POST',
type: urlPathTemplateType
},
{
name: 'PUT',
type: urlPathTemplateType
},
{
name: 'PATCH',
type: urlPathTemplateType
},
{
name: 'DELETE',
type: urlPathTemplateType
},
{
name: 'body',
type: jsonSelectionType
},
{
name: 'headers',
type: new ListType(new NonNullType(httpHeaderMappingType))
},
{
name: 'path',
type: jsonSelectionType
},
{
name: 'queryParams',
type: jsonSelectionType
},
];
}
})
);
/*
directive @connect(
source: String
http: ConnectHTTP!
batch: ConnectBatch
errors: ConnectorErrors
selection: JSONSelection!
entity: Boolean = false
) repeatable on FIELD_DEFINITION
| OBJECT # added in v0.2, validation enforced in rust
*/
this.registerDirective(
createDirectiveSpecification({
name: CONNECT,
locations: [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT],
repeatable: true,
args: [
{
name: 'source',
type: (schema) => schema.stringType()
},
{
name: 'http',
type: (schema, feature) => {
const connectHttpType =
lookupFeatureTypeInSchema<InputObjectType>(CONNECT_HTTP, 'InputObjectType', schema, feature);
return new NonNullType(connectHttpType);
}
},
{
name: 'batch',
type: (schema, feature) =>
lookupFeatureTypeInSchema<InputObjectType>(CONNECT_BATCH, 'InputObjectType', schema, feature)
},
{
name: 'errors',
type: (schema, feature) =>
lookupFeatureTypeInSchema<InputObjectType>(CONNECTOR_ERRORS, 'InputObjectType', schema, feature)
},
{
name: 'selection',
type: (schema, feature) => {
const jsonSelectionType =
lookupFeatureTypeInSchema<ScalarType>(JSON_SELECTION, 'ScalarType', schema, feature);
return new NonNullType(jsonSelectionType);
}
},
{
name: 'entity',
type: (schema) => schema.booleanType(),
defaultValue: false
}
],
// We "compose" these directives using the `@join__directive` mechanism,
// so they do not need to be composed in the way passing `composes: true`
// here implies.
composes: false,
}),
);
/*
directive @source(
name: String!
http: SourceHTTP!
errors: ConnectorErrors
) repeatable on SCHEMA
*/
this.registerDirective(
createDirectiveSpecification({
name: SOURCE,
locations: [DirectiveLocation.SCHEMA],
repeatable: true,
composes: false,
args: [
{
name: 'name',
type: (schema) => new NonNullType(schema.stringType())
},
{
name: 'http',
type: (schema, feature) => {
const sourceHttpType =
lookupFeatureTypeInSchema<InputObjectType>(SOURCE_HTTP, 'InputObjectType', schema, feature);
return new NonNullType(sourceHttpType);
}
},
{
name: 'errors',
type: (schema, feature) =>
lookupFeatureTypeInSchema<InputObjectType>(CONNECTOR_ERRORS, 'InputObjectType', schema, feature)
}
]
}),
);
}
get defaultCorePurpose(): CorePurpose {
return 'EXECUTION';
}
}
export const CONNECT_VERSIONS = new FeatureDefinitions<ConnectSpecDefinition>(
connectIdentity,
)
.add(
new ConnectSpecDefinition(
new FeatureVersion(0, 1),
new FeatureVersion(2, 10),
),
)
.add(
new ConnectSpecDefinition(
new FeatureVersion(0, 2),
new FeatureVersion(2, 10),
),
);
registerKnownFeature(CONNECT_VERSIONS);
// This function is purposefully declared only in this file and without export.
//
// Do NOT add this to "internals-js/src/directiveAndTypeSpecification.ts", and
// do NOT export this function.
//
// Subgraph schema building, at this time of writing, does not really support
// input objects in specs. We did a number of one-off things to support them in
// the connect spec's case, and it will be non-maintainable/bug-prone to do them
// again.
//
// There's work to be done to support input objects more generally; please see
// https://github.com/apollographql/federation/pull/3311 for more information.
function createInputObjectTypeSpecification({
name,
inputFieldsFct,
}: {
name: string,
inputFieldsFct: (schema: Schema, feature?: CoreFeature) => InputFieldSpecification[],
}): TypeSpecification {
return {
name,
checkOrAdd: (schema: Schema, feature?: CoreFeature, asBuiltIn?: boolean) => {
const actualName = feature?.typeNameInSchema(name) ?? name;
const expectedFields = inputFieldsFct(schema, feature);
const existing = schema.type(actualName);
if (existing) {
let errors = ensureSameTypeKind('InputObjectType', existing);
if (errors.length > 0) {
return errors;
}
assert(isInputObjectType(existing), 'Should be an input object type');
// The following mimics `ensureSameArguments()`, but with some changes.
for (const { name: fieldName, type, defaultValue } of expectedFields) {
const existingField = existing.field(fieldName);
if (!existingField) {
// Not declaring an optional input field is ok: that means you won't
// be able to pass a non-default value in your schema, but we allow
// you that. But missing a required input field it not ok.
if (isNonNullType(type) && defaultValue === undefined) {
errors.push(ERRORS.TYPE_DEFINITION_INVALID.err(
`Invalid definition for type ${name}: missing required input field "${fieldName}"`,
{ nodes: existing.sourceAST },
));
}
continue;
}
let existingType = existingField.type!;
if (isNonNullType(existingType) && !isNonNullType(type)) {
// It's ok to redefine an optional input field as mandatory. For
// instance, if you want to force people on your team to provide a
// "maxSize", you can redefine ConnectBatch as
// `input ConnectBatch { maxSize: Int! }` to get validation. In
// other words, you are allowed to always pass an input field that
// is optional if you so wish.
existingType = existingType.ofType;
}
// Note that while `ensureSameArguments()` allows input type
// redefinitions (e.g. allowing users to declare `String` instead of a
// custom scalar), this behavior can be confusing/error-prone more
// generally, so we forbid this for now. We can relax this later on a
// case-by-case basis if needed.
//
// Further, `ensureSameArguments()` would skip default value checking
// if the input type was non-nullable. It's unclear why this is there;
// it may have been a mistake due to the impression that non-nullable
// inputs can't have default values (they can), or this may have been
// to avoid some breaking change, but there's no such limitation in
// the case of input objects, so we always validate default values
// here.
if (!sameType(type, existingType)) {
errors.push(ERRORS.TYPE_DEFINITION_INVALID.err(
`Invalid definition for type ${name}: input field "${fieldName}" should have type "${type}" but found type "${existingField.type!}"`,
{ nodes: existingField.sourceAST },
));
} else if (!valueEquals(defaultValue, existingField.defaultValue)) {
errors.push(ERRORS.TYPE_DEFINITION_INVALID.err(
`Invalid definition type ${name}: input field "${fieldName}" should have default value ${valueToString(defaultValue)} but found default value ${valueToString(existingField.defaultValue)}`,
{ nodes: existingField.sourceAST },
));
}
}
for (const existingField of existing.fields()) {
// If it's an expected input field, we already validated it. But we
// still need to reject unknown input fields.
if (!expectedFields.some((field) => field.name === existingField.name)) {
errors.push(ERRORS.TYPE_DEFINITION_INVALID.err(
`Invalid definition for type ${name}: unknown/unsupported input field "${existingField.name}"`,
{ nodes: existingField.sourceAST },
));
}
}
return errors;
} else {
const createdType = schema.addType(new InputObjectType(actualName, asBuiltIn));
for (const { name, type, defaultValue } of expectedFields) {
const newField = createdType.addField(name, type);
newField.defaultValue = defaultValue;
}
return [];
}
},
}
}