Skip to content

[Bug]: Discriminator mapping of a oneOf nested in array items gets a null key and is no longer updated after external ref resolution (regression since 2.1.36) #2406

Open

Description

@wyhasany

Description

When a oneOf with a discriminator.mapping is nested inside another schema (for example as the items of an array property) and its members are external relative $refs, resolving the document (ParseOptions.setResolve(true)) leaves the discriminator mapping in a broken state:

  • an extra entry with a null key is added to mapping, pointing at the root-relative path of one of the members (e.g. null -> ./schemas/TypeB.yaml);
  • the real mapping entries are no longer rewritten to #/components/schemas/... like the oneOf $refs are, and a stray message Unable to load RELATIVE ref: ./TypeA.yaml path: <root dir> is reported.

The cause is in ExternalRefProcessor.processSchema (v2.1.48, lines 231-257):

final Map<String, String> refMap = ... mapping.entrySet().stream()
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));   // discriminator value -> ref
Map<String, Schema> refCache = ... oneOf/anyOf members keyed by their ORIGINAL $ref ...
processProperties(composed.getOneOf(), file);                                // rewrites the members' $ref
...
refCache.entrySet().stream()
        .filter(e -> !e.getKey().equals(e.getValue().get$ref()))
        .forEach(entry -> {
            String newRef = entry.getValue().get$ref();
            property.getDiscriminator().getMapping().put(refMap.get(entry.getKey()), newRef);
        });

refMap.get(entry.getKey()) looks up a $ref string in a map keyed by discriminator values, so it always returns null, and mapping.put(null, newRef) is executed for every member whose $ref was rewritten. The code was introduced by #2087 with the map inverted (toMap(Map.Entry::getValue, Map.Entry::getKey), i.e. ref -> discriminator value), which worked when the mapping value is spelled exactly like the member $ref, but threw Duplicate key when several discriminator values map to the same schema (#2222). #2249 fixed #2222 by swapping key and value, which made the lookup never match. The test added in #2249 (Issue2222Test) only uses local #/components/schemas/... refs, so the filter(... !equals ...) branch is never reached by it.

The spec is OpenAPI 3.0.3; the parser does not throw, it returns an unexpected result. Downstream, openapi-generator (which bundles swagger-parser 2.1.36+ since 7.18.0) turns the null entry into @JsonSubTypes.Type(value = ERRORUNKNOWN.class, name = ""), i.e. non-compiling Java.

Affected Version

2.1.48 (latest) and master.

Earliest version the bug appears in (if known): 2.1.36 (first release containing #2249, merged 2025-11-20). 2.1.35 produces the expected result for the example below.

Steps to Reproduce

Four files:

openapi.yaml

openapi: 3.0.3
info:
  title: Nested discriminator repro
  version: 1.0.0
paths:
  /search:
    post:
      operationId: search
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: './schemas/SearchRequest.yaml'
      responses:
        '204':
          description: OK

schemas/SearchRequest.yaml

type: object
properties:
  query:
    type: array
    items:
      oneOf:
        - $ref: './TypeA.yaml'
        - $ref: './TypeB.yaml'
      discriminator:
        propertyName: kind
        mapping:
          a: './TypeA.yaml'
          b: './TypeB.yaml'

schemas/TypeA.yaml

type: object
required: [kind]
properties:
  kind:
    type: string
  valueA:
    type: string

schemas/TypeB.yaml

type: object
required: [kind]
properties:
  kind:
    type: string
  valueB:
    type: integer

Java (dependency io.swagger.parser.v3:swagger-parser):

import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.parser.OpenAPIV3Parser;
import io.swagger.v3.parser.core.models.ParseOptions;
import io.swagger.v3.parser.core.models.SwaggerParseResult;

public class Repro {
    public static void main(String[] args) {
        ParseOptions options = new ParseOptions();
        options.setResolve(true);
        SwaggerParseResult result = new OpenAPIV3Parser().readLocation("openapi.yaml", null, options);
        result.getMessages().forEach(m -> System.out.println("message: " + m));

        Schema<?> request = result.getOpenAPI().getComponents().getSchemas().get("SearchRequest");
        Schema<?> items = ((Schema<?>) request.getProperties().get("query")).getItems();
        System.out.println("oneOf refs: " + items.getOneOf().stream().map(Schema::get$ref).toList());
        items.getDiscriminator().getMapping()
             .forEach((key, ref) -> System.out.println("mapping: " + key + " -> " + ref));
    }
}
  1. Put the four YAML files next to each other as shown (openapi.yaml, schemas/...).
  2. Run Repro with swagger-parser 2.1.48.
  3. Run the same with swagger-parser 2.1.35 to compare.

Expected Behavior

Output of 2.1.35 (also what the oneOf members get):

oneOf refs: [#/components/schemas/TypeA, #/components/schemas/TypeB]
mapping: a -> #/components/schemas/TypeA
mapping: b -> #/components/schemas/TypeB

Actual Behavior

Output of 2.1.48:

message: Unable to load RELATIVE ref: ./TypeA.yaml path: /.../spec
oneOf refs: [#/components/schemas/TypeA, #/components/schemas/TypeB]
mapping: a -> ./TypeA.yaml
mapping: b -> ./TypeB.yaml
mapping: null -> ./schemas/TypeB.yaml

The oneOf $refs are rewritten, the mapping values are not, and a null key is inserted (only one, because every iteration overwrites the same null key; which member "wins" depends on HashMap iteration order).

Logs / Stack Traces

No exception in swagger-parser itself. Any later Json.pretty(schema) of the composed schema fails with:

com.fasterxml.jackson.databind.JsonMappingException: Null key for a Map not allowed in JSON (use a converting NullKeySerializer?)
(through reference chain: io.swagger.v3.oas.models.media.ComposedSchema["discriminator"]->io.swagger.v3.oas.models.media.Discriminator["mapping"]->java.util.LinkedHashMap["null"])

Environment

  • Java version: OpenJDK 25.0.3 (Temurin)
  • Build tool: Maven 3.10.0
  • OS: macOS 26

Additional Context

Suggested fix: index the mapping by ref value -> list of discriminator values, so that several values can map to the same schema (#2222) and the lookup by the member's original $ref works again; never put with a null key.

Map<String, List<String>> keysByRef = mapping.entrySet().stream()
        .collect(Collectors.groupingBy(Map.Entry::getValue,
                 Collectors.mapping(Map.Entry::getKey, Collectors.toList())));
...
refCache.entrySet().stream()
        .filter(e -> !e.getKey().equals(e.getValue().get$ref()))
        .forEach(entry -> {
            String newRef = entry.getValue().get$ref();
            keysByRef.getOrDefault(entry.getKey(), List.of())
                     .forEach(key -> property.getDiscriminator().getMapping().put(key, newRef));
        });

A regression test should use external file refs in a nested oneOf (like the files above); Issue2222Test cannot catch this because its refs are local.

Checklist

  • I have searched the existing issues and this is not a duplicate.
  • I have provided sufficient information for maintainers to reproduce the issue.

Activity

  1. wyhasany commented on Oct 13, 2026

    @wyhasany
    Author

    Runnable Maven project with the files from the description (run mvn -q -Dswagger-parser.version=2.1.48 compile exec:java -Dexec.args=spec/openapi.yaml, then the same with 2.1.35 to compare):

    swagger-parser-nested-discriminator-repro.zip

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions