Skip to content

Instantly share code, notes, and snippets.

@saillinux
Created March 22, 2026 13:19
Show Gist options
  • Select an option

  • Save saillinux/6a02d68e75cc4da28c453ee8ecfb6f56 to your computer and use it in GitHub Desktop.

Select an option

Save saillinux/6a02d68e75cc4da28c453ee8ecfb6f56 to your computer and use it in GitHub Desktop.
package com.schooldevops.apifirst.apifirstsamples.config;
import io.swagger.annotations.ApiModelProperty;
import io.swagger.annotations.Extension;
import io.swagger.annotations.ExtensionProperty;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.media.Schema;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import springfox.documentation.oas.web.OpenApiTransformationContext;
import springfox.documentation.oas.web.WebMvcOpenApiTransformationFilter;
import springfox.documentation.spi.DocumentationType;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* SpringFox 3.0.0 does not serialize {@code @ApiModelProperty} extensions into the
* generated OpenAPI 3.0 schema properties. This filter post-processes the spec
* and injects them by reflecting on the registered DTO classes.
*/
@Slf4j
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class ModelPropertyExtensionFilter implements WebMvcOpenApiTransformationFilter {
/**
* Map of OpenAPI schema title -> Java class.
* Register every DTO that uses @ApiModelProperty(extensions = ...) here.
*/
private static final Map<String, Class<?>> SCHEMA_CLASS_MAP = new LinkedHashMap<>();
static {
SCHEMA_CLASS_MAP.put("Product",
com.schooldevops.apifirst.apifirstsamples.dto.ProductDto.class);
SCHEMA_CLASS_MAP.put("ErrorResponse",
com.schooldevops.apifirst.apifirstsamples.dto.ErrorResponseDto.class);
}
@Override
public OpenAPI transform(OpenApiTransformationContext<HttpServletRequest> context) {
OpenAPI openApi = context.getSpecification();
if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) {
return openApi;
}
Map<String, Schema> schemas = openApi.getComponents().getSchemas();
for (Map.Entry<String, Class<?>> entry : SCHEMA_CLASS_MAP.entrySet()) {
String schemaName = entry.getKey();
Class<?> dtoClass = entry.getValue();
Schema<?> schema = schemas.get(schemaName);
if (schema == null || schema.getProperties() == null) {
continue;
}
Map<String, Schema> properties = schema.getProperties();
for (Field field : dtoClass.getDeclaredFields()) {
ApiModelProperty annotation = field.getAnnotation(ApiModelProperty.class);
if (annotation == null) {
continue;
}
Extension[] extensions = annotation.extensions();
if (extensions.length == 0) {
continue;
}
Schema<?> propertySchema = properties.get(field.getName());
if (propertySchema == null) {
continue;
}
for (Extension ext : extensions) {
String extName = ext.name();
// Skip default/empty extensions (annotation defaults)
if (extName.isEmpty() && ext.properties().length == 0) {
continue;
}
// Skip extensions with only empty properties
boolean hasRealProperties = false;
for (ExtensionProperty prop : ext.properties()) {
if (!prop.name().isEmpty()) {
hasRealProperties = true;
break;
}
}
if (!hasRealProperties) {
continue;
}
if (!extName.startsWith("x-")) {
extName = "x-" + extName;
}
Map<String, String> extValues = new LinkedHashMap<>();
for (ExtensionProperty prop : ext.properties()) {
if (!prop.name().isEmpty()) {
extValues.put(prop.name(), prop.value());
}
}
propertySchema.addExtension(extName, extValues);
log.debug("Added extension '{}' to {}.{}", extName, schemaName, field.getName());
}
}
}
return openApi;
}
@Override
public boolean supports(DocumentationType delimiter) {
return delimiter == DocumentationType.OAS_30;
}
}
package com.schooldevops.apifirst.apifirstsamples.config;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.models.media.Schema;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.stereotype.Component;
import springfox.documentation.spring.web.json.JsonSerializer;
import java.lang.reflect.Field;
import java.util.Map;
/**
* Fixes the serialization of vendor extensions on {@link Schema} properties.
*
* <p>SpringFox's {@code OpenApiJacksonModule} registers a plain {@code NonEmptyMixin}
* for {@link Schema} that serializes extensions as a nested object:
* {@code "extensions": {"x-foo": ...}}.
*
* <p>The OpenAPI 3.0 spec requires them as flattened top-level keys:
* {@code "x-foo": ...}.
*
* <p>This component runs after all singletons are initialized, extracts the
* {@code ObjectMapper} from SpringFox's {@link JsonSerializer}, and replaces
* the {@link Schema} mixin with one that uses {@code @JsonAnyGetter}.
*/
@Slf4j
@Component
public class OpenApiExtensionSerializationConfig implements SmartInitializingSingleton {
private final JsonSerializer jsonSerializer;
public OpenApiExtensionSerializationConfig(JsonSerializer jsonSerializer) {
this.jsonSerializer = jsonSerializer;
}
@Override
public void afterSingletonsInstantiated() {
try {
Field mapperField = JsonSerializer.class.getDeclaredField("objectMapper");
mapperField.setAccessible(true);
ObjectMapper mapper = (ObjectMapper) mapperField.get(jsonSerializer);
mapper.addMixIn(Schema.class, SchemaExtensionMixin.class);
log.info("Applied SchemaExtensionMixin to SpringFox's ObjectMapper — " +
"vendor extensions will be serialized as top-level x-* keys");
} catch (NoSuchFieldException | IllegalAccessException e) {
log.warn("Failed to patch SpringFox ObjectMapper for extension serialization: {}", e.getMessage());
}
}
/**
* Mixin that flattens the extensions map into top-level {@code x-*} keys
* and hides the internal {@code exampleSetFlag} property.
*/
@JsonAutoDetect
@JsonInclude(JsonInclude.Include.NON_EMPTY)
abstract static class SchemaExtensionMixin {
@JsonIgnore
abstract boolean getExampleSetFlag();
@JsonAnyGetter
abstract Map<String, Object> getExtensions();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment