OpenAPITools / OpenAPITools/openapi-generator

[BUG][Java Spring] Generating wrong type for response when list and single responses under same tag

Open
#15,768 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Java
Stars
26.8k
Forks
7.7k
PR merge metrics
PR metrics pending

Description

  • Have you provided a full/minimal spec to reproduce the issue?
  • Have you validated the input using an OpenAPI validator (example)?
  • Have you tested with the latest master to confirm the issue still exists?
  • Have you searched for related issues/PRs?
  • What's the actual output vs expected output?
Description

As a learning lab, I have this repository (and until the merge happens, the code can be found in the branch feature/wip_country-api). In it I am exhaustively using the $ref: parameter in every possible place.

Why am I doing this? Simply because the specification says it is possible.

Apparently there must be some cache that considers the tag and replicates the response type regardless of the endpoints contained in it.

openapi-generator version

org.openapitools:openapi-generator-maven-plugin:6.0.0 and with the latest master by building the JAR locally to see if the issue has already been addressed.

OpenAPI declaration file content or url

NOTE!: The comments # Open-API generation issue. are related to another type of path resolution bug.

# specification.yaml:
openapi: '3.0.3'
info:
  title: &info-title moname-addressing-api
  description: Project to money me with no name [addressing-api].
  termsOfService: https://moname.kinlhp.com/api/terms
  contact:
    name: Kin.LHP® Software, Inc.
    url: https://kinlhp.com
    email: lhp.kin@gmail.com
  license:
    name: &info-license-name MIT License
    url: https://github.com/kinlhpsoftwareinc/moname/blob/develop/LICENSE
  version: 1.0.0.BUILD-SNAPSHOT
servers:
  - url: http://{host}:{port}/{basePath}
    description: The local development API server.
    variables:
      host:
        # NOTE! No enum here means it is an open value.
        enum:
          - localhost
        default: localhost
        description:
      port:
        # NOTE! No enum here means it is an open value.
        enum:
          - 8080
        default: 8080
        description:
      basePath:
        # NOTE! No enum here means it is an open value.
        enum:
          - api
        default: api
        description:
  - url: https://{host}:{port}
    description: The production API server.
    variables:
      host:
        # NOTE! No enum here means it is an open value.
        enum:
          - api.addressing.moname.kinlhp.com
        default: api.addressing.moname.kinlhp.com
        description: This value is assigned by the service provider.
      port:
        # NOTE! No enum here means it is an open value.
        enum:
          - 8080
        default: 8080
        description: This value is assigned by the service provider.
paths:
  /v1/countries:
    #
    # Escaped forward-slash is necessary when using JSON references.
    # https://spec.openapis.org/oas/latest.html#operationref-examples
    $ref: 'components/pathItems/v1/countries.yaml#/getCountries'
  /v1/countries/{numeric-code}:
    #
    # Escaped forward-slash is necessary when using JSON references.
    # https://spec.openapis.org/oas/latest.html#operationref-examples
    $ref: 'components/pathItems/v1/countries.yaml#/getCountry'
tags:
  - name: countries
    description: Countries resource related.
# components/commons/examples/4xx.yaml
notFound:
  summary: &notFound-summary 404 Not Found.
  description: *notFound-summary
  value:
    code: 404
    description: Not Found
# components/commons/examples/parameters.yaml
pageIndex:
  summary: &pageIndex-summary Zero-based page index, must not be negative.
  description: *pageIndex-summary
  value: 0
pageSize:
  summary: &pageSize-summary The size of the page to be returned, must be greater than 0.
  description: *pageSize-summary
  value: 25
# components/commons/parameters/in/query.yaml
pageIndex:
  name: page-index
  in: query
  description: Zero-based page index, must not be negative.
  required: true
  deprecated: false
  allowEmptyValue: false
  style: form
  explode: false
  allowReserved: false
  schema:
    $ref: '../../schemas/parameters.yaml#/pageIndex'
  examples:
    pageIndex:
      #ref: '../../examples/parameters.yaml#/pageIndex'
      $ref: '../components/commons/examples/parameters.yaml#/pageIndex' # Open-API generation issue.
pageSize:
  name: page-size
  in: query
  description: The size of the page to be returned, must be greater than 0.
  required: true
  deprecated: false
  allowEmptyValue: false
  style: form
  explode: false
  allowReserved: false
  schema:
    $ref: '../../schemas/parameters.yaml#/pageSize'
  examples:
    pageSize:
      #ref: '../../examples/parameters.yaml#/pageSize'
      $ref: '../components/commons/examples/parameters.yaml#/pageSize' # Open-API generation issue.
# components/commons/responses/2xx.yaml
noContent:
  description: 204 No Content.
# components/commons/responses/4xx.yaml
notFound:
  description: 404 Not Found.
  content:
    application/json:
      schema:
        $ref: '../schemas/4xx.yaml#/notFoundResponse'
      examples:
        notFound:
          $ref: '../examples/4xx.yaml#/notFound'
# components/commons/schemas/4xx.yaml
errorCode:
  nullable: false
  readOnly: true
  deprecated: false
  multipleOf: 1
  maximum: 499
  minimum: 400
  type: integer
  description:
  format: int32
  default: 404
errorDescription:
  nullable: false
  readOnly: true
  deprecated: false
  maxLength: 31
  minLength: 4
  type: string
  description:
  default: Not Found
notFoundResponse:
  nullable: false
  readOnly: true
  deprecated: false
  title: notFoundResponse
  required:
    - code
    - description
  type: object
  properties:
    code:
      $ref: '#/errorCode'
    description:
      $ref: '#/errorDescription'
  description:
# components/commons/schemas/parameters.yaml
pageIndex:
  nullable: false
  readOnly: true
  deprecated: false
  multipleOf: 1
  maximum: 2147483647
  minimum: 0
  type: integer
  description:
  format: int32
  default: 0
pageSize:
  nullable: false
  readOnly: true
  deprecated: false
  multipleOf: 1
  maximum: 100
  minimum: 1
  type: integer
  description:
  format: int32
  default: 25
# components/examples/countries.yaml
countriesResponse:
  summary: &countriesResponse-summary Countries.
  description: *countriesResponse-summary
  value:
    countries:
      - alpha2Code: AF
        alpha3Code: AFG
        englishName: Afghanistan
        frenchName: Afghanistan (l')
        internetCctld: .af
        numericCode: 4
        portugueseName: Afeganistão
      - alpha2Code: BV
        alpha3Code: BVT
        englishName: Bouvet Island
        frenchName: Bouvet (l'Île)
        internetCctld:
        numericCode: 74
        portugueseName: Ilha Bouvet
      - alpha2Code: ZM
        alpha3Code: ZMB
        englishName: Zambia
        frenchName: Zambie (la)
        internetCctld: .zm
        numericCode: 894
        portugueseName: Zâmbia
countryResponse:
  summary: &countryResponse-summary Country.
  description: *countryResponse-summary
  value:
    alpha2Code: BR
    alpha3Code: BRA
    englishName: Brazil
    frenchName: Brésil (le)
    internetCctld: .br
    numericCode: 76
    portugueseName: Brasil
# components/examples/parameters.yaml
numericCode:
  summary: &numericCode-summary ISO 3166-1 numeric codes are three-digit (left padded with zero) country codes defined in ISO 3166-1.
  description: *numericCode-summary
  value: 76
# components/parameters/in/path.yaml
numericCode:
  name: numeric-code
  in: path
  description: ISO 3166-1 numeric codes are three-digit (left padded with zero) country codes defined in ISO 3166-1.
  required: true
  deprecated: false
  allowEmptyValue: false
  style: simple
  explode: false
  allowReserved: false
  schema:
    $ref: '../../schemas/countries.yaml#/numericCode'
  examples:
    pageIndex:
      #ref: '../../examples/parameters.yaml#/numericCode'
      $ref: '../components/examples/parameters.yaml#/numericCode' # Open-API generation issue.
# components/pathItems/v1/countries.yaml
getCountries:
  summary: &getCountries-summary Countries resource.
  description: *getCountries-summary
  get:
    tags:
      - countries
    summary: &getCountries-get-summary GET Countries.
    description: *getCountries-get-summary
    operationId: getCountries
    parameters:
      - $ref: '../../commons/parameters/in/query.yaml#/pageIndex'
      - $ref: '../../commons/parameters/in/query.yaml#/pageSize'
    responses:
      default:
        $ref: '../../commons/responses/2xx.yaml#/noContent'
      200:
        $ref: '../../responses/countries.yaml#/getCountries/ok'
    deprecated: false
getCountry:
  summary: &getCountry-summary Countries resource.
  description: *getCountry-summary
  get:
    tags:
      - countries
    summary: &getCountry-get-summary GET Country.
    description: *getCountry-get-summary
    operationId: getCountry
    parameters:
      - $ref: '../../parameters/in/path.yaml#/numericCode'
    responses:
      default:
        $ref: '../../commons/responses/4xx.yaml#/notFound'
      200:
        $ref: '../../responses/countries.yaml#/getCountry/ok'
    deprecated: false
# components/responses/countries.yaml
getCountries:
  ok:
    description: 200 OK.
    content:
      application/json:
        schema:
          $ref: '../schemas/countries.yaml#/countriesResponse'
        examples:
          ok:
            $ref: '../examples/countries.yaml#/countriesResponse'
getCountry:
  ok:
    description: 200 OK.
    content:
      application/json:
        schema:
          $ref: '../schemas/countries.yaml#/countryResponse'
        examples:
          ok:
            $ref: '../examples/countries.yaml#/countryResponse'
# components/schemas/countries.yaml
alpha2Code:
  nullable: false
  readOnly: true
  deprecated: false
  maxLength: 2
  minLength: 2
  type: string
  description:
alpha3Code:
  nullable: false
  readOnly: true
  deprecated: false
  maxLength: 3
  minLength: 3
  type: string
  description:
countriesResponse:
  nullable: false
  readOnly: true
  deprecated: false
  title: countriesResponse
  required:
    - countries
  type: object
  properties:
    countries:
      nullable: false
      readOnly: true
      deprecated: false
      title: countries
      maxItems: 100
      minItems: 1
      uniqueItems: true
      type: array
      items:
        $ref: '#/countryResponse'
countryResponse:
  nullable: false
  readOnly: true
  deprecated: false
  title: countryResponse
  required:
    - alpha2Code
    - alpha3Code
    - englishName
    - frenchName
    - numericCode
    - portugueseName
  type: object
  properties:
    alpha2Code:
      $ref: '#/alpha2Code'
    alpha3Code:
      $ref: '#/alpha3Code'
    englishName:
      $ref: '#/englishName'
    frenchName:
      $ref: '#/frenchName'
    internetCctld:
      $ref: '#/internetCctld'
    numericCode:
      $ref: '#/numericCode'
    portugueseName:
      $ref: '#/portugueseName'
  description:
englishName:
  nullable: false
  readOnly: true
  deprecated: false
  maxLength: 58
  minLength: 4
  type: string
  description:
frenchName:
  nullable: false
  readOnly: true
  deprecated: false
  maxLength: 56
  minLength: 4
  type: string
  description:
internetCctld:
  nullable: false
  readOnly: true
  deprecated: false
  maxLength: 7
  type: string
  description:
numericCode:
  nullable: false
  readOnly: true
  deprecated: false
  multipleOf: 1
  maximum: 894
  minimum: 4
  type: integer
  description:
  format: int32
portugueseName:
  nullable: false
  readOnly: true
  deprecated: false
  maxLength: 46
  minLength: 3
  type: string
  description:
# components/schemas/parameters.yaml
numericCode:
  nullable: false
  readOnly: true
  deprecated: false
  multipleOf: 1
  maximum: 2147483647
  minimum: 0
  type: integer
  description: ISO 3166-1 numeric codes are three-digit (left padded with zero) country codes defined in ISO 3166-1.
  format: int32
  default: 0
Command line used for generation
./mvnw --batch-mode --define skipTests --no-transfer-progress --threads 1C --update-snapshots clean package
Steps to reproduce
  1. Define all the specifications described in the files above
  2. Run the command described above to generate all files (canonical specification and Java classes)
Expected behavior

One endpoint with correct default ResponseEntity<CountriesResponseDTO> getCountries(Integer pageIndex, Integer pageSize) signature and another endpoint with correct default ResponseEntity<CountryResponseDTO> getCountry(Integer numericCode) signature:

// .../CountriesApiDelegate.java
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2023-06-05T22:29:29.944481946-03:00[America/Sao_Paulo]")
public interface CountriesApiDelegate {

    default Optional<NativeWebRequest> getRequest() {
        return Optional.empty();
    }

    /**
     * GET /v1/countries : GET Countries.
     * GET Countries.
     *
     * @param pageIndex Zero-based page index, must not be negative. (required)
     * @param pageSize The size of the page to be returned, must be greater than 0. (required)
     * @return 200 OK. (status code 200)
     *         or 204 No Content. (status code 200)
     * @see CountriesApi#getCountries
     */
    default ResponseEntity<CountriesResponseDTO> getCountries(Integer pageIndex,
        Integer pageSize) throws Exception {
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"countries\" : [ { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 } ] }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }
    /**
     * GET /v1/countries/{numeric-code} : GET Country.
     * GET Country.
     *
     * @param numericCode ISO 3166-1 numeric codes are three-digit (left padded with zero) country codes defined in ISO 3166-1. (required)
     * @return 200 OK. (status code 200)
     *         or 404 Not Found. (status code 200)
     * @see CountriesApi#getCountry
     */
    default ResponseEntity<CountryResponseDTO> getCountry(Integer numericCode) throws Exception {
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }

}
Observed behavior

One endpoint with correct default ResponseEntity<CountriesResponseDTO> getCountries(Integer pageIndex, Integer pageSize) signature and another endpoint with wrong default ResponseEntity<CountriesResponseDTO> getCountry(Integer numericCode) signature:

// .../CountriesApiDelegate.java
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2023-06-05T22:29:29.944481946-03:00[America/Sao_Paulo]")
public interface CountriesApiDelegate {

    default Optional<NativeWebRequest> getRequest() {
        return Optional.empty();
    }

    /**
     * GET /v1/countries : GET Countries.
     * GET Countries.
     *
     * @param pageIndex Zero-based page index, must not be negative. (required)
     * @param pageSize The size of the page to be returned, must be greater than 0. (required)
     * @return 200 OK. (status code 200)
     *         or 204 No Content. (status code 200)
     * @see CountriesApi#getCountries
     */
    default ResponseEntity<CountriesResponseDTO> getCountries(Integer pageIndex,
        Integer pageSize) throws Exception {
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"countries\" : [ { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 } ] }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }

    /**
     * GET /v1/countries/{numeric-code} : GET Country.
     * GET Country.
     *
     * @param numericCode ISO 3166-1 numeric codes are three-digit (left padded with zero) country codes defined in ISO 3166-1. (required)
     * @return 200 OK. (status code 200)
     *         or 404 Not Found. (status code 200)
     * @see CountriesApi#getCountry
     */
    default ResponseEntity<CountriesResponseDTO> getCountry(Integer numericCode) throws Exception {//BUG: Here should be ResponseEntity<CountryResponseDTO>
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"countries\" : [ { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 }, { \"alpha3Code\" : \"alpha3Code\", \"englishName\" : \"englishName\", \"alpha2Code\" : \"alpha2Code\", \"internetCctld\" : \"internetCctld\", \"frenchName\" : \"frenchName\", \"portugueseName\" : \"portugueseName\", \"numericCode\" : 75 } ] }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }

}
Aditional observation

If only one of the endpoints is specified the expected behavior is the desired one. I.e:

# specification.yaml:
# ...
paths:
  /v1/countries:
    #
    # Escaped forward-slash is necessary when using JSON references.
    # https://spec.openapis.org/oas/latest.html#operationref-examples
    $ref: 'components/pathItems/v1/countries.yaml#/getCountries'
  #/v1/countries/{numeric-code}:
    #
    # Escaped forward-slash is necessary when using JSON references.
    # https://spec.openapis.org/oas/latest.html#operationref-examples
    #$ref: 'components/pathItems/v1/countries.yaml#/getCountry'
tags:
  - name: countries
    description: Countries resource related.

or:

# specification.yaml:
paths:
  #/v1/countries:
    #
    # Escaped forward-slash is necessary when using JSON references.
    # https://spec.openapis.org/oas/latest.html#operationref-examples
    #$ref: 'components/pathItems/v1/countries.yaml#/getCountries'
  /v1/countries/{numeric-code}:
    #
    # Escaped forward-slash is necessary when using JSON references.
    # https://spec.openapis.org/oas/latest.html#operationref-examples
    $ref: 'components/pathItems/v1/countries.yaml#/getCountry'
tags:
  - name: countries
    description: Countries resource related.

NOTE!: The same behavior of Java classes is also observed in the canonical specification.

Related issues/PRs

I looked for issues and PRs related to this behavior but found nothing.

Suggest a fix/enhancement

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the supplied OpenAPI specification and the Java Spring generator invoked through org.openapitools:openapi-generator-maven-plugin:6.0.0, using the linked reproduction repository if needed. Run generation for the countries tag and compare the generated response types for the list and single-item endpoints; done means they reflect their distinct schemas rather than sharing one type.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, openapi, spring
Domain
api, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.