microsoft / kiota

OpenAPI based HTTP Client code generator
https://aka.ms/kiota/docs
MIT License
2.83k stars 194 forks source link

TypeScript - anyOf/oneOf arrays do not emit the correct serialization and deserialization information #5353

Open baywet opened 1 week ago

baywet commented 1 week ago

Follow up of #5348 which revealed multiple bugs in TypeScript composed types generation

Repro

openapi: 3.0.3
info:
  title: Example
  description: Example
  version: 1.0.0
servers:
  - url: https://example.com/api
paths:
  '/example1':
    post:
      summary: Test error generation.
      description: "Test generating error with message property."
      operationId: postErrorGeneration
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Success'
components:
  schemas:
    Success:
      type: object
      oneOf:
        - type: string
        - type: array
          items:
            type: string

(or change the oneOf by anyOf)

Will emit the following

For the serialization code

export function serializeItemPostRequestBody_tax_rates(writer: SerializationWriter, itemPostRequestBody_tax_rates: Partial<string> | undefined | null = {}) : void {
    if (itemPostRequestBody_tax_rates === undefined || itemPostRequestBody_tax_rates === null) return;
    switch (typeof itemPostRequestBody_tax_rates) {
        case "string":
            writer.writeCollectionOfPrimitiveValues<string>(key, itemPostRequestBody_tax_rates);
            break;
    }
}

Multiple things are wrong here:

  1. the parameter declaration should contain string[] in addition to what it contains
  2. the switch should have TWO entries
  3. the first entry should call writeStringValue
  4. what is this key symbol that does not exist?
  5. a second entry should check for object + Array.isArray (and call collectionOfPrimitiveValues)

for the deserialization code

export function deserializeIntoItemPostRequestBody_tax_rates(itemPostRequestBody_tax_rates: Partial<string | string> | undefined = {}) : Record<string, (node: ParseNode) => void> {
    return {
    }
}

