compiler/src/model/build-model.ts (524 lines of code) (raw):

/* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { mkdirSync, writeFileSync } from 'fs' import { join } from 'path' import { STATUS_CODES } from 'http' import { ClassDeclaration, EnumDeclaration, InterfaceDeclaration, Node, Project, PropertyDeclaration, PropertySignature, ts, TypeAliasDeclaration } from 'ts-morph' import * as model from './metamodel' import buildJsonSpec from './json-spec' import { assert, customTypes, getAllBehaviors, getNameSpace, hoistRequestAnnotations, hoistTypeAnnotations, isKnownBehavior, modelBehaviors, modelEnumDeclaration, modelGenerics, modelInherits, modelProperty, modelType, modelTypeAlias, parseVariantNameTag, parseVariantsTag, verifyUniqueness, parseJsDocTags, deepEqual, sourceLocation, sortTypeDefinitions, parseDeprecation } from './utils' const jsonSpec = buildJsonSpec() export function compileEndpoints (): Record<string, model.Endpoint> { // Create endpoints and merge them with // the recorded mappings if present. const map = {} for (const [api, spec] of jsonSpec.entries()) { map[api] = { name: api, description: spec.documentation.description, docUrl: spec.documentation.url, docTag: spec.docTag, extDocUrl: spec.externalDocs?.url, // Setting these values by default should be removed // when we no longer use rest-api-spec stubs as the // source of truth for stability/visibility. availability: { stack: { stability: spec.stability, visibility: spec.visibility } }, request: null, requestBodyRequired: Boolean(spec.body?.required), response: null, urls: spec.url.paths.map(path => { return { path: path.path, methods: path.methods, ...(path.deprecated != null && { deprecation: path.deprecated }) } }) } if (typeof spec.feature_flag === 'string') { map[api].availability.stack.featureFlag = spec.feature_flag } } return map } export function compileSpecification (endpointMappings: Record<string, model.Endpoint>, specsFolder: string, outputFolder: string): model.Model { const tsConfigFilePath = join(specsFolder, 'tsconfig.json') const project = new Project({ tsConfigFilePath }) verifyUniqueness(project) const model: model.Model = { types: new Array<model.TypeDefinition>(), endpoints: new Array<model.Endpoint>() } // Read and compile source files const declarations = { classes: new Array<ClassDeclaration>(), interfaces: new Array<InterfaceDeclaration>(), enums: new Array<EnumDeclaration>(), typeAliases: new Array<TypeAliasDeclaration>() } const definedButNeverUsed: string[] = [] for (const sourceFile of project.getSourceFiles()) { for (const declaration of sourceFile.getClasses()) { if (customTypes.includes(declaration.getName() ?? '')) continue declarations.classes.push(declaration) } for (const declaration of sourceFile.getInterfaces()) { declarations.interfaces.push(declaration) } for (const declaration of sourceFile.getEnums()) { declarations.enums.push(declaration) } for (const declaration of sourceFile.getTypeAliases()) { declarations.typeAliases.push(declaration) } } mkdirSync(join(outputFolder, 'dangling-types'), { recursive: true }) writeFileSync( join(outputFolder, 'dangling-types', 'dangling.csv'), definedButNeverUsed.join('\n'), { encoding: 'utf8', flag: 'w' } ) for (const api of jsonSpec.keys()) { model.endpoints.push(endpointMappings[api]) } // Visit all class, interface, enum and type alias definitions for (const declaration of declarations.classes) { model.types.push(compileClassOrInterfaceDeclaration(declaration, endpointMappings, declarations.classes)) } for (const declaration of declarations.interfaces) { model.types.push(compileClassOrInterfaceDeclaration(declaration, endpointMappings, declarations.classes)) } for (const declaration of declarations.enums) { model.types.push(modelEnumDeclaration(declaration)) } for (const declaration of declarations.typeAliases) { model.types.push(modelTypeAlias(declaration)) } // Sort the types in alphabetical order sortTypeDefinitions(model.types) return model } function compileClassOrInterfaceDeclaration (declaration: ClassDeclaration | InterfaceDeclaration, mappings: Record<string, model.Endpoint>, allClasses: ClassDeclaration[]): model.Request | model.Response | model.Interface { const name = declaration.getName() assert(declaration, name != null, 'Anonymous definitions should not exists') if (name === 'Request') { assert( declaration, Node.isInterfaceDeclaration(declaration), `Request definitions must be declared as interfaces: ${name}` ) } if (name === 'Response') { assert( declaration, Node.isClassDeclaration(declaration), `Response definitions must be declared as classes: ${name}` ) } // Request and Response definitions needs to be handled // differently from normal classes if (name === 'Request' || name === 'Response') { let type: model.Request | model.Response const namespace = getNameSpace(declaration) if (name === 'Request') { type = { specLocation: sourceLocation(declaration), kind: 'request', name: { name, namespace }, path: new Array<model.Property>(), query: new Array<model.Property>(), body: { kind: 'no_body' } } const response: model.TypeName = { name: 'Response', namespace: getNameSpace(declaration) } hoistRequestAnnotations(type, declaration.getJsDocs(), mappings, response) const mapping = mappings[namespace.includes('_global') ? namespace.slice(8) : namespace] if (mapping == null) { throw new Error(`Cannot find url template for ${namespace}, very likely the specification folder does not follow the rest-api-spec`) } let pathMember: Node | null = null let bodyProperties: model.Property[] = [] let bodyValue: model.ValueOf | null = null let bodyMember: Node | null = null // collect path/query/body properties for (const member of declaration.getMembers()) { // we are visiting `urls`, `path_parts, `query_parameters` or `body` assert( member, Node.isPropertyDeclaration(member) || Node.isPropertySignature(member), 'Class and interfaces can only have property declarations or signatures' ) const name = member.getName() if (name === 'urls') { // Overwrite the endpoint urls read from the json-rest-spec // TODO: once all spec files are using it, make it mandatory. mapping.urls = visitUrls(member) } else if (name === 'path_parts') { const property = visitRequestOrResponseProperty(member) assert(member, property.properties.length > 0, 'There is no need to declare an empty object path_parts, just remove the path_parts declaration.') pathMember = member type.path = property.properties } else if (name === 'query_parameters') { const property = visitRequestOrResponseProperty(member) assert(member, property.properties.length > 0, 'There is no need to declare an empty object query_parameters, just remove the query_parameters declaration.') type.query = property.properties } else if (name === 'body') { const property = visitRequestOrResponseProperty(member) bodyMember = member if (property.valueOf != null) { bodyValue = property.valueOf } else { assert(member, property.properties.length > 0, 'There is no need to declare an empty object body, just remove the body declaration.') bodyProperties = property.properties } } else { assert(member, false, `Unknown request property: ${name}`) } } // validate path properties // list of unique dynamic parameters const urlTemplateParams = [...new Set( mapping.urls.flatMap(url => url.path.split('/') .filter(part => part.includes('{')) .map(part => part.slice(1, -1)) ) )] const methods = [...new Set(mapping.urls.flatMap(url => url.methods))] for (const part of type.path) { assert( pathMember as Node, urlTemplateParams.includes(part.name), `The property '${part.name}' does not exist in the rest-api-spec ${namespace} url template` ) if (type.query.map(p => p.name).includes(part.name)) { const queryType = type.query.find(property => property != null && property.name === part.name) as model.Property if (!deepEqual(queryType.type, part.type)) { assert(pathMember as Node, part.codegenName != null, `'${part.name}' already exist in the query_parameters with a different type, you should define an @codegen_name.`) assert(pathMember as Node, !type.query.map(p => p.codegenName ?? p.name).includes(part.codegenName), `The codegen_name '${part.codegenName}' already exists as parameter in query_parameters.`) } } if (bodyProperties.map(p => p.name).includes(part.name)) { const bodyType = bodyProperties.find(property => property != null && property.name === part.name) as model.Property if (!deepEqual(bodyType.type, part.type)) { assert(pathMember as Node, part.codegenName != null, `'${part.name}' already exist in the body with a different type, you should define an @codegen_name.`) assert(pathMember as Node, !bodyProperties.map(p => p.codegenName ?? p.name).includes(part.codegenName), `The codegen_name '${part.codegenName}' already exists as parameter in body.`) } } } // validate body if (bodyMember != null) { assert( bodyMember, methods.some(method => ['POST', 'PUT', 'DELETE'].includes(method)), `${namespace}.${name} can't have a body, allowed methods: ${methods.join(', ')}` ) } // the body can either be a value (eg Array<string> or an object with properties) if (bodyValue != null) { // Propagate required body value nature based on TS question token being present. // Overrides the value set by spec files. mapping.requestBodyRequired = !(bodyMember as PropertySignature).hasQuestionToken() if (bodyValue.kind === 'instance_of' && bodyValue.type.name === 'Void') { assert(bodyMember as Node, false, 'There is no need to use Void in requets definitions, just remove the body declaration.') } else { const tags = parseJsDocTags((bodyMember as PropertySignature).getJsDocs()) assert( bodyMember as Node, tags.codegen_name != null, 'You should configure a body @codegen_name' ) assert( (bodyMember as PropertySignature).getJsDocs(), !type.path.map(p => p.codegenName ?? p.name).concat(type.query.map(p => p.codegenName ?? p.name)).includes(tags.codegen_name), `The codegen_name '${tags.codegen_name}' already exists as a property in the path or query.` ) type.body = { kind: 'value', value: bodyValue, codegenName: tags.codegen_name } } } else if (bodyProperties.length > 0) { type.body = { kind: 'properties', properties: bodyProperties } } // The interface is extended, an extended interface could accept generics as well, // Implements will be caught here as well, they can be differentiated by looking as `node.token` // which can either be `ts.SyntaxKind.ExtendsKeyword` or `ts.SyntaxKind.ImplementsKeyword` // In case of `ts.SyntaxKind.ImplementsKeyword`, we need to check // if it's a normal implements or a behavior, in such case, the behaviors // need to be collected and added to the type. if (declaration.getHeritageClauses().length > 0) { // check if the current node or one of the ancestor // has one or more behaviors attached const attachedBehaviors = getAllBehaviors(declaration) if (attachedBehaviors.length > 0) { type.attachedBehaviors = Array.from( new Set((type.attachedBehaviors ?? []).concat(attachedBehaviors)) ) } } for (const inherit of declaration.getHeritageClauses()) { const extended = inherit.getTypeNodes() .map(t => t.getExpression()) .map(t => t.getType().getSymbol()?.getDeclarations()[0])[0] assert(inherit, Node.isClassDeclaration(extended) || Node.isInterfaceDeclaration(extended), 'Should extend from a class or interface') type.inherits = modelInherits(extended, inherit) } // If the body wasn't set and we have a parent class, then it's a property body with no additional properties if (type.body.kind === 'no_body' && type.inherits != null) { const parent = type.inherits.type // RequestBase is special as it's a "marker" base class that doesn't imply a property body type. We should get rid of it. if (parent.name === 'RequestBase' && parent.namespace === '_types') { // nothing to do // CatRequestBase is special as it's a "marker" base class that doesn't imply a property body type. We should get rid of it. } else if (parent.name === 'CatRequestBase' && parent.namespace === 'cat._types') { // nothing to do } else { type.body = { kind: 'properties', properties: new Array<model.Property>() } } } } else { type = { specLocation: sourceLocation(declaration), kind: 'response', name: { name, namespace: getNameSpace(declaration) }, body: { kind: 'no_body' } } for (const member of declaration.getMembers()) { // we are visiting `path_parts, `query_parameters` or `body` assert( member, Node.isPropertyDeclaration(member) || Node.isPropertySignature(member), 'Class and interfaces can only have property declarations or signatures' ) if (member.getName() === 'body') { const property = visitRequestOrResponseProperty(member) // the body can either by a value (eg Array<string> or an object with properties) if (property.valueOf != null) { if (property.valueOf.kind === 'instance_of' && property.valueOf.type.name === 'Void') { type.body = { kind: 'no_body' } } else { const tags = parseJsDocTags((member as PropertySignature).getJsDocs()) assert( member as Node, tags.codegen_name != null, 'You should configure a body @codegen_name' ) type.body = { kind: 'value', value: property.valueOf, codegenName: tags.codegen_name } } } else { type.body = { kind: 'properties', properties: property.properties } } } else if (member.getName() === 'exceptions') { const exceptions: model.ResponseException[] = [] const property = member.getTypeNode() assert( property, Node.isTupleTypeNode(property), 'Exceptionlures should be an array.' ) for (const element of property.getElements()) { const exception: model.ResponseException = { statusCodes: [], body: { kind: 'no_body' } } element.forEachChild(child => { assert( child, Node.isPropertySignature(child) || Node.isPropertyDeclaration(child), `Children should be ${ts.SyntaxKind[ts.SyntaxKind.PropertySignature]} or ${ts.SyntaxKind[ts.SyntaxKind.PropertyDeclaration]} but is ${ts.SyntaxKind[child.getKind()]} instead` ) const jsDocs = child.getJsDocs() if (jsDocs.length > 0) { exception.description = jsDocs[0].getDescription().replace(/\r/g, '') } if (child.getName() === 'statusCodes') { const value = child.getTypeNode() assert(value, Node.isTupleTypeNode(value), 'statusCodes should be an array.') for (const code of value.getElements()) { assert(code, Node.isLiteralTypeNode(code) && Number.isInteger(Number(code.getText())), 'Status code values should a valid integer') assert(code, STATUS_CODES[code.getText()] != null, `${code.getText()} is not a valid status code`) exception.statusCodes.push(Number(code.getText())) } } else if (child.getName() === 'body') { const property = visitRequestOrResponseProperty(child) // the body can either by a value (eg Array<string> or an object with properties) if (property.valueOf != null) { if (property.valueOf.kind === 'instance_of' && property.valueOf.type.name === 'Void') { exception.body = { kind: 'no_body' } } else { exception.body = { kind: 'value', value: property.valueOf } } } else { exception.body = { kind: 'properties', properties: property.properties } } } else { assert(child, false, 'Exception.body and Exception.statusCode are the only Exception properties supported') } }) exceptions.push(exception) } type.exceptions = exceptions } else { assert(member, false, 'Response.body and Response.exceptions are the only Response properties supported') } } assert( declaration, declaration.getHeritageClauses().length === 0, 'Responses cannot be extended' ) } for (const typeParameter of declaration.getTypeParameters()) { type.generics = (type.generics ?? []).concat({ name: modelGenerics(typeParameter), namespace: type.name.namespace + '.' + type.name.name }) } return type // Every other class or interface will be handled here } else { const type: model.Interface = { specLocation: sourceLocation(declaration), kind: 'interface', name: { name, namespace: getNameSpace(declaration) }, properties: new Array<model.Property>() } const jsDocs = declaration.getJsDocs() hoistTypeAnnotations(type, jsDocs) const variant = parseVariantNameTag(declaration.getJsDocs()) if (typeof variant === 'string') { type.variantName = variant } const variants = parseVariantsTag(declaration.getJsDocs()) if (variants != null) { assert(declaration.getJsDocs(), variants.kind === 'container', 'Interfaces can only use `container` variant kind') type.variants = variants } for (const member of declaration.getMembers()) { // Any property definition assert( member, Node.isPropertyDeclaration(member) || Node.isPropertySignature(member), 'Class and interfaces can only have property declarations or signatures' ) try { const property = modelProperty(member) if (type.variants?.kind === 'container' && property.containerProperty == null) { assert( member, !property.required, 'All @variants container properties must be optional' ) } type.properties.push(property) } catch (e) { const name = declaration.getName() if (name !== undefined) { console.log(`failed to parse ${name}, reason:`, e.message) } else { console.log('failed to parse field, reason:', e.message) } process.exit(1) } } // The class or interface is extended, an extended class or interface could // accept generics as well, Implements will be caught here as well, // they can be differentiated by looking as `node.token`, which can either be // `ts.SyntaxKind.ExtendsKeyword` or `ts.SyntaxKind.ImplementsKeyword` // In case of `ts.SyntaxKind.ImplementsKeyword`, we need to check // if it's a normal implements or a behavior, in such case, the behaviors // need to be collected and added to the type. if (declaration.getHeritageClauses().length > 0) { // check if the current node or one of the ancestor // has one or more behaviors attached const attachedBehaviors = getAllBehaviors(declaration) if (attachedBehaviors.length > 0) { type.attachedBehaviors = Array.from( new Set((type.attachedBehaviors ?? []).concat(attachedBehaviors)) ) } } for (const inherit of declaration.getHeritageClauses()) { if (inherit.getToken() === ts.SyntaxKind.ExtendsKeyword) { const extended = inherit.getTypeNodes() .map(t => t.getExpression()) .map(t => t.getType().getSymbol()?.getDeclarations()[0])[0] assert(inherit, Node.isClassDeclaration(extended) || Node.isInterfaceDeclaration(extended), 'Should extend from a class or interface') type.inherits = modelInherits(extended, inherit) } } // Only classes can implement interfaces if (Node.isClassDeclaration(declaration)) { for (const implement of declaration.getImplements()) { if (isKnownBehavior(implement)) { type.behaviors = (type.behaviors ?? []).concat(modelBehaviors(implement, jsDocs)) } } } for (const typeParameter of declaration.getTypeParameters()) { type.generics = (type.generics ?? []).concat({ name: modelGenerics(typeParameter), namespace: type.name.namespace + '.' + type.name.name }) } return type } } /** * Utility wrapper around `modelProperty`, as Request classes needs to be handled * differently as are described as nested objects, and the body could have two * different types, `model.Property[]` (a normal object) or `model.ValueOf` (eg: an array or generic) */ function visitRequestOrResponseProperty (member: PropertyDeclaration | PropertySignature): { name: string, properties: model.Property[], valueOf: model.ValueOf | null } { const properties: model.Property[] = [] let valueOf: model.ValueOf | null = null const name = member.getName() const value = member.getTypeNode() assert(member, value != null, `The property ${name} is not defined`) // Request classes have three top level properties: // - path_parts // - query_parameters // - body // // In most of the cases all of those are PropertyDeclarations, eg (path_parts: { foo: string }), // but in some cases they can be a TypeReference eg (body: Array<string>) // PropertySignatures and PropertyDeclarations must be iterated via `ts.forEachChild`, as every // declaration is a child of the top level properties, while TypeReference should // directly by "unwrapped" with `modelType` because if you navigate the children of a TypeReference // you will lose the context and crafting the types becomes really hard. if (Node.isTypeReference(value) || Node.isUnionTypeNode(value)) { valueOf = modelType(value) } else { value.forEachChild(child => { assert( child, Node.isPropertySignature(child) || Node.isPropertyDeclaration(child), `Children should be ${ts.SyntaxKind[ts.SyntaxKind.PropertySignature]} or ${ts.SyntaxKind[ts.SyntaxKind.PropertyDeclaration]} but is ${ts.SyntaxKind[child.getKind()]} instead` ) properties.push(modelProperty(child)) }) } return { name, properties, valueOf } } /** * Parse the 'urls' property of a request definition. Format is: * ``` * urls: [ * { * /** @deprecated 1.2.3 Use something else * path: '/some/path', * methods: ["GET", "POST"] * } * ] * ``` */ function visitUrls (member: PropertyDeclaration | PropertySignature): model.UrlTemplate[] { const value = member.getTypeNode() // Literal arrays are exposed as tuples by ts-morph assert(value, Node.isTupleTypeNode(value), '"urls" should be an array') const result: model.UrlTemplate[] = [] value.forEachChild(urlNode => { assert(urlNode, Node.isTypeLiteral(urlNode), '"urls" members should be objects') const urlTemplate: any = {} urlNode.forEachChild(node => { assert(node, Node.isPropertySignature(node), "Expecting 'path' and 'methods' properties") const name = node.getName() const propValue = node.getTypeNode() if (name === 'path') { assert(propValue, Node.isLiteralTypeNode(propValue), '"path" should be a string') const pathLit = propValue.getLiteral() assert(pathLit, Node.isStringLiteral(pathLit), '"path" should be a string') urlTemplate.path = pathLit.getLiteralValue() // Deprecation const jsDoc = node.getJsDocs() const tags = parseJsDocTags(jsDoc) const deprecation = parseDeprecation(tags, jsDoc) if (deprecation != null) { urlTemplate.deprecation = deprecation } if (Object.keys(tags).length > 0) { assert(jsDoc, false, `Unknown annotations: ${Object.keys(tags).join(', ')}`) } } else if (name === 'methods') { assert(propValue, Node.isTupleTypeNode(propValue), '"methods" should be an array') const methods: string[] = [] propValue.forEachChild(node => { assert(node, Node.isLiteralTypeNode(node), '"methods" should contain strings') const nodeLit = node.getLiteral() assert(nodeLit, Node.isStringLiteral(nodeLit), '"methods" should contain strings') methods.push(nodeLit.getLiteralValue()) }) assert(node, methods.length > 0, "'methods' should not be empty") urlTemplate.methods = methods } else { assert(node, false, "Expecting 'path' or 'methods'") } }) assert(urlTemplate, urlTemplate.path, "Missing required property 'path'") assert(urlTemplate, urlTemplate.methods, "Missing required property 'methods'") result.push(urlTemplate) }) return result }