Skip to content

Commit

Permalink
feat!: dynamically determine mime types from application context
Browse files Browse the repository at this point in the history
This is closer how Spring WebMVC/WebFlux actually negotiate
and convert content types (aka mime types aka media types).
  • Loading branch information
neiser committed Sep 26, 2022
1 parent 1dea74e commit 8643025
Show file tree
Hide file tree
Showing 66 changed files with 779 additions and 219 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import de.qaware.openapigeneratorforspring.common.reference.HasReferencedItemConsumer;
import de.qaware.openapigeneratorforspring.model.trait.HasContent;
import org.springframework.util.MimeType;

import javax.annotation.Nullable;
import java.util.Optional;
Expand Down Expand Up @@ -52,7 +53,7 @@ public interface MapperContext extends HasReferencedItemConsumer {
* @param owningType owning type, must extend {@link HasContent HasContent}
* @return media types, or empty optional if nothing can be provided for this owning type
*/
Optional<Set<String>> findMediaTypes(Class<? extends HasContent> owningType);
Optional<Set<MimeType>> findMimeTypes(Class<? extends HasContent> owningType);

/**
* Set the owner for any following referenced item.
Expand All @@ -64,7 +65,7 @@ public interface MapperContext extends HasReferencedItemConsumer {

/**
* Sets the {@link MediaTypesProvider media types provider} for any
* following calls of {@link #findMediaTypes}.
* following calls of {@link #findMimeTypes}.
*
* @param mediaTypesProvider media types provider
* @return mapper context with modified media types provider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
package de.qaware.openapigeneratorforspring.common.mapper;

import de.qaware.openapigeneratorforspring.model.trait.HasContent;
import org.springframework.util.MimeType;

import java.util.Set;

Expand All @@ -31,5 +32,5 @@
@FunctionalInterface
public interface MediaTypesProvider {

Set<String> getMediaTypes(Class<? extends HasContent> owningType);
Set<MimeType> getMimeTypes(Class<? extends HasContent> owningType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package de.qaware.openapigeneratorforspring.common.operation.mimetype;

import de.qaware.openapigeneratorforspring.common.paths.HandlerMethod;
import de.qaware.openapigeneratorforspring.common.util.OpenApiOrderedUtils;
import org.springframework.util.MimeType;

import java.util.Set;

public interface ConsumesMimeTypeProvider extends OpenApiOrderedUtils.DefaultOrdered {
Set<MimeType> findConsumesMimeTypes(HandlerMethod handlerMethod);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package de.qaware.openapigeneratorforspring.common.operation.mimetype;

import de.qaware.openapigeneratorforspring.common.paths.HandlerMethod;
import org.springframework.util.MimeType;

import java.util.Set;

public interface ConsumesMimeTypeProviderStrategy {
Set<MimeType> getConsumesMimeTypes(HandlerMethod handlerMethod);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package de.qaware.openapigeneratorforspring.common.operation.mimetype;

import de.qaware.openapigeneratorforspring.common.paths.HandlerMethod;
import de.qaware.openapigeneratorforspring.common.util.OpenApiOrderedUtils;
import org.springframework.util.MimeType;

import java.util.Set;

public interface ProducesMimeTypeProvider extends OpenApiOrderedUtils.DefaultOrdered {
Set<MimeType> findProducesMimeTypes(HandlerMethod handlerMethod);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package de.qaware.openapigeneratorforspring.common.operation.mimetype;

import de.qaware.openapigeneratorforspring.common.paths.HandlerMethod;
import org.springframework.util.MimeType;

import java.util.Set;

public interface ProducesMimeTypeProviderStrategy {
Set<MimeType> getProducesMimeTypes(HandlerMethod handlerMethod);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@

import de.qaware.openapigeneratorforspring.common.annotation.HasAnnotationsSupplier;
import de.qaware.openapigeneratorforspring.model.response.ApiResponse;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.Order;
import org.springframework.util.MimeType;

import javax.annotation.Nullable;
import java.lang.annotation.Annotation;
Expand Down Expand Up @@ -105,13 +107,13 @@ interface Parameter extends HasAnnotationsSupplier, HasType, HasContext,

interface RequestBody extends HasAnnotationsSupplier, HasType, HasContext,
HasCustomize<de.qaware.openapigeneratorforspring.model.requestbody.RequestBody> {
Set<String> getConsumesContentTypes();
Set<MimeType> getConsumesMimeTypes();
}

interface Response extends HasType, HasCustomize<ApiResponse> {
String getResponseCode();

Set<String> getProducesContentTypes();
Set<MimeType> getProducesMimeTypes();
}

/**
Expand Down Expand Up @@ -272,7 +274,7 @@ interface ContextModifier<C> {
* de.qaware.openapigeneratorforspring.common.schema.resolver.SchemaResolver#resolveFromType schema resolver}
*/
interface Type extends HasAnnotationsSupplier {
java.lang.reflect.Type getType();
ResolvableType getType();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
import de.qaware.openapigeneratorforspring.common.operation.id.DefaultOperationIdProvider;
import de.qaware.openapigeneratorforspring.common.operation.id.OperationIdConflictResolver;
import de.qaware.openapigeneratorforspring.common.operation.id.OperationIdProvider;
import de.qaware.openapigeneratorforspring.common.operation.mimetype.ConsumesMimeTypeProvider;
import de.qaware.openapigeneratorforspring.common.operation.mimetype.DefaultConsumesMimeTypeProviderStrategy;
import de.qaware.openapigeneratorforspring.common.operation.mimetype.DefaultProducesMimeTypeProviderStrategy;
import de.qaware.openapigeneratorforspring.common.operation.mimetype.ProducesMimeTypeProvider;
import de.qaware.openapigeneratorforspring.common.paths.HandlerMethod;
import de.qaware.openapigeneratorforspring.common.schema.resolver.SchemaResolver;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
Expand Down Expand Up @@ -129,4 +133,16 @@ public DefaultRequestBodyOperationCustomizer defaultRequestBodyOperationCustomiz
) {
return new DefaultRequestBodyOperationCustomizer(requestBodyAnnotationMapper, schemaResolver, handlerMethodRequestBodyMappers);
}

@Bean
@ConditionalOnMissingBean
public DefaultProducesMimeTypeProviderStrategy defaultProducesMimeTypeProviderStrategy(List<ProducesMimeTypeProvider> providers) {
return new DefaultProducesMimeTypeProviderStrategy(providers);
}

@Bean
@ConditionalOnMissingBean
public DefaultConsumesMimeTypeProviderStrategy defaultConsumesMimeTypeProviderStrategy(List<ConsumesMimeTypeProvider> providers) {
return new DefaultConsumesMimeTypeProviderStrategy(providers);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.util.MimeType;

import java.util.Arrays;
import java.util.Objects;
Expand All @@ -57,16 +58,16 @@ public Content mapArray(io.swagger.v3.oas.annotations.media.Content[] contentAnn
if (StringUtils.isBlank(contentAnnotation.mediaType())) {
// if the mapperContext doesn't have any suggested media types,
// the mediaTypeValue is discarded!
Set<String> mediaTypes = mapperContext.findMediaTypes(owningType)
.orElseThrow(() -> new IllegalStateException("No media types available in context for " + owningType.getSimpleName()
Set<MimeType> mimeTypes = mapperContext.findMimeTypes(owningType)
.orElseThrow(() -> new IllegalStateException("No mime types available in context for " + owningType.getSimpleName()
+ " and Content annotation has blank mediaType"));
return mediaTypes.stream().map(mediaType -> Pair.of(mediaType, mediaTypeValue));
return mimeTypes.stream().map(mimeType -> Pair.of(mimeType, mediaTypeValue));
}
return Stream.of(Pair.of(contentAnnotation.mediaType(), mediaTypeValue));
return Stream.of(Pair.of(MimeType.valueOf(contentAnnotation.mediaType()), mediaTypeValue));
})
.collect(Collectors.toMap(Pair::getKey, Pair::getValue, (a, b) -> {
.collect(Collectors.toMap(p -> p.getKey().toString(), Pair::getValue, (a, b) -> {
if (!Objects.equals(a, b)) {
throw new IllegalStateException("Conflicting media type found for " + a + " vs. " + b);
throw new IllegalStateException("Conflicting mime type found for " + a + " vs. " + b);
}
return a;
}, Content::new));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.With;
import org.springframework.util.MimeType;

import javax.annotation.Nullable;
import java.util.Optional;
Expand All @@ -48,8 +49,8 @@ public <T extends ReferencedItemConsumer> T getReferencedItemConsumer(Class<T> r
}

@Override
public Optional<Set<String>> findMediaTypes(Class<? extends HasContent> owningType) {
return Optional.ofNullable(mediaTypesProvider).map(provider -> provider.getMediaTypes(owningType));
public Optional<Set<MimeType>> findMimeTypes(Class<? extends HasContent> owningType) {
return Optional.ofNullable(mediaTypesProvider).map(provider -> provider.getMimeTypes(owningType));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import de.qaware.openapigeneratorforspring.model.operation.Operation;
import de.qaware.openapigeneratorforspring.model.requestbody.RequestBody;
import lombok.RequiredArgsConstructor;
import org.springframework.util.MimeType;

import javax.annotation.Nullable;
import java.util.List;
Expand Down Expand Up @@ -66,20 +67,20 @@ private RequestBody applyFromMethod(@Nullable RequestBody existingRequestBody, O
private RequestBody buildRequestBody(List<HandlerMethod.RequestBody> handlerMethodRequestBodies,
@Nullable RequestBody existingRequestBody, OperationBuilderContext operationBuilderContext) {
RequestBody requestBody = buildRequestBodyFromSwaggerAnnotations(handlerMethodRequestBodies, existingRequestBody, operationBuilderContext);
handlerMethodRequestBodies.forEach(handlerMethodRequestBodyParameter -> {
for (String contentType : handlerMethodRequestBodyParameter.getConsumesContentTypes()) {
MediaType mediaType = addMediaTypeIfNotPresent(contentType, requestBody);
handlerMethodRequestBodies.forEach(handlerMethodRequestBody -> {
for (MimeType mimeType : handlerMethodRequestBody.getConsumesMimeTypes()) {
MediaType mediaType = addMediaTypeIfNotPresent(mimeType, requestBody);
if (mediaType.getSchema() == null) {
handlerMethodRequestBodyParameter.getType().ifPresent(parameterType -> schemaResolver.resolveFromType(
handlerMethodRequestBody.getType().ifPresent(parameterType -> schemaResolver.resolveFromType(
REQUEST_BODY,
parameterType.getType(),
handlerMethodRequestBodyParameter.getAnnotationsSupplier().andThen(parameterType.getAnnotationsSupplier()),
parameterType.getType().getType(),
handlerMethodRequestBody.getAnnotationsSupplier().andThen(parameterType.getAnnotationsSupplier()),
operationBuilderContext.getReferencedItemConsumer(ReferencedSchemaConsumer.class),
mediaType::setSchema
));
}
}
handlerMethodRequestBodyParameter.customize(requestBody);
handlerMethodRequestBody.customize(requestBody);
});
return requestBody;
}
Expand All @@ -99,12 +100,12 @@ private RequestBody buildRequestBodyFromSwaggerAnnotations(List<HandlerMethod.Re
return requestBody;
}

private static MediaType addMediaTypeIfNotPresent(String contentType, RequestBody requestBody) {
private static MediaType addMediaTypeIfNotPresent(MimeType mimeType, RequestBody requestBody) {
if (requestBody.getContent() == null) {
Content content = new Content();
requestBody.setContent(content);
}
return requestBody.getContent().computeIfAbsent(contentType, ignored -> new MediaType());
return requestBody.getContent().computeIfAbsent(mimeType.toString(), ignored -> new MediaType());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package de.qaware.openapigeneratorforspring.common.operation.mimetype;

import de.qaware.openapigeneratorforspring.common.paths.HandlerMethod;
import lombok.RequiredArgsConstructor;
import org.springframework.util.MimeType;

import java.util.Collections;
import java.util.List;
import java.util.Set;

@RequiredArgsConstructor
public class DefaultConsumesMimeTypeProviderStrategy implements ConsumesMimeTypeProviderStrategy {

private final List<ConsumesMimeTypeProvider> providers;

@Override
public Set<MimeType> getConsumesMimeTypes(HandlerMethod handlerMethod) {
return providers.stream()
.map(provider -> provider.findConsumesMimeTypes(handlerMethod))
.filter(mimeTypes -> !mimeTypes.isEmpty())
.findFirst()
.orElseGet(Collections::emptySet);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package de.qaware.openapigeneratorforspring.common.operation.mimetype;

import de.qaware.openapigeneratorforspring.common.paths.HandlerMethod;
import lombok.RequiredArgsConstructor;
import org.springframework.util.MimeType;

import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@RequiredArgsConstructor
public class DefaultProducesMimeTypeProviderStrategy implements ProducesMimeTypeProviderStrategy {

private final List<ProducesMimeTypeProvider> providers;

@Override
public Set<MimeType> getProducesMimeTypes(HandlerMethod handlerMethod) {
return providers.stream()
.map(provider -> provider.findProducesMimeTypes(handlerMethod))
// Produces mime types end up as response body keys in model and must be concrete (aka no wildcards!)
.map(mimeTypes -> mimeTypes.stream().filter(MimeType::isConcrete).collect(Collectors.toSet()))
.filter(mimeTypes -> !mimeTypes.isEmpty())
.findFirst()
.orElseGet(Collections::emptySet);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public void customize(Parameter parameter, OperationParameterCustomizerContext c
ReferencedSchemaConsumer referencedSchemaConsumer = context.getReferencedItemConsumer(ReferencedSchemaConsumer.class);
AnnotationsSupplier annotationsSupplier = handlerMethodParameter.getAnnotationsSupplier()
.andThen(parameterType.getAnnotationsSupplier());
schemaResolver.resolveFromType(PARAMETER, parameterType.getType(), annotationsSupplier, referencedSchemaConsumer, parameter::setSchema);
schemaResolver.resolveFromType(PARAMETER, parameterType.getType().getType(), annotationsSupplier, referencedSchemaConsumer, parameter::setSchema);
})
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,16 @@ public void customize(ApiResponses apiResponses, OperationBuilderContext operati
}

private void addMediaTypesToApiResponseContent(Content content, HandlerMethod handlerMethod, HandlerMethod.Response handlerMethodResponse, ReferencedSchemaConsumer referencedSchemaConsumer) {
handlerMethodResponse.getProducesContentTypes().forEach(contentType -> {
MediaType mediaType = content.getOrDefault(contentType, new MediaType());
handlerMethodResponse.getProducesMimeTypes().forEach(mimeType -> {
MediaType mediaType = content.getOrDefault(mimeType.toString(), new MediaType());
// we might have already set some media type, only amend this if the schema is not present
// this gives annotations a higher preference than the schema inferred from the method return type
if (mediaType.getSchema() == null) {
handlerMethodResponse.getType().ifPresent(responseType -> {
AnnotationsSupplier annotationsSupplier = responseType.getAnnotationsSupplier()
// restrict searching the annotations from the handler method to @Schema or @ArraySchema only
// this prevents things like @Deprecated on the method to make the response schema also deprecated
// but we can still use @Schema to modify properties of the "default" response
// but, we can still use @Schema to modify properties of the "default" response
.andThen(new AnnotationsSupplier() {
@Override
public <A extends Annotation> Stream<A> findAnnotations(Class<A> annotationType) {
Expand All @@ -88,14 +88,14 @@ public <A extends Annotation> Stream<A> findAnnotations(Class<A> annotationType)
return Stream.empty();
}
});
schemaResolver.resolveFromType(API_RESPONSE, responseType.getType(), annotationsSupplier,
schemaResolver.resolveFromType(API_RESPONSE, responseType.getType().getType(), annotationsSupplier,
referencedSchemaConsumer,
mediaType::setSchema
);
});
// putting empty media types is ok
// when there are multiple content types present for one response code
content.put(contentType, mediaType);
content.put(mimeType.toString(), mediaType);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;

public class DefaultOpenApiObjectMapperSupplier implements OpenApiObjectMapperSupplier {
// Maybe the "auto-configured" object mapper from spring would works better?
// Maybe the autoconfigured object mapper from spring would work better?
// Allow customizations?
// See GH Issue #7
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package de.qaware.openapigeneratorforspring.test.app55;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
class App55 {
public static void main(String[] args) {
SpringApplication.run(App55.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package de.qaware.openapigeneratorforspring.test.app55;

import lombok.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class App55Controller {

@GetMapping
public SomeDto getJson() {
return new SomeDto("some-value");
}

@Value
private static class SomeDto {
String someProperty;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package de.qaware.openapigeneratorforspring.test.app55;

import de.qaware.openapigeneratorforspring.test.AbstractOpenApiGeneratorWebMvcIntTest;

class App55Test extends AbstractOpenApiGeneratorWebMvcIntTest {

}
Loading

0 comments on commit 8643025

Please sign in to comment.