Multiple things are wrong here:

  1. one parameter union type value is missing the array symbol
  2. the returned body is empty, is that expected?
  3. the parameter type does not match for the factory (see below0

for the factory

/**
 * Creates a new instance of the appropriate class based on discriminator value
 * @param parseNode The parse node to use to read the discriminator value and create the object
 * @returns {string[] | string}
 */
// @ts-ignore
export function createItemPostRequestBody_tax_ratesFromDiscriminatorValue(parseNode: ParseNode | undefined) : ((instance?: Parsable) => Record<string, (node: ParseNode) => void>) {
    return deserializeIntoItemPostRequestBody_tax_rates;
}

Multiple things are wrong again here:

  1. no triage for deserialization (should happen at some point)
  2. returned value doesn't match parameter type (see above)
  3. the documented return type does not match the actual function return type.
rkodev commented 1 week ago

@baywet my thoughts in this issue is to reintroduce the wrapper class for the composed types. I don't think there is any other way to deserialize the types without atleast some changes in the kiota-typescript in order to support deserialization of composed types

rkodev commented 1 week ago

Here is an example for the changes

/* tslint:disable */
/* eslint-disable */
// Generated by Microsoft Kiota
// @ts-ignore
import { type BaseRequestBuilder, type Parsable, type ParsableFactory, type ParseNode, type RequestConfiguration, type RequestInformation, type RequestsMetadata, type SerializationWriter } from '@microsoft/kiota-abstractions';

/**
 * Creates a new instance of the appropriate class based on discriminator value
 * @param parseNode The parse node to use to read the discriminator value and create the object
 * @returns {Success}
 */
// @ts-ignore
export function createSuccessFromDiscriminatorValue(parseNode: ParseNode | null | undefined) : ((instance?: Parsable) => Record<string, (node: ParseNode) => void>) {
    return deserializeIntoSuccess;
}
/**
 * The deserialization information for the current model
 * @returns {Record<string, (node: ParseNode) => void>}
 */
// @ts-ignore
export function deserializeIntoSuccess(success: Success | null | undefined = {} as Success) : Record<string, (node: ParseNode) => void> {
    if (!success) return {};
    return {
        "string": n => { success.string = n.getCollectionOfPrimitiveValues<string>(); },
        "successString": n => { success.successString = n.getStringValue(); },
    }
}
/**
 * Builds and executes requests for operations under /example1
 */
export interface Example1RequestBuilder extends BaseRequestBuilder<Example1RequestBuilder> {
    /**
     * Test generating error with message property.
     * @param requestConfiguration Configuration for the request such as headers, query parameters, and middleware options.
     * @returns {Promise<Success>}
     */
     post(requestConfiguration?: RequestConfiguration<object> | null | undefined) : Promise<Success | undefined>;
    /**
     * Test generating error with message property.
     * @param requestConfiguration Configuration for the request such as headers, query parameters, and middleware options.
     * @returns {RequestInformation}
     */
     toPostRequestInformation(requestConfiguration?: RequestConfiguration<object> | null | undefined) : RequestInformation;
}
/**
 * Serializes information the current object
 * @param writer Serialization writer to use to serialize this model
 */
// @ts-ignore
export function serializeSuccess(writer: SerializationWriter, success: Success | null | undefined = {} as Success) : void {
    if (success === undefined || success === null) return;
    if(success.string){
      writer.writeCollectionOfPrimitiveValues<string>(undefined, success.string);
    } else if(success.successString){
      writer.writeStringValue(undefined, success.successString);
    }
}
/**
 * Composed type wrapper for classes {@link string[]}, {@link string}
 */
export interface Success extends Parsable {
    /**
     * Composed type representation for type {@link string[]}
     */
    string?: string[] | null;
    /**
     * Composed type representation for type {@link string}
     */
    successString?: string | null;
}
/**
 * Uri template for the request builder.
 */
export const Example1RequestBuilderUriTemplate = "{+baseurl}/example1";
/**
 * Metadata for all the requests in the request builder.
 */
export const Example1RequestBuilderRequestsMetadata: RequestsMetadata = {
    post: {
        uriTemplate: Example1RequestBuilderUriTemplate,
        responseBodyContentType: "application/json",
        adapterMethodName: "send",
        responseBodyFactory:  createSuccessFromDiscriminatorValue,
    },
};
/* tslint:enable */
/* eslint-enable */
rkodev commented 1 week ago

@baywet @andrueastman @koros

The approach to re-introduce the wrapper class is because I cant think of any other way to deserialize the classes that would not result in changing the ParseNode factory. As it is, deserialization of the composed types can be any union of primitives, collections or objects. It would not be possible to evaluate the correct send method since the response is unknown.

Happy to hear your thoughts on the same

andrueastman commented 1 week ago

Just to confirm here @rkodev,

Is the evaluation of the correct send method the only challenge you see?

Assuming the deserializer and serializer functions are correctly generated with the type information from the description, could we possibly

Thoughts?

The approach to re-introduce the wrapper class is because I cant think of any other way to deserialize the classes that would not result in changing the ParseNode factory.

I believe using the first class features of the language would be the best outcome here if we can get there.

rkodev commented 1 week ago

have the generator detect the scenario where the composed type is a combination of different send methods at generation time (e.g. collection and non collection or primitive and collection)

Should be possible

generate/use a sendmethod that will return an intermediate type that can be eventually used by the relevant parsenodes (e.g. a stream/Arraybuffer).

Should be possible with the sendPrimitive with type ArrayBuffer

once the stream/Arraybuffer(or intermediate type) is returned, generate code to convert to the relevant parseNode and let the generated functions do the job as they should.(this could also be a functionality added to the libs)

This will need to be handle at library level since we do risk generation of a larger amount of code.

The only challenge on this might be how to detect if the ArrayBuffer is a Parsable type X or Parsable Type Y, or a collection of type X. While it might be easier to evaluate if the content of the arraybuffer is a primitive/collection of primitives it might mean to brute force the responses for interfaces?

Another issue I found with brute force approach, you might find type X and type Y have an intersecting property i.e x.name and y.name, in this case while the response might be of type Y, if the order of brute force is type X before Y it might erroneously be converted to type X

andrueastman commented 1 week ago

The only challenge on this might be how to detect if the ArrayBuffer is a Parsable type X or Parsable Type Y, or a collection of type X. While it might be easier to evaluate if the content of the arraybuffer is a primitive/collection of primitives it might mean to brute force the responses for interfaces?

Won't the generated deserailizer function do all this? All you would need to do is initialize a ParseNode instance with the stream and pass it to the function. Yes?

baywet commented 1 week ago

Thanks for adding much needed details on this topic. In the case of deserialization whenever primitive types are present (or collections of Parsable) as member of the union types, I believe the main limitation is the fact that factory methods:

  1. always declare returning a parsable.
  2. do not contain "triage" code to return the collections of parsable or the scalar values.

If we could start unblocking this, we'd probably get to the next logical blocker (which might be the parsenode interface itself?)

For the serialization case, I think having the serializeMethod being a triage method should suffice as you've illustrated, and shouldn't require wrapper classes, anything I'm missing here?