openapi-generator: [java] [typescript-angular] Regression: No model for array of type file

Description

We have an api, where the resource consumes in the post body an array of files. While I’m not sure if it’s fully spec compliant, the swagger codegen generates workable code , but the openapi-generator does not find a model / schema, and uses the UNKNOWN_BASE_TYPE.

Generated Code from openapi-generator
import { UNKNOWN_BASE_TYPE } from '../model/uNKNOWNBASETYPE'; // this can never work, since this type is not generated, and will always end up in a compilation error -> should this just be the any type in typescript?

// [....]
/**
     * uploadFilesToStage
     * 
     * @param uploadId uploadId
     * @param UNKNOWN_BASE_TYPE 
     * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
     * @param reportProgress flag to report request and response progress.
     */
    public uploadFilesToStageUsingPOST(uploadId: string, UNKNOWN_BASE_TYPE: UNKNOWN_BASE_TYPE, observe?: 'body', reportProgress?: boolean): Observable<StagedFileUploadStatus>;
    public uploadFilesToStageUsingPOST(uploadId: string, UNKNOWN_BASE_TYPE: UNKNOWN_BASE_TYPE, observe?: 'response', reportProgress?: boolean): Observable<HttpResponse<StagedFileUploadStatus>>;
    public uploadFilesToStageUsingPOST(uploadId: string, UNKNOWN_BASE_TYPE: UNKNOWN_BASE_TYPE, observe?: 'events', reportProgress?: boolean): Observable<HttpEvent<StagedFileUploadStatus>>;
    public uploadFilesToStageUsingPOST(uploadId: string, UNKNOWN_BASE_TYPE: UNKNOWN_BASE_TYPE, observe: any = 'body', reportProgress: boolean = false ): Observable<any> {
        if (uploadId === null || uploadId === undefined) {
            throw new Error('Required parameter uploadId was null or undefined when calling uploadFilesToStageUsingPOST.');
        }
        if (UNKNOWN_BASE_TYPE === null || UNKNOWN_BASE_TYPE === undefined) {
            throw new Error('Required parameter UNKNOWN_BASE_TYPE was null or undefined when calling uploadFilesToStageUsingPOST.');
        }

        let queryParameters = new HttpParams({encoder: new CustomHttpUrlEncodingCodec()});
        if (uploadId !== undefined && uploadId !== null) {
            queryParameters = queryParameters.set('uploadId', <any>uploadId);
        }

        let headers = this.defaultHeaders;

        // to determine the Accept header
        let httpHeaderAccepts: string[] = [
            '*/*'
        ];
        const httpHeaderAcceptSelected: string | undefined = this.configuration.selectHeaderAccept(httpHeaderAccepts);
        if (httpHeaderAcceptSelected !== undefined) {
            headers = headers.set('Accept', httpHeaderAcceptSelected);
        }

        // to determine the Content-Type header
        const consumes: string[] = [
            'application/json'
        ];
        const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes);
        if (httpContentTypeSelected !== undefined) {
            headers = headers.set('Content-Type', httpContentTypeSelected);
        }

        return this.httpClient.post<StagedFileUploadStatus>(`${this.configuration.basePath}/be/files/staged`,
            UNKNOWN_BASE_TYPE,
            {
                params: queryParameters,
                withCredentials: this.configuration.withCredentials,
                headers: headers,
                observe: observe,
                reportProgress: reportProgress
            }
        );
    }
Generated Code from swagger-generator
/**
     * uploadFilesToStage
     * 
     * @param uploadId uploadId
     * @param files files
     * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
     * @param reportProgress flag to report request and response progress.
     */
    public uploadFilesToStageUsingPOST(uploadId: string, files: Array<Blob>, observe?: 'body', reportProgress?: boolean): Observable<StagedFileUploadStatus>;
    public uploadFilesToStageUsingPOST(uploadId: string, files: Array<Blob>, observe?: 'response', reportProgress?: boolean): Observable<HttpResponse<StagedFileUploadStatus>>;
    public uploadFilesToStageUsingPOST(uploadId: string, files: Array<Blob>, observe?: 'events', reportProgress?: boolean): Observable<HttpEvent<StagedFileUploadStatus>>;
    public uploadFilesToStageUsingPOST(uploadId: string, files: Array<Blob>, observe: any = 'body', reportProgress: boolean = false ): Observable<any> {
        if (uploadId === null || uploadId === undefined) {
            throw new Error('Required parameter uploadId was null or undefined when calling uploadFilesToStageUsingPOST.');
        }
        if (files === null || files === undefined) {
            throw new Error('Required parameter files was null or undefined when calling uploadFilesToStageUsingPOST.');
        }

        let queryParameters = new HttpParams({encoder: new CustomHttpUrlEncodingCodec()});
        if (uploadId !== undefined) {
            queryParameters = queryParameters.set('uploadId', <any>uploadId);
        }

        let headers = this.defaultHeaders;

        // to determine the Accept header
        let httpHeaderAccepts: string[] = [
            '*/*'
        ];
        let httpHeaderAcceptSelected: string | undefined = this.configuration.selectHeaderAccept(httpHeaderAccepts);
        if (httpHeaderAcceptSelected != undefined) {
            headers = headers.set("Accept", httpHeaderAcceptSelected);
        }

        // to determine the Content-Type header
        let consumes: string[] = [
            'application/json'
        ];

        const canConsumeForm = this.canConsumeForm(consumes);

        let formParams: { append(param: string, value: any): void; };
        let useForm = false;
        let convertFormParamsToString = false;
        if (useForm) {
            formParams = new FormData();
        } else {
            formParams = new HttpParams({encoder: new CustomHttpUrlEncodingCodec()});
        }

        if (files) {
            files.forEach((element) => {
                formParams = formParams.append('files', <any>element) || formParams;
            })
        }

        return this.httpClient.post<StagedFileUploadStatus>(`${this.basePath}/be/files/staged`,
            convertFormParamsToString ? formParams.toString() : formParams,
            {
                params: queryParameters,
                withCredentials: this.configuration.withCredentials,
                headers: headers,
                observe: observe,
                reportProgress: reportProgress
            }
        );
    }
Generated Code from openapi-generator, if consumes is set to multipart/form-data
 /**
     * uploadFilesToStage
     * 
     * @param uploadId uploadId
     * @param files files
     * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
     * @param reportProgress flag to report request and response progress.
     */
    public uploadFilesToStageUsingPOST(uploadId: string, files: Array<Blob>, observe?: 'body', reportProgress?: boolean): Observable<StagedFileUploadStatus>;
    public uploadFilesToStageUsingPOST(uploadId: string, files: Array<Blob>, observe?: 'response', reportProgress?: boolean): Observable<HttpResponse<StagedFileUploadStatus>>;
    public uploadFilesToStageUsingPOST(uploadId: string, files: Array<Blob>, observe?: 'events', reportProgress?: boolean): Observable<HttpEvent<StagedFileUploadStatus>>;
    public uploadFilesToStageUsingPOST(uploadId: string, files: Array<Blob>, observe: any = 'body', reportProgress: boolean = false ): Observable<any> {
        if (uploadId === null || uploadId === undefined) {
            throw new Error('Required parameter uploadId was null or undefined when calling uploadFilesToStageUsingPOST.');
        }
        if (files === null || files === undefined) {
            throw new Error('Required parameter files was null or undefined when calling uploadFilesToStageUsingPOST.');
        }

        let queryParameters = new HttpParams({encoder: new CustomHttpUrlEncodingCodec()});
        if (uploadId !== undefined && uploadId !== null) {
            queryParameters = queryParameters.set('uploadId', <any>uploadId);
        }

        let headers = this.defaultHeaders;

        // to determine the Accept header
        let httpHeaderAccepts: string[] = [
            '*/*'
        ];
        const httpHeaderAcceptSelected: string | undefined = this.configuration.selectHeaderAccept(httpHeaderAccepts);
        if (httpHeaderAcceptSelected !== undefined) {
            headers = headers.set('Accept', httpHeaderAcceptSelected);
        }

        // to determine the Content-Type header
        const consumes: string[] = [
            'multipart/form-data'
        ];

        const canConsumeForm = this.canConsumeForm(consumes);

        let formParams: { append(param: string, value: any): any; };
        let useForm = false;
        let convertFormParamsToString = false;
        if (useForm) {
            formParams = new FormData();
        } else {
            formParams = new HttpParams({encoder: new CustomHttpUrlEncodingCodec()});
        }

        if (files) {
            formParams = formParams.append('files', files.join(COLLECTION_FORMATS['csv'])) || formParams;
        }

        return this.httpClient.post<StagedFileUploadStatus>(`${this.configuration.basePath}/be/files/staged`,
            convertFormParamsToString ? formParams.toString() : formParams,
            {
                params: queryParameters,
                withCredentials: this.configuration.withCredentials,
                headers: headers,
                observe: observe,
                reportProgress: reportProgress
            }
        );
    }
Working code example
/**
   * uploadFilesToStage
   *
   * @param uploadId uploadId
   * @param files
   * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
   * @param reportProgress flag to report request and response progress.
   */
  public uploadFilesToStageUsingPOST(uploadId: string, files: Blob[], observe?: 'body', reportProgress?: boolean): Observable<StagedFileUploadStatus>;
  public uploadFilesToStageUsingPOST(uploadId: string, files: Blob[], observe?: 'response', reportProgress?: boolean): Observable<HttpResponse<StagedFileUploadStatus>>;
  public uploadFilesToStageUsingPOST(uploadId: string, files: Blob[], observe?: 'events', reportProgress?: boolean): Observable<HttpEvent<StagedFileUploadStatus>>;
  public uploadFilesToStageUsingPOST(uploadId: string, files: Blob[], observe: any = 'body', reportProgress: boolean = false ): Observable<any> {
    if (uploadId === null || uploadId === undefined) {
      throw new Error('Required parameter uploadId was null or undefined when calling uploadFilesToStageUsingPOST.');
    }
    if (files === null || files === undefined) {
      throw new Error('Required parameter files was null or undefined when calling uploadFilesToStageUsingPOST.');
    }

    let queryParameters = new HttpParams({encoder: new CustomHttpUrlEncodingCodec()});
    if (uploadId !== undefined && uploadId !== null) {
      queryParameters = queryParameters.set('uploadId', <any>uploadId);
    }

    let headers = this.defaultHeaders;

    // to determine the Accept header
    let httpHeaderAccepts: string[] = [
      '*/*'
    ];
    const httpHeaderAcceptSelected: string | undefined = this.configuration.selectHeaderAccept(httpHeaderAccepts);
    if (httpHeaderAcceptSelected !== undefined) {
      headers = headers.set('Accept', httpHeaderAcceptSelected);
    }

    // to determine the Content-Type header
    // const consumes: string[] = [
    //   'multipart/form-data'
    // ];

    // const canConsumeForm = this.canConsumeForm(consumes);
    const canConsumeForm = true;

    let formParams: { append(param: string, value: any): any; };
    let useForm = false;
    let convertFormParamsToString = false;
    // use FormData to transmit files using content-type "multipart/form-data"
    // see https://stackoverflow.com/questions/4007969/application-x-www-form-urlencoded-or-multipart-form-data
    useForm = canConsumeForm;
    if (useForm) {
      formParams = new FormData();
    } // else {
    //   formParams = new HttpParams({encoder: new CustomHttpUrlEncodingCodec()});
    // }

    if (files !== undefined) {
      // formParams = formParams.append('files', <any>files) || formParams;

      for (var i = 0; i < files.length; i++) {
        formParams = formParams.append('files', files[i]) || formParams;
        // formParams = formParams.append( 'files[' + i + ']', files[i]) || formParams;
      }

      // Array.from(Array(files.length).keys()).map(x => {
      //   formParams.append(`files[${x}]`, files[x]);
      // });
    }

    // Attention: we need to let the browser set the header to automatically configure the file boundaries!
    // const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes);
    // if (httpContentTypeSelected !== undefined) {
    //   headers = headers.set('Content-Type', httpContentTypeSelected);
    // }

    // console.log('ul', formParams);

    return this.httpClient.post<StagedFileUploadStatus>(`${this.configuration.basePath}/be/files/staged`,
      convertFormParamsToString ? formParams.toString() : formParams,
      {
        params: queryParameters,
        withCredentials: this.configuration.withCredentials,
        headers: headers,
        observe: observe,
        reportProgress: reportProgress
      }
    );
  }
openapi-generator version

openapi-generator: version 3.3.2 -> it works in swagger-codegen 2.3.1

OpenAPI declaration file content or url
{
  "paths": {
      "/be/files/staged": {
      "post": {
        "tags": [
          "atrs-backend-file-upload-controller"
        ],
        "summary": "uploadFilesToStage",
        "operationId": "uploadFilesToStageUsingPOST",
        "consumes": [
          "application/json"
        ],
        "produces": [
          "*/*"
        ],
        "parameters": [
          {
            "name": "uploadId",
            "in": "query",
            "description": "uploadId",
            "required": true,
            "type": "string"
          },
          {
            "name": "files",
            "in": "formData",
            "description": "files",
            "required": true,
            "type": "array",
            "items": {
              "type": "file"
            },
            "collectionFormat": "multi"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "schema": {
              "$ref": "#/definitions/StagedFileUploadStatus"
            }
          },
          "201": {
            "description": "Created"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    }
  },
  "definitions": {
      "StagedFileUploadStatus": {
      "type": "object",
      "properties": {
        "fileStatuses": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/FileStatus"
          }
        },
        "uploadId": {
          "type": "string"
        }
      },
      "title": "StagedFileUploadStatus"
    },
    "FileStatus": {
      "type": "object",
      "properties": {
        "accountCount": {
          "type": "integer",
          "format": "int64"
        },
        "accountOwnershipCount": {
          "type": "integer",
          "format": "int64"
        },
        "accountOwnershipUpsertCount": {
          "type": "integer",
          "format": "int64"
        },
        "accountUpsertCount": {
          "type": "integer",
          "format": "int64"
        },
        "archiveName": {
          "type": "string"
        },
        "beneficialOwnerAddressCount": {
          "type": "integer",
          "format": "int64"
        },
        "beneficialOwnerAddressUpsertCount": {
          "type": "integer",
          "format": "int64"
        },
        "beneficialOwnerCount": {
          "type": "integer",
          "format": "int64"
        },
        "beneficialOwnerUpsertCount": {
          "type": "integer",
          "format": "int64"
        },
        "documentCount": {
          "type": "integer",
          "format": "int64"
        },
        "documentUpsertCount": {
          "type": "integer",
          "format": "int64"
        },
        "fileName": {
          "type": "string"
        },
        "fileType": {
          "type": "string"
        },
        "messages": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/UiCommunicationMessage"
          }
        },
        "success": {
          "type": "boolean"
        },
        "taxPayerIdentificationNumberCount": {
          "type": "integer",
          "format": "int64"
        },
        "taxPayerIdentificationNumberUpsertCount": {
          "type": "integer",
          "format": "int64"
        },
        "transactionCount": {
          "type": "integer",
          "format": "int64"
        },
        "transactionUpsertCount": {
          "type": "integer",
          "format": "int64"
        }
      },
      "title": "FileStatus"
    }
  }
}
Error message during code-generation
[WARNING] The following schema has undefined (null) baseType. It could be due to form parameter defined in OpenAPI v2 spec with incorrect consumes. A correct 'consumes' for form parameters should be 'application/x-www-form-urlencoded' or 'multipart/form-data'
[WARNING] schema: class Schema {
    title: null
    multipleOf: null
    maximum: null
    exclusiveMaximum: null
    minimum: null
    exclusiveMinimum: null
    maxLength: null
    minLength: null
    pattern: null
    maxItems: null
    minItems: null
    uniqueItems: null
    maxProperties: null
    minProperties: null
    required: [files]
    type: null
    not: null
    properties: {files=class ArraySchema {
        class Schema {
            title: null
            multipleOf: null
            maximum: null
            exclusiveMaximum: null
            minimum: null
            exclusiveMinimum: null
            maxLength: null
            minLength: null
            pattern: null
            maxItems: null
            minItems: null
            uniqueItems: null
            maxProperties: null
            minProperties: null
            required: null
            type: null
            not: null
            properties: null
            additionalProperties: null
            description: files
            format: null
            $ref: null
            nullable: null
            readOnly: null
            writeOnly: null
            example: null
            externalDocs: null
            deprecated: null
            discriminator: null
            xml: null
        }
        type: array
        items: class FileSchema {
            class Schema {
                title: null
                multipleOf: null
                maximum: null
                exclusiveMaximum: null
                minimum: null
                exclusiveMinimum: null
                maxLength: null
                minLength: null
                pattern: null
                maxItems: null
                minItems: null
                uniqueItems: null
                maxProperties: null
                minProperties: null
                required: null
                type: null
                not: null
                properties: null
                additionalProperties: null
                description: null
                format: null
                $ref: null
                nullable: null
                readOnly: null
                writeOnly: null
                example: null
                externalDocs: null
                deprecated: null
                discriminator: null
                xml: null
            }
            type: file
            format: binary
        }
    }}
    additionalProperties: null
    description: null
    format: null
    $ref: null
    nullable: null
    readOnly: null
    writeOnly: null
    example: null
    externalDocs: null
    deprecated: null
    discriminator: null
    xml: null
}
[WARNING] codegenModel is null. Default to UNKNOWN_BASE_TYPE
Command line used for generation

mvn test mvn verify

The source is spring boot / java, with the following method signature:

