Skip to content

Conversation

@tvahrst
Copy link
Contributor

@tvahrst tvahrst commented Nov 2, 2025

I introduced a new Property springwolf.docket.payload-schema-format which is an enum and supports currently

  • asyncapi_v3 (the default)
  • openapi_v3
  • openapi_v3_1

With the default-setting (asyncapi_v3), SpringWolf behaves as before.

Enabling openapi_v3 or _v3_1 leads to a AsyncApi where the payload schemas (not the header schemas!) are formatted with openapi v3/v3.1 format. This is possible and allowed because the AsyncApi Spec supports the definition of alternative schema formats.

The openapi v3/v3.1 schema formats are especially helpful when it comes to polymorphic schemas with custom discrimiantor mappings (which are not possible to describe with default asyncapi schema format).

@netlify
Copy link

netlify bot commented Nov 2, 2025

Deploy Preview for springwolf-ui canceled.

Name Link
🔨 Latest commit 014bcb9
🔍 Latest deploy log https://app.netlify.com/projects/springwolf-ui/deploys/691839828b53e0000847dbc5

}

String type = schema.getType();
// schema may be an openapi v3 or v3.1 schema. While v3 uses an simple 'type' field, v3.1 supports a set of
Copy link
Contributor Author

Choose a reason for hiding this comment

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

building examples from types happend to be a little bit 'tricky' because openapi v3 and v3.1 changed the field for the type(s). I moved the "type-lookup" in an extra method 'getTypeForExampleValue'.

public ExtractedSchemas resolveSchema(Type type, String contentType, SchemaFormat schemaFormat) {
String actualContentType =
StringUtils.isBlank(contentType) ? properties.getDocket().getDefaultContentType() : contentType;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is one of the main implementation point: Decision, which ModelConverts to use. AsyncApi is currently based on openapi-V3

* @return the resulting AsnycApi SchemaObject
*/
public SchemaObject mapSchema(Schema value) {
public ComponentSchema mapSchema(Schema swaggerSchema, SchemaFormat schemaFormat) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the point where the generated Swagger-schema (from swagger ModelConverters) is mapped to the target schema format. For openapi v3/v3.1, no transformation is necessary, only wrapping the schema into an MultiFormatschema.

For asyncapi schemaformat (the default), the existing mapping logic is used (mapSwaggerSchemaToAsyncApiSchema

@sam0r040 sam0r040 force-pushed the componentschema-refactoring branch from d8d540e to ad80cfa Compare November 7, 2025 15:35
Copy link
Member

@timonback timonback left a comment

Choose a reason for hiding this comment

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

@sam0r040 and I reviewed the PR. We enjoyed the PR very much. The PR is surprisingly sleek and it shows that you have understood and found the best place for integrating this feature.

We added some comments, suggestions. If there are questions, let us know. Some comments probably also reflect the Springwolf code style.
FYI: We committed & pushed a couple smaller changes (i.e. avoiding breaking API change)

*/
public SchemaObject mapSchema(Schema value) {
public ComponentSchema mapSchema(Schema swaggerSchema, SchemaFormat schemaFormat) {
return switch (schemaFormat) {
Copy link
Member

Choose a reason for hiding this comment

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

Note: the current mix up of openapi 3.0 and 3.1 schemas still needs to be addressed in the mapper (other open issue, out of scope for this pr)


import static org.assertj.core.api.Assertions.assertThat;

public class AsyncApiDocumentWithOpenApiV31SchemaFormatIntegrationTest {
Copy link
Member

Choose a reason for hiding this comment

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

review: do we need all the tests on this level?
I wonder if one happy path test case is sufficient. Feels like a lot of test duplication

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There are quite some differences between the default test (AsyncApiDocumentIntegrationTest) and the corresponding tests for openapi formats. (especially for polymorphic models and enums).

The differences between tests for openapi 3.0 and 3.1 are marginal. I would therefore suggest keeping a test class for OpenAPI 3.0 and creating only one simple test with a happy-path scenario for OpenAPI 3.1.

Copy link
Member

Choose a reason for hiding this comment

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

I see, still it feels like we are testing an external library.

Let's do the following: Include the updated tests as you suggest.
At the same time, #1509 is an alternative way to run these core integration tests (I agree that currently, there are no easy to use integration tests in core).

After this PR is merged, we / I re-visit this.

@@ -0,0 +1,44 @@
// SPDX-License-Identifier: Apache-2.0
Copy link
Member

Choose a reason for hiding this comment

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

I am not sure whether the jms example is the best place for these tests, as it has not a lot of dtos.
So far, we have mostly used the kafka example.

Similiarly, we can use the new StandaloneApplication test, which executes faster because it does not need a full spring boot context. Environment variables (for the schema format) can be injected using the builder

Copy link
Member

Choose a reason for hiding this comment

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

One second thought, we believe that we can skip this test.

The examples are particular show cases of the main functionality - not all types of configurations possible.
Second (out of scope), we can benefit from integration tests in core with the asyncapi.json file checked in (currently tested manual in the two classes that you duplicated).

Also, the actual mapping is done by swaggers ModelConverter (a library that we do not control). Therefore, we see it not as much in our test scope.

public class SwaggerSchemaUtil {

public Map<String, SchemaObject> mapSchemasMap(Map<String, Schema> schemaMap) {
public Map<String, ComponentSchema> mapSchemasMap(Map<String, Schema> schemaMap, SchemaFormat schemaFormat) {
Copy link
Member

Choose a reason for hiding this comment

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

Have you considered using the schemaFormat as a global setting and then injecting the SpringwolfConfigProperties?

Over the time the SwaggerSchemaUtil has grown and we could imagine that the SpringwolfConfigProperties become a constructor argument when rename to i.e. SwaggerSchemaMapper. (It does not feel like an util anymore)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think, this is not possible, because 'mapSchemaAsMap' is not only used for payload schemas but also for header schemas which are always asyncapi-schemas. See
SwaggerSchemaService#postProcessSimpleSchema .

Copy link
Member

Choose a reason for hiding this comment

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

General thought unrelated to the changes in the PR: Do the headers feel like an edge case that needs to be handled? Can we handle the headers the same way as the normal payloads?

-> Must the headers be asyncapi-schema or can we use the same schemaFormat as well?

Alternative: There are two methods in the SwaggerSchemaUtil (or the renamed class).


This can also be a refactoring step outside of this PR. I might have to try it out to see what feels good.

General thought: The schemaFormat configuration property is constant. Why pass it around, if we can get it through the properties.

private final ModelConverters converter = ModelConverters.getInstance();
private final ModelConverters converter_openapi30 = ModelConverters.getInstance(false);
private final ModelConverters converter_openapi31 = ModelConverters.getInstance(true);

Copy link
Member

Choose a reason for hiding this comment

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

Have you considered moving these two fields into a ModelConvertersProvider?

We stumpled upon this, because the related tests verify that the correct model provider switch case is used, but not that the actual mapping work as expected. Still, actual mapping is probably best used in an integration test anyway.

Copy link
Contributor Author

@tvahrst tvahrst Nov 9, 2025

Choose a reason for hiding this comment

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

Yes. I added a ModelConvertersProvider bean. I think you prefer lombok constructor annotations instead of implemented constructors. So I add the spring InitializingBean interface and moved the necessary initialization of the modelconvertes to the afterPropertiesSet method. (Alternativly this initialization could happen inside the constructor)

Copy link
Member

Choose a reason for hiding this comment

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

Yes, that looks good.

Since the payloadSchemaFormat value cannot be changed, the constructor initializer can select (switch) the correct implementation so that public ModelConverters getModelConverterForSchemaFormat(SchemaFormat schemaFormat) { comes a simple public ModelConverters getModelConverter() { return modelConverter;}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same problem as with SwaggerSchemaUtil: ModelConvertersProvider is also used for header schemas so the return value cannot be stable as long as we did not decide to handle headers the same way as payload schemas.

Additionally I moved the initialization vom afterPropertiesSet into the constructor because implementing Springs InitializingBean did not work in junit-tests. I had to change a lot of junit-tests adding the afterPropertiesSet() invocation to avoid NPEs. Those many changes to save the lombok Annotation did not feel good ;-).

/**
* Enumeration defining the supported payload schema formats, for use in SpringwolfConfigProperties.
*/
public enum PayloadSchemaFormat {
Copy link
Member

Choose a reason for hiding this comment

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

We are not sure if it a good idea to directly map the payload schema into the SchemaFormat enum, which has more types.
It couples the values of this enum to the other other and leaves less room for configuration changes.

Similarly, it feels odd in the switch expression to have an else branch, that can never be reached - at this moment in time, but is required technically for the compiler.

Suggestion:
ASYNCAPI_V3, OPENAPI_V3, OPENAPI_V3_1;

And the PayloadSchemaFormat is used in the code, including the switch expression

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure if I understood that correctly. Do you mean we should remove the SchemaFormat reference in PayloadSchemaFormat? like this:

public enum PayloadSchemaFormat {
    ASYNCAPI_V3, OPENAPI_V3, OPENAPI_V3_1;
}

As a result, the necessary mapping from PayloadSchemaFormat to the existing SchemaFormat enumeration has to be implemented somewhere in DefaultComponentService as switch block, like this:

private SchemaFormat getSchemaFormatForPayloadSchemaFormat(PayloadSchemaFormat payloadSchemaFormat) {
        return switch (payloadSchemaFormat){
            case ASYNCAPI_V3 -> SchemaFormat.ASYNCAPI_V3;
            case OPENAPI_V3 -> SchemaFormat.OPENAPI_V3;
            case OPENAPI_V3_1 -> SchemaFormat.OPENAPI_V3_1;
        };
    }

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, it is something in between.

From our point of view, the mapping from PayloadSchemaFormat to SchemaFormat happened to early. It is probably fine to have the getSchemaFormatForPayloadSchemaFormat method on the enum.

However, most of the code should use (and pass around) the configuration property (PayloadSchemaFormat). Which schema should be used depends on the configuration value (including the enum value) and not on the SchemaFormat enum (which might change, has more configuration options, etc).
Still, the SchemaFormat is used when using the schema format string, but it does not control program flow (switch)

Copy link
Member

Choose a reason for hiding this comment

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

I pushed a commit to address/resolve it

String url = "/springwolf/docs";
String actual = restTemplate.getForObject(url, String.class);
String actualPatched = actual.replace("localhost:61616", "activemq:61616");
Files.writeString(Path.of("src", "test", "resources", "asyncapi.actual.json"), actualPatched);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Files.writeString(Path.of("src", "test", "resources", "asyncapi.actual.json"), actualPatched);
Files.writeString(Path.of("src", "test", "resources", "asyncapi.openapiv31.actual.json"), actualPatched);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes. I changed the filenames accordingly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes. I changed the filenames accordingly.

@sam0r040 sam0r040 force-pushed the componentschema-refactoring branch from 23e6862 to 98a085c Compare November 7, 2025 17:12
…chema, change back public api method from registerSimpleSchema to registerSchema to prevent breaking change, rename SwaggerSchemaService.postProcessSimpleSchema to postProcessSchemaWithoutRef to clarify the intention of the method

Co-authored-by: Timon Back <timonback@users.noreply.github.com>
@sam0r040 sam0r040 force-pushed the componentschema-refactoring branch from 98a085c to f3b6093 Compare November 7, 2025 17:25
Copy link
Member

@timonback timonback left a comment

Choose a reason for hiding this comment

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

I started looking at the output openapiv3 file it is looks quite similar. Besides that the json-schema extension is not really supported, the rest looks fine or related to the difference between openapi 3.0 vs 3.1

Testing:
-> remove runtimeOnly project(":springwolf-add-ons:springwolf-json-schema")
-> add @JsonPropertyOrder(alphabetic = true) to SchemaObject for easier comparison


One more question, when using the payloadSchemaFormat, why not also format the header schemas using the same setting?
Does your use-case only require the payloads in one format and the headers in the default?
I would have assumed that if I change it, all schema change.

@tvahrst
Copy link
Contributor Author

tvahrst commented Nov 15, 2025

Yes, Asyncapi and OpenAPI schema formats are quite similar. The main difference lies in the area of ​​inheritance. Schema classes with defined discriminator fields and values ​​are not (well) implemented by Asyncapi. That was the original motivation for this pull request.

The main reason for not using the payload schema format for the headers was that the headers are usually so simple that there are no differences between AsyncAPI and OpenAPI.

I thought it would unnecessarily bloat the resulting AsyncAPI by turning every headers block into a MultiSchemaFormat header:

headers:
  type: object
  properties:
    my-header-1:
      type: string
      description: Header 1
    my-header-2:
      type: string
      description: Header 2

to

headers:
  schemaFormat: application/vnd.oai.openapi;version=3.0.0
  schema:
    type: object
    properties:
      my-header-1:
        type: string
        description: Header 1
      my-header-2:
        type: string
        description: Header 2

But I must admit that the overhead is not that large at all. So - It might simplify the implementation if the schemaformat is used globally for payload and headers.

I would try change the PR towards a global asyncapi schemaformat. What do you think is a good propertyname / Enum classname instead of 'PayloadSchemaFormat'? Simply 'SchemaFormat' (which already exists), 'AsyncApiSchemaFormt' oder 'OutputSchemaFormat' ?

@timonback
Copy link
Member

timonback commented Nov 16, 2025

Sounds good. Since it is the configuration for schemaFormat, it can also be called SchemaFormat. If that is confusing with the asyncapi one, maybe SchemaFormatConfig?

I am mostly concerned about complexity and maintainability in the long term. We are a small team, so the easier it is now (also to contribute) the more features and less bugs are shipped.

A bloated document can be improved later as an optimisation, possibly also via a serialiser.

(Actually, I was considering if it is easier, when internally everything is a MultiFormatSchema and therefore less checking if this else that)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants