Skip to content

Conversation

YoungHoney
Copy link

@YoungHoney YoungHoney commented Aug 26, 2025

Motivation

This pull request implements support for Jackson's polymorphism annotations (@JsonTypeInfo, @JsonSubTypes) in DocService, as requested in the community (issue #6313). Currently, DocService does not correctly generate documentation for annotated services that use inheritance in their DTOs, leading to incomplete specifications. This change adds a new DescriptiveTypeInfoProvider to resolve these polymorphic types and generate accurate JSON Schemas.

However, this feature has uncovered significant and complex build stability issues when running a full parallel build (./gradlew clean build --parallel). This PR serves as both the implementation of the feature and a concrete test case for discussing the build instability it triggers.

Modifications

  • Added JacksonPolymorphismTypeInfoProvider: A new provider that uses pure Java reflection to safely inspect @JsonTypeInfo and @JsonSubTypes annotations. It is registered via Java's SPI mechanism to be discoverable by DocService.
  • Added DiscriminatorInfo: A new data class to hold polymorphism metadata extracted from the annotations.
  • Consolidated Type Utilities: General-purpose type conversion logic (e.g., toTypeSignature) was moved from a separate DocServiceTypeUtil into AnnotatedDocServicePlugin for better cohesion.
  • Updated StructInfo: Modified to include oneOf and discriminator fields to carry polymorphism information.
  • Updated JsonSchemaGenerator: The generator now recognizes the new fields in StructInfo and correctly produces JSON Schema with oneOf and discriminator properties.
  • Added PolymorphismDocServiceExample: A new example service to demonstrate and manually verify the feature.

Result

  • DocService can now correctly generate documentation for annotated services that use polymorphic types with Jackson. The resulting JSON Schema will contain the appropriate oneOf and discriminator fields.
  • Known Issue: This change is known to trigger build instability in the project's CI environment. A detailed summary of the investigation is provided here : Request Guidance on Build Issues in my feature branch #6369

Example usage

also, you can try this at PolymorphismDocServiceExample

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "species")
@JsonSubTypes({
    @JsonSubTypes.Type(value = Dog.class, name = "dog"),
    @JsonSubTypes.Type(value = Cat.class, name = "cat")
})
interface Animal {
    // ...
}
"structs" : [ {
    "name" : "example.armeria.server.animal.PolymorphismDocServiceExample$Animal",
    "fields" : [ ],
    "descriptionInfo" : {
      "docString" : "",
      "markup" : "NONE"
    },
    "oneOf" : [ "example.armeria.server.animal.PolymorphismDocServiceExample$Dog", "example.armeria.server.animal.PolymorphismDocServiceExample$Cat" ],
    "discriminator" : {
      "propertyName" : "species",
      "mapping" : {
        "dog" : "#/definitions/example.armeria.server.animal.PolymorphismDocServiceExample$Dog",
        "cat" : "#/definitions/example.armeria.server.animal.PolymorphismDocServiceExample$Cat"
      }
    }

@CLAassistant
Copy link

CLAassistant commented Aug 26, 2025

CLA assistant check
All committers have signed the CLA.

@YoungHoney YoungHoney marked this pull request as ready for review August 26, 2025 13:35
@YoungHoney YoungHoney marked this pull request as draft August 26, 2025 13:35
@minwoox
Copy link
Contributor

minwoox commented Aug 28, 2025

However, this feature has uncovered significant and complex build stability issues when running a full parallel build (./gradlew clean build --parallel).

I will investigate it.
It seems like your changes aren't related to the failure, so please feel free to change the draft status when you are ready.

YoungHoney added a commit to YoungHoney/armeria that referenced this pull request Sep 1, 2025
Adds a new `JacksonPolymorphismTypeInfoProvider` to generate correct
JSON Schemas with `oneOf` and `discriminator` for polymorphic types
annotated with `@JsonTypeInfo` and `@JsonSubTypes`.
@YoungHoney YoungHoney marked this pull request as ready for review September 1, 2025 13:18
YoungHoney added a commit to YoungHoney/armeria that referenced this pull request Sep 2, 2025
Adds a new `JacksonPolymorphismTypeInfoProvider` to generate correct
JSON Schemas with `oneOf` and `discriminator` for polymorphic types
annotated with `@JsonTypeInfo` and `@JsonSubTypes`.
Copy link

codecov bot commented Sep 3, 2025

Codecov Report

❌ Patch coverage is 82.48588% with 62 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.11%. Comparing base (8150425) to head (4823d7b).
⚠️ Report is 182 commits behind head on main.

Files with missing lines Patch % Lines
...ecorp/armeria/server/docs/JsonSchemaGenerator.java 88.38% 11 Missing and 12 partials ⚠️
...a/com/linecorp/armeria/server/docs/StructInfo.java 42.30% 12 Missing and 3 partials ⚠️
...inecorp/armeria/server/docs/DiscriminatorInfo.java 43.75% 9 Missing ⚠️
...meria/internal/server/docs/DocServiceTypeUtil.java 89.47% 3 Missing and 5 partials ⚠️
...rver/docs/JacksonPolymorphismTypeInfoProvider.java 81.57% 3 Missing and 4 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #6370      +/-   ##
============================================
- Coverage     74.46%   74.11%   -0.35%     
- Complexity    22234    23016     +782     
============================================
  Files          1963     2063     +100     
  Lines         82437    86197    +3760     
  Branches      10764    11334     +570     
============================================
+ Hits          61385    63889    +2504     
- Misses        15918    16888     +970     
- Partials       5134     5420     +286     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

logger.info("JSON Specification: http://127.0.0.1:8080/docs/specification.json");
}

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "species")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the subtypes are missing species, so deserialization actually doesn't work.
Also, this is not a tutorial, but just an example.
So, what do you think of making this class a test case (PolymorphismDocServiceTest) like we did for AnnotatedDocServiceTest?
We can use TestUtil.isDocServiceDemoMode() to see how it works on a browser.

Could you also use English for comments instead of Korean?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the detailed and helpful feedback! I've addressed all of your suggestions based on our discussion.

  1. Converted Example to a Test Case: As you suggested, the PolymorphismDocServiceExample.java has been removed entirely.

  2. Created a Comprehensive Test Suite: I've added a new integration test, PolymorphismDocServiceTest.java. This new test suite now covers:

    • Correct documentation generation for polymorphic types (oneOf, discriminator).
    • deserialization of various polymorphic objects (Dog, Cat).
    • handling of edge cases, including misconfigured @JsonSubTypes({}) annotations and other types like Optional and Map.
    • I also confirmed that the TestUtil.isDocServiceDemoMode() works correctly for manual UI verification.
  3. Used English for Comments: I've reviewed all new and modified files to ensure all comments are in English now.

Copy link
Contributor

@minwoox minwoox left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few more suggestions. 😎
Please, keep up the great work. 👍

if (structInfo != null) {
visited.put(firstParam.typeSignature(), "#");
generateProperties(structInfo.fields(), visited, "#", root);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we warn if structInfo == null?

Copy link
Author

@YoungHoney YoungHoney Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the code to handle the structInfo == null case as you suggested.

        if (structInfo != null) {
            visited.put(firstParam.typeSignature(), "#");
            generateProperties(structInfo.fields(), visited, "#", root);
        } else {
            logger.warn("Could not find root struct for signature: {}",
                        firstParam.typeSignature().signature());
            root.put("additionalProperties", true);
        }

Now, if a StructInfo for a gRPC request(may be) is not found:

  1. A warning will be logged with the missing TypeSignature.
  2. The generated schema for that method will fall back to using "additionalProperties": true, effectively treating it as a generic object.

fieldNode.set("additionalProperties", additionalPropertiesNode.get(""));
}
break;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happends if the type is other types such as CONTAINER?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've analyzed this and improved the generator's logic to handle these cases more robustly by “unwrapping” them to their inner types.

Implemented Solution

I've updated both getSchemaType and generateFieldSchema in JsonSchemaGenerator to recursively process OPTIONAL and CONTAINER types.

Code Snippet from JsonSchemaGenerator.java:

// In getSchemaType()
case OPTIONAL:
case CONTAINER: {
    // Unwrap and return the inner type's schema type
    final TypeSignature inner =
            ((ContainerTypeSignature) typeSignature).typeParameters().get(0);
    return getSchemaType(inner);
}

// In generateFieldSchema()
if (typeSignature.type() == TypeSignatureType.OPTIONAL ||
    typeSignature.type() == TypeSignatureType.CONTAINER) {
    final TypeSignature inner =
            ((ContainerTypeSignature) typeSignature).typeParameters().get(0);
    final ObjectNode innerNode = generateFieldSchema(FieldInfo.of("", inner));
    fieldNode.setAll(innerNode);
    return fieldNode;
}

Example and Results

To verify this, I've used the following service methods in the test code PolymorphismDocServiceTest :

// Container example
    static final class ApiResponse<T> {
        @JsonProperty
        private final int status;
        @JsonProperty
        private final T data;

        ApiResponse(int status, T data) {
            this.status = status;
            this.data = data;
        }
    }


// OPTIONAL type in parameter
@Post("/animal/optional")
public String processOptionalAnimal(Optional<Animal> animal) { ... }

// CONTAINER type in return value
@Post("/dummy/api_response")
public ApiResponse<Toy> getExampleResponse() { ... }

1. Result in specification.json:
The DocService correctly unwraps Optional<Animal> to its inner type Animal and marks it as OPTIONAL. For ApiResponse<Toy>, it correctly identifies the full generic type signature.

