kiota icon indicating copy to clipboard operation
kiota copied to clipboard

Add support for composed types in Typescript

Open koros opened this issue 1 year ago • 1 comments

Fixes: https://github.com/microsoft/kiota/issues/1812

Related Tickets

  • https://github.com/microsoft/kiota/issues/2462
  • https://github.com/microsoft/kiota/issues/4326

TODO

  • [X] Handle Union of primitive values e.g https://github.com/microsoft/kiota/issues/2462
  • [ ] Handle Objects Unions - by using Discriminator info and Intersections

Generation Logic Design:

1. Composed Types Comprised of Primitives Without Discriminator Property

Sample yml
openapi: 3.0.1
info:
  title: Example of UnionTypes
  version: 1.0.0
paths:
  /primitives:
    get:
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/primitives'
components:
  schemas:
    primitives:
      oneOf:
        - type: string
        - type: number

For the union of primitive values shown above, the generation logic is as follows: The factory method for union of primitives determines the return type from the parse node value. For example, if the node is number | string, the method should return the correct type based on the node's value, as shown below:


export type Primitives = number | string;

export function createPrimitivesFromDiscriminatorValue3(parseNode: ParseNode | undefined) : number | string | undefined {
  if (parseNode) {
    return parseNode.getNumberValue() || parseNode.getStringValue();
  }
  return undefined;
}

There is no need for a deserialization method, so it is omitted during generation.

The serialization method will determine the type of the node and call the respective method on the abstraction library for that type. Using the example above, if the node is number | string, the serializer should call either writer.writeNumberValue or writer.writeStringValue based on the node's actual value, as shown below:

export function serializePrimitives(writer: SerializationWriter, key: string, primitives: Primitives | undefined) : void {
    if (primitives == undefined) return;
    switch (typeof primitives) {
        case "number":
            writer.writeNumberValue(key, primitives);
            break;
        case "string":
            writer.writeStringValue(key, primitives);
            break;
    }
}

2. Composed Types Comprised of Objects with a Discriminator Property Specified

Sample yml
openapi: 3.0.3
info:
  title: Pet API
  description: An API to return pet information.
  version: 1.0.0
servers:
  - url: http://localhost:8080
    description: Local server

paths:
  /pet:
    get:
      summary: Get pet information
      operationId: getPet
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Cat'
                  - $ref: '#/components/schemas/Dog'
                discriminator:
                  propertyName: petType
                  mapping:
                    cat: '#/components/schemas/Cat'
                    dog: '#/components/schemas/Dog'
        '400':
          description: Bad Request
        '500':
          description: Internal Server Error

components:
  schemas:
    Pet:
      type: object
      required:
        - id
        - name
        - petType
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: "Fido"
        petType:
          type: string
          description: "Type of the pet"
          example: "cat"

    Cat:
      allOf:
        - $ref: '#/components/schemas/Pet'
        - type: object
          properties:
            favoriteToy:
              type: string
              example: "Mouse"

    Dog:
      allOf:
        - $ref: '#/components/schemas/Pet'
        - type: object
          properties:
            breed:
              type: string
              example: "Labrador"

For the example above, the factory method determines which pet to create, either Cat or Dog, based on the discriminator information, as shown below:

export type PetGetResponse = Cat | Dog;

export function createPetGetResponseFromDiscriminatorValue(parseNode: ParseNode | undefined) : ((instance?: Parsable) => Record<string, (node: ParseNode) => void>) {
    const mappingValueNode = parseNode.getChildNode("petType");
    if (mappingValueNode) {
        const mappingValue = mappingValueNode.getStringValue();
        if (mappingValue) {
            switch (mappingValue) {
                case "cat":
                    return deserializeIntoCat;
                case "dog":
                    return deserializeIntoDog;
            }
        }
    }
    throw new Error("A discriminator property is required to distinguish a union type");
}

The deserializer is not necessary since the function has been delegated to the respective handlers as shown above. So instead of deserializePet, we have either deserializeCat if the response is a Cat, or deserializeDog if the response is a Dog.

The serializer will also delegate the writing functionality to the respective writer. In the example above, the generated output will be as follows:

export function serializePetGetResponse(writer: SerializationWriter, petGetResponse: Partial<PetGetResponse> | undefined = {}) : void {
    if (petGetResponse == undefined) return;
    switch (petGetResponse.petType) {
        case "cat":
            serializeCat(writer, petGetResponse);
            break;
        case "dog":
            serializeDog(writer, petGetResponse);
            break;
    }
}

3. Intersection of Object Values


// Dummy intersection Objects to use in the tests

type Foo = { foo?: string; }
type Bar = { bar?: string; }

export type FooBar = Foo & Bar;


// Factory Method
export function createFooBarFromDiscriminatorValue(parseNode: ParseNode | undefined) : ((instance?: Parsable) => Record<string, (node: ParseNode) => void>) {
	return deserializeIntoFooBar;
}

// Deserialization methods
export function deserializeIntoFooBar(fooBar: Partial<FooBar> | undefined = {}) : Record<string, (node: ParseNode) => void> {
    return {
        ...deserializeIntoFoo(fooBar),
        ...deserializeIntoBar(fooBar),
    }
}

export function deserializeIntoFoo(foo: Partial<Foo> | undefined = {}) : Record<string, (node: ParseNode) => void> {
    return {
        "foo": n => { foo.foo = n.getStringValue(); },
    }
}

export function deserializeIntoBar(bar: Partial<Bar> | undefined = {}) : Record<string, (node: ParseNode) => void> {
    return {
        "bar": n => { bar.bar = n.getStringValue(); },
    }
}


// Serialization methods
export function serializeFoo(writer: SerializationWriter, foo: Partial<Foo> | undefined = {}) : void {
    writer.writeStringValue("foo", foo.foo);
}

export function serializeBar(writer: SerializationWriter, bar: Partial<Bar> | undefined = {}) : void {
    writer.writeStringValue("bar", bar.bar);
}

export function serializeFooBar(writer: SerializationWriter, fooBar: Partial<FooBar> | undefined = {}) : void {
  serializeFoo(writer, fooBar);
  serializeBar(writer, fooBar);
}

koros avatar May 06 '24 08:05 koros

@rkodev Please also remember to refactor the composed type which comprises of primitive values only, The factory methods I used won't work for primitive only values. My proposal is remove the factory methods together with serializers and deserializers.

then if the composed type field is property inside another object the existing logic will handle it e.g:

Deserializing

primitiveComposedType: (n) => {
    response.primitiveComposedType= n.getNumberValue() ?? n.getStringValue();
},

Serializing

switch (typeof response.primitiveComposedType) {
    case "number":
        writer.writeNumberValue("data", response.primitiveComposedType);
	 break;
    case "string":
        writer.writeStringValue("data", response.primitiveComposedType);
        break;
}

However if the composed type is at the root of the payload then sendPrimitive should be modified since basically there is no corresponding factory

koros avatar Aug 27 '24 16:08 koros

@koros there are a number of changes that have been made

For serializing this is the syntax to check for types when its a collection or single value of primitives

        switch (true) {
            case typeof bank_account.account === "string":
                writer.writeStringValue("account", bank_account.account as string);
            break;
            case Array.isArray(bank_account.account) && bank_account.account.every(item => typeof item === 'number'):
                writer.writeCollectionOfPrimitiveValues<string>("account", bank_account.account);
            break;
            default:
                writer.writeObjectValue<Account>("account", bank_account.account as Account | undefined | null, serializeBank_account_account);
            break;
        }

as for the deserializing, here is an example of a composed type that will serialize objects / collection of objects or primitives

petGetResponse.data = n.getObjectValue<Cat | Dog>(createpetResponseFromDiscriminatorValue) ?? n.getCollectionOfObjectValues<Dog>(createDogFromDiscriminatorValue) ?? n.getNumberValue() ?? n.getStringValue()

rkodev avatar Aug 27 '24 17:08 rkodev

@rkodev can you also look at the defects reported here (ignore the complexity ones) and address the ones you can before I give it another review please? https://sonarcloud.io/project/issues?id=microsoft_kiota&pullRequest=4602&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true

baywet avatar Aug 28 '24 12:08 baywet

@andrueastman FYI you still have two comments that are unresolved in the history. I didn't resolve them so you get a chance at checking whether they still apply

baywet avatar Aug 29 '24 13:08 baywet

@rkodev I just noticed we forgot the changelog entry. Can you stand a quick pr please?

baywet avatar Aug 29 '24 15:08 baywet

Done

rkodev avatar Aug 29 '24 16:08 rkodev