function compileClassOrInterfaceDeclaration()

in compiler/src/model/build-model.ts [167:568]


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
  }