// For processOptionalAnimal
 {
      "id" : "...AnimalService/processOptionalAnimal/POST",
      "name" : "processOptionalAnimal",
      "returnTypeSignature" : "string",
      "parameters" : [ {
        "name" : "animal",
        "location" : "UNSPECIFIED",
        "requirement" : "OPTIONAL",
        "typeSignature" : "...$Animal",
        "descriptionInfo" : {
          "docString" : "",
          "markup" : "NONE"
        }
      }

// For getExampleResponse
{
...
  "returnTypeSignature": "ApiResponse<...PolymorphismDocServiceTest$Toy>"
...
}

2. Result in schemas.json:

// part of Schema for processOptionalAnimal
{
  "$id": ".../processOptionalAnimal/POST",
  "properties": {
    "animal": {
      "$ref": "#/definitions/...PolymorphismDocServiceTest$Animal"
    }
  }
...
}

//part of Schema for getExampleResponse
{
  "$id" : ".../getExampleResponse/POST",
  "title" : "getExampleResponse",
  "additionalProperties" : false,
  "type" : "object",
...
}

YoungHoney added a commit to YoungHoney/armeria that referenced this pull request Sep 7, 2025
Adds a new `JacksonPolymorphismTypeInfoProvider` to generate correct
JSON Schemas with `oneOf` and `discriminator` for polymorphic types
annotated with `@JsonTypeInfo` and `@JsonSubTypes`.
YoungHoney added a commit to YoungHoney/armeria that referenced this pull request Sep 12, 2025
@minwoox minwoox added this to the 1.34.0 milestone Sep 19, 2025
@minwoox
Copy link
Contributor

minwoox commented Sep 19, 2025

I found a couple of issues in the generated JSON schema:

  • There are many duplicate definitions because each method has its own ID and definitions.
    [
      {
        "$id": "...",
        "definitions": { ... } // duplicate definitions
      },
      {
        "$id": "...",
        "definitions": { ... } // duplicate definitions
      }
    ]
    
  • The "Cat" and "Dog" definitions are missing the species property, which is causing Autocomplete to fail.

To address this, I propose the following:

  • Use a root object with "$defs/methods" and "$defs/models" to put all methods and structs a single time.
    {
      "$schema": "https://json-schema.org/draft/2020-12/schema",
      "$id": "...",
      "title": "...",
    
      "$defs": {
        "methods": {
          "processAnimal": {
            "$id": "com.linecorp.armeria.server.docs.PolymorphismDocServiceTest$AnimalService/processAnimal/POST",
            "title": "processAnimal",
            "type": "object",
            "properties": {
              "animal": {
                "$ref": "#/$defs/models/Animal" 
              }
            },
            "required": [ "animal" ]
          },
          "processZoo": {
            ...
          }
        },
    
        "models": {
          "Animal": {
            "type": "object",
            "oneOf": [
              { "$ref": "#/$defs/models/Dog" },
              { "$ref": "#/$defs/models/Cat" }
            ],
            "discriminator": {
              "propertyName": "species",
              "mapping": {
                "dog": "#/$defs/models/Dog",
                "cat": "#/$defs/models/Cat"
              }
            }
          },
          "Cat": {
            "type": "object",
            "properties": {
              "species": { "type": "string" },
              "name": { "type": "string" },
              "likesTuna": { "type": "boolean" },
              "scratchPost": { "$ref": "#/$defs/models/Toy" },
              "vetRecord": { "$ref": "#/$defs/models/VetRecord" }
            },
            "required": [ "name", "likesTuna", "scratchPost", "vetRecord" ]
          },
          "Dog": {
            ...
          },
          ...
        }
      }
    }
    
  • Update RequestBody.tsx to align with the new schema format. (I might help you if you are not familiar with the frontend)

Please, let me know your opinion. 🙇

@YoungHoney
Copy link
Author

Use a root object with "$defs/methods" and "$defs/models" to put all methods and structs a single time.

* It's worth noting that "definitions" is deprecated, as mentioned in the JSON Schema draft specification

Thank you for your Review !

I've changed JsonSchemaGenerator based on your feedback, and I agree that the new structure is better than duplicated definitions . Here is the new output :

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "com.....$AnimalService",
  "title": "com....$AnimalService",
  "$defs": {
    "models": {
      "com....$Animal": {
        "type": "object",
        "title": "com....$Animal",
        "oneOf": [
          {
            "$ref": "#/$defs/models/com...$Dog"
          },
          {
            "$ref": "#/$defs/models/com.l...$Cat"
          }
        ],
        "discriminator": {
          "propertyName": "species",
          "mapping": {
            "dog": "#/$defs/models/com....$Dog",
            "cat": "#/$defs/models/com.....$Cat"
          }
        }
      },
      "com....$Cat": {
        "type": "object",
        "title": "com....$Cat",
        "properties": {
          "species": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "likesTuna": {
            "type": "boolean"
          },
          "scratchPost": {
            "$ref": "#/$defs/models/com....$Toy"
          },
          "vetRecord": {
            "$ref": "#/$defs/models/com....$VetRecord"
          }
        },
        "required": [ "name", "likesTuna", "scratchPost", "vetRecord", "species" ] // Should "species" be first? 
      },
      "...": "..."
    }, //models
    "methods": {
      "processAnimal": {
        "$id": "com....$AnimalService/processAnimal/POST",
        "title": "processAnimal",
        "additionalProperties": false,
        "type": "object",
        "properties": {
          "animal": {
            "$ref": "#/$defs/models/com....$Animal"
          }
        },
        "required": [ "animal" ]
      },
      "...": "..."
    } //methods
  }
}

However, this change caused the existing GrpcDocServiceJsonSchemaTest to fail. This brings me to my main question:

Should I maintain backward compatibility for the gRPC schema, or should I update it to use the new, unified structure as well?

Also, as you mentioned, I'm not familiar with the frontend, so I would really appreciate your help with the RequestBody.tsx changes when the time comes.

@minwoox
Copy link
Contributor

minwoox commented Sep 23, 2025

Should I maintain backward compatibility for the gRPC schema, or should I update it to use the new, unified structure as well?

I think we don't have to worry about the compatibility because the browser will fetch the new JSON schema and use it for the autocompletion.

Also, as you mentioned, I'm not familiar with the frontend, so I would really appreciate your help with the RequestBody.tsx changes when the time comes.

I'm happy to help you. 😉 Will push a commit after the server-side changes are done.

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

Successfully merging this pull request may close these issues.

3 participants