@RequestMapping(value = "/staged", method = RequestMethod.POST)
    public StagedFileUploadStatus uploadFilesToStage(@RequestParam String uploadId, @RequestParam("files") MultipartFile[] files) throws IOException { ... }
Steps to reproduce

Generate the code from the json file using openapi-generator generates the incorrect code with an

import { UNKNOWN_BASE_TYPE } from '../model/uNKNOWNBASETYPE';
```.

The swagger-codegen does not create an unknown base type.

##### Suggest a fix/enhancement
- The generated code should never use the unknown base type in typescript. Instead this should just be the <any> type.
- The request signature should not be`foo (uploadId: string, UNKNOWN_BASE_TYPE: UNKNOWN_BASE_TYPE, observe?: 'body', reportProgress?: boolean)` -> the name of the request parameter should still be files.
- Some changes for the generated template (with consumes set to `multipart/form-data`), in order to correctly send the files.

Furthermore, the consumes / content type header should not be set by typescript to 'multipart/form-data' -> this has to be set by the browser (to something like `Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryvFl26fEEGB24rwat`). Without the formboundary, the backend is not going to be able to split the form into different files.

I could make a PR with the typescript-angular template changes, however the `UNKNOWN_BASE_TYPE` will result in broken compilation error, and I probably can't make a PR to fix that behaviour. I suggest using an <any> type for this case.

If the consumes is set to `multipart/form-data` (by requiring a List of files, and not an array in the spring boot stuff), some code is generated without the UNKNOWN_BASE_TYPE. However, the problem there is, that the typescript code is incorrect due to some assumptions.

First of all, it generates the following:
```typescript
        if (files) {
            formParams = formParams.append('files', files.join(COLLECTION_FORMATS['csv'])) || formParams;
        }

This will throw TypeError: files.join is not a function. Also, the assumtion that csv is going to be used is incorrect.

Therefore, this should probably be changed to something like the below:

        if (files) {
            for (var i = 0; i < files.length; i++) {
                formParams = formParams.append('files', files[i]) || formParams;
            }
        }

If that’s fixed, the next error will be at spring side with org.springframework.web.multipart.MultipartException: Current request is not a multipart request.

        let formParams: { append(param: string, value: any): any; };
        let useForm = false;
        let convertFormParamsToString = false;
        if (useForm) {
            formParams = new FormData();
        } else {
            formParams = new HttpParams({encoder: new CustomHttpUrlEncodingCodec()});
        }

Since useForm is false, the sent content will not be correctly, at least if it’s not a text / csv file.

Let me know your thoughts @macjohnny @wing328, I can probably create a template which will hopefully generate a more correct api.

About this issue

  • Original URL
  • State: closed
  • Created 6 years ago
  • Reactions: 4
  • Comments: 25 (15 by maintainers)

Commits related to this issue

Most upvoted comments

Any tips how to deal with UNKNOWN_BASE_TYPE? I get it both in typescript-fetch and typescript-node.

@macjohnny : If it was just updating the generated client code I’d have made a PR.

The problem is, that I suspect that an incorrect condition is set in the codegen .jar itself. Below, you see the relevant template code. Generated is what’s in {{^isCollectionFormatMulti}}, whereas I’d expect {{#isCollectionFormatMulti}} code to be run:

        {{#isCollectionFormatMulti}}
            {{paramName}}.forEach((element) => {
                {{#useHttpClient}}formParams = {{/useHttpClient}}formParams.append('{{baseName}}', <any>element){{#useHttpClient}} || formParams{{/useHttpClient}};
            })
        {{/isCollectionFormatMulti}}
        {{^isCollectionFormatMulti}}
            {{#useHttpClient}}formParams = {{/useHttpClient}}formParams.append('{{baseName}}', {{paramName}}.join(COLLECTION_FORMATS['{{collectionFormat}}'])){{#useHttpClient}} || formParams{{/useHttpClient}};
        {{/isCollectionFormatMulti}}

Furthermore is also that currently the generated API’s have let useForm = false; so form uploads are never used.

While I can fix and test a generated angular client well enough, I’m not sure I can provide a fix for the java code itself (or that I’d know if indeed an incorrect code branch is selected).

Furthermore, the petstore API itself does not cover these 2 cases, so there are no existing tests where we could confirm the regression is fixed correctly. It might be sensible to add a new api call with an array of files as well - Although probably most client’s don’t even test the normal file upload api’s. The angular client for sure doesn’t test the normal file upload.

See https://github.com/OpenAPITools/openapi-generator/issues/1458#issuecomment-439348229 for more detail