Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.springdoc.core.customizers.OperationCustomizer;
Expand All @@ -27,6 +27,7 @@
import io.swagger.v3.core.converter.ModelConverters;
import io.swagger.v3.core.converter.ResolvedSchema;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.examples.Example;
import io.swagger.v3.oas.models.media.Content;
import io.swagger.v3.oas.models.media.MediaType;
import io.swagger.v3.oas.models.media.Schema;
Expand All @@ -52,18 +53,36 @@ public Operation customize(Operation operation, HandlerMethod handler) {
}

ApiResponses responses = operation.getResponses();
if (responses == null) {
responses = new ApiResponses();
operation.setResponses(responses);
}
ApiResponses targetResponses = responses;

Type returnType = getActualResponseType(handler);
ApiResponseCode[] codes = ann.value();
for (int i = 0; i < codes.length; i++) {
ApiResponseCode code = codes[i];
String key = String.format("%d) %d", i + 1, code.getHttpStatus().value());
responses.put(key, createApiResponse(
code.getMessage(),
() -> createResponseBody(code, handler, returnType)
Map<Integer, List<ApiResponseCode>> codesByStatus = Arrays.stream(ann.value())
.collect(Collectors.groupingBy(
code -> code.getHttpStatus().value(),
LinkedHashMap::new,
Collectors.toList()
));

ApiResponse springdocSuccessResponse = responses.get("200");
boolean hasExplicitSuccessResponse = codesByStatus.keySet().stream()
.anyMatch(status -> status >= 200 && status < 300);
if (hasExplicitSuccessResponse && !codesByStatus.containsKey(200)) {
responses.remove("200");
}

codesByStatus.forEach((status, codes) -> {
String statusKey = String.valueOf(status);
ApiResponse existingResponse = targetResponses.get(statusKey);
targetResponses.put(
statusKey,
createApiResponse(codes, handler, returnType, existingResponse, springdocSuccessResponse)
);
});

return operation;
}

Expand All @@ -79,29 +98,78 @@ private Type getActualResponseType(HandlerMethod handler) {
}

private ApiResponse createApiResponse(
String description,
Supplier<MediaType> supplier
List<ApiResponseCode> codes,
HandlerMethod handler,
Type returnType,
ApiResponse existingResponse,
ApiResponse springdocSuccessResponse
) {
return new ApiResponse()
.description(description)
.content(new Content().addMediaType(APPLICATION_JSON_VALUE, supplier.get()));
ApiResponse apiResponse = existingResponse == null ? new ApiResponse() : existingResponse;
String description = codes.stream()
.map(code -> code.getCode() + ": " + code.getMessage())
.collect(Collectors.joining("\n"));
apiResponse.setDescription(mergeDescriptions(apiResponse.getDescription(), description));

ApiResponseCode firstCode = codes.get(0);
if (firstCode.getHttpStatus().is2xxSuccessful()) {
mergeSuccessResponse(apiResponse, springdocSuccessResponse, returnType, firstCode);
} else {
mergeErrorResponse(apiResponse, codes, handler);
}
return apiResponse;
}

private MediaType createResponseBody(
ApiResponseCode code,
HandlerMethod handler,
Type returnType
private String mergeDescriptions(String existingDescription, String generatedDescription) {
if (existingDescription == null || existingDescription.isBlank()) {
return generatedDescription;
}
if (generatedDescription == null || generatedDescription.isBlank()
|| existingDescription.equals(generatedDescription)
|| existingDescription.endsWith("\n" + generatedDescription)) {
return existingDescription;
}
return existingDescription + "\n" + generatedDescription;
}

private void mergeSuccessResponse(
ApiResponse response,
ApiResponse springdocSuccessResponse,
Type returnType,
ApiResponseCode code
) {
if (code.getHttpStatus().is2xxSuccessful()) {
return new MediaType().schema(loadSchema(returnType));
if (isNoContentResponse(returnType) || code.getHttpStatus().value() == 204) {
response.setContent(null);
return;
}
if (code == ApiResponseCode.INVALID_REQUEST_BODY) {
return createErrorMediaType(createInvalidRequestBodyErrorExample(code, handler));
if (hasSchema(response.getContent())) {
return;
}
if (springdocSuccessResponse != null && hasSchema(springdocSuccessResponse.getContent())) {
response.setContent(springdocSuccessResponse.getContent());
return;
}
return createErrorMediaType(createGenericErrorExample(code));
response.setContent(new Content().addMediaType(
APPLICATION_JSON_VALUE,
new MediaType().schema(loadSchema(returnType))
));
}

private boolean hasSchema(Content content) {
return content != null && content.values().stream()
.anyMatch(mediaType -> mediaType != null && mediaType.getSchema() != null);
}

private boolean isNoContentResponse(Type returnType) {
return returnType.equals(Void.class) || returnType.equals(void.class);
}

private Map<String,Object> createGenericErrorExample(ApiResponseCode code) {
private Map<String,Object> createErrorExample(
ApiResponseCode code,
HandlerMethod handler
) {
if (code == ApiResponseCode.INVALID_REQUEST_BODY) {
return createInvalidRequestBodyErrorExample(code, handler);
}
return Map.of(
"code", code.getCode(),
"message", code.getMessage(),
Expand Down Expand Up @@ -202,9 +270,52 @@ private static Schema<?> loadSchema(Type type) {
return resolvedSchema.schema;
}

private MediaType createErrorMediaType(Map<String,Object> example) {
MediaType mt = new MediaType().schema(errorSchema);
mt.example(example);
return mt;
private void mergeErrorResponse(
ApiResponse response,
List<ApiResponseCode> codes,
HandlerMethod handler
) {
Content content = response.getContent();
if (content == null) {
content = new Content();
response.setContent(content);
}

MediaType mediaType = content.get(APPLICATION_JSON_VALUE);
if (mediaType == null) {
mediaType = new MediaType();
content.addMediaType(APPLICATION_JSON_VALUE, mediaType);
}
if (mediaType.getSchema() == null) {
mediaType.setSchema(errorSchema);
}

Map<String, Example> examples = mediaType.getExamples() == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(mediaType.getExamples());
moveSingleExampleToNamedExamples(mediaType, examples);
for (ApiResponseCode code : codes) {
examples.putIfAbsent(
code.getCode(),
new Example()
.summary(code.getCode())
.description(code.getMessage())
.value(createErrorExample(code, handler))
);
}
mediaType.setExamples(examples);
}

private void moveSingleExampleToNamedExamples(MediaType mediaType, Map<String, Example> examples) {
if (mediaType.getExample() == null) {
return;
}
String exampleName = "default";
int suffix = 2;
while (examples.containsKey(exampleName)) {
exampleName = "default-" + suffix++;
}
examples.put(exampleName, new Example().value(mediaType.getExample()));
mediaType.setExample(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package in.koreatech.koin.acceptance.domain;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.util.Iterator;

import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MvcResult;

import com.fasterxml.jackson.databind.JsonNode;

import in.koreatech.koin.acceptance.AcceptanceTest;
import in.koreatech.koin.acceptance.support.JsonAssertions;

class CampusOpenApiContractTest extends AcceptanceTest {

private static final String CAMPUS_GROUP = "3. Campus API";

@Test
void root_list_success_schema_is_preserved() throws Exception {
JsonNode openApi = campusOpenApi();
JsonNode response = openApi.at("/paths/~1callvan~1notifications/get/responses/200");

assertThat(response.isMissingNode()).isFalse();
JsonNode schema = findResponseSchema(response);
assertThat(schema.path("type").asText()).isEqualTo("array");
assertThat(schema.path("items").path("$ref").asText()).endsWith("CallvanNotificationResponse");
}

@Test
void explicit_created_and_no_content_responses_do_not_keep_phantom_200() throws Exception {
JsonNode openApi = campusOpenApi();
JsonNode created = openApi.at("/paths/~1callvan/post/responses");
JsonNode noContent = openApi.at(
"/paths/~1callvan~1notifications~1mark-all-read/post/responses");

assertThat(created.has("200")).isFalse();
assertThat(created.has("201")).isTrue();
assertThat(findResponseSchema(created.path("201")).path("$ref").asText())
.endsWith("CallvanPostCreateResponse");
assertThat(noContent.has("200")).isFalse();
assertThat(noContent.has("204")).isTrue();
assertThat(noContent.path("204").has("content")).isFalse();
}

private JsonNode campusOpenApi() throws Exception {
MvcResult result = mockMvc.perform(get("/v3/api-docs/{group}", CAMPUS_GROUP))
.andExpect(status().isOk())
.andReturn();
return JsonAssertions.convertJsonNode(result);
}

private JsonNode findResponseSchema(JsonNode response) {
Iterator<JsonNode> mediaTypes = response.path("content").elements();
while (mediaTypes.hasNext()) {
JsonNode schema = mediaTypes.next().path("schema");
if (!schema.isMissingNode()) {
return schema;
}
}
throw new AssertionError("Response content does not contain a schema");
}

}
Loading
Loading