Skip to content

Commit af64563

Browse files
committed
feat: Add new linter to check empty response schemas
1 parent 2d7e4d3 commit af64563

File tree

3 files changed

+307
-0
lines changed

3 files changed

+307
-0
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.endava.cats.fuzzer.contract;
2+
3+
import com.endava.cats.annotations.LinterFuzzer;
4+
import com.endava.cats.http.HttpMethod;
5+
import com.endava.cats.model.FuzzingData;
6+
import com.endava.cats.report.TestCaseListener;
7+
import com.endava.cats.util.CatsModelUtils;
8+
import io.github.ludovicianul.prettylogger.PrettyLogger;
9+
import io.github.ludovicianul.prettylogger.PrettyLoggerFactory;
10+
import io.swagger.v3.oas.models.Operation;
11+
import io.swagger.v3.oas.models.media.MediaType;
12+
import io.swagger.v3.oas.models.responses.ApiResponse;
13+
import jakarta.inject.Singleton;
14+
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
import java.util.Map;
18+
19+
@LinterFuzzer
20+
@Singleton
21+
public class EmptyResponseSchemaLinterFuzzer extends BaseLinterFuzzer {
22+
private final PrettyLogger log = PrettyLoggerFactory.getLogger(this.getClass());
23+
private final List<String> ignoreStatusCodes = List.of("204", "304");
24+
private final List<String> ignoreContentTypes = List.of("application/octet-stream", "image/png", "image/jpeg", "application/pdf");
25+
26+
public EmptyResponseSchemaLinterFuzzer(TestCaseListener tcl) {
27+
super(tcl);
28+
}
29+
30+
@Override
31+
public void process(FuzzingData data) {
32+
testCaseListener.addScenario(log, "Detect empty response schemas (inline) that have no properties, $ref, or allOf/anyOf/oneOf");
33+
testCaseListener.addExpectedResult(log, "All response schemas should define properties, use $ref, or include oneOf/allOf/anyOf");
34+
35+
List<String> violations = new ArrayList<>();
36+
Operation operation = HttpMethod.getOperation(data.getMethod(), data.getPathItem());
37+
38+
if (operation.getResponses() != null) {
39+
for (Map.Entry<String, ApiResponse> entry : operation.getResponses().entrySet()) {
40+
String status = entry.getKey();
41+
ApiResponse response = entry.getValue();
42+
43+
if (ignoreStatusCodes.contains(status)) {
44+
continue;
45+
}
46+
47+
if (response.getContent() != null) {
48+
for (Map.Entry<String, MediaType> mediaEntry : response.getContent().entrySet()) {
49+
String contentType = mediaEntry.getKey();
50+
MediaType media = mediaEntry.getValue();
51+
52+
if (ignoreContentTypes.contains(contentType)) {
53+
continue;
54+
}
55+
56+
if (media.getSchema() != null && CatsModelUtils.isEmptyObjectSchema(media.getSchema())) {
57+
violations.add("Response schema on response %s is an empty object".formatted(status));
58+
}
59+
}
60+
} else {
61+
violations.add("Response %s does not define any content".formatted(status));
62+
}
63+
}
64+
}
65+
66+
if (!violations.isEmpty()) {
67+
testCaseListener.reportResultWarn(
68+
log,
69+
data,
70+
"Empty response schemas found",
71+
String.join("\n", violations)
72+
);
73+
} else {
74+
testCaseListener.reportResultInfo(log, data, "All response schemas define properties, $ref, or structural composition");
75+
}
76+
}
77+
78+
79+
@Override
80+
protected String runKey(FuzzingData data) {
81+
return data.getPath() + data.getMethod();
82+
}
83+
84+
@Override
85+
public String description() {
86+
return "detects response schemas that define neither properties, $ref, nor structural composition (oneOf, anyOf, allOf)";
87+
}
88+
}
89+
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.endava.cats.fuzzer.contract;
2+
3+
import com.endava.cats.args.IgnoreArguments;
4+
import com.endava.cats.args.ReportingArguments;
5+
import com.endava.cats.context.CatsGlobalContext;
6+
import com.endava.cats.http.HttpMethod;
7+
import com.endava.cats.model.FuzzingData;
8+
import com.endava.cats.report.ExecutionStatisticsListener;
9+
import com.endava.cats.report.TestCaseExporter;
10+
import com.endava.cats.report.TestCaseExporterHtmlJs;
11+
import com.endava.cats.report.TestCaseListener;
12+
import io.quarkus.test.junit.QuarkusTest;
13+
import io.swagger.parser.OpenAPIParser;
14+
import io.swagger.v3.oas.models.OpenAPI;
15+
import io.swagger.v3.oas.models.media.Schema;
16+
import jakarta.enterprise.inject.Instance;
17+
import org.assertj.core.api.Assertions;
18+
import org.junit.jupiter.api.BeforeEach;
19+
import org.junit.jupiter.api.Test;
20+
import org.mockito.Mockito;
21+
22+
import java.nio.file.Files;
23+
import java.nio.file.Paths;
24+
import java.util.stream.Stream;
25+
26+
@QuarkusTest
27+
class EmptyResponseSchemaLinterFuzzerTest {
28+
29+
private TestCaseListener testCaseListener;
30+
private EmptyResponseSchemaLinterFuzzer emptyResponseSchemaLinterFuzzer;
31+
32+
@BeforeEach
33+
void setup() {
34+
Instance<TestCaseExporter> exporters = Mockito.mock(Instance.class);
35+
TestCaseExporter exporter = Mockito.mock(TestCaseExporterHtmlJs.class);
36+
Mockito.when(exporters.stream()).thenReturn(Stream.of(exporter));
37+
testCaseListener = Mockito.spy(new TestCaseListener(Mockito.mock(CatsGlobalContext.class), Mockito.mock(ExecutionStatisticsListener.class), exporters,
38+
Mockito.mock(IgnoreArguments.class), Mockito.mock(ReportingArguments.class)));
39+
emptyResponseSchemaLinterFuzzer = new EmptyResponseSchemaLinterFuzzer(testCaseListener);
40+
}
41+
42+
@Test
43+
void shouldReportWarnWhenNoContent() throws Exception {
44+
OpenAPI openAPI = new OpenAPIParser().readContents(Files.readString(Paths.get("src/test/resources/inconsistent-api-2.yml")), null, null).getOpenAPI();
45+
FuzzingData data = FuzzingData.builder().openApi(openAPI).method(HttpMethod.PUT).pathItem(openAPI.getPaths().get("/users/{userId}")).build();
46+
emptyResponseSchemaLinterFuzzer.fuzz(data);
47+
48+
Mockito.verify(testCaseListener, Mockito.times(1)).reportResultWarn(Mockito.any(), Mockito.any(), Mockito.eq("Empty response schemas found"), Mockito.eq("Response 200 does not define any content"));
49+
}
50+
51+
@Test
52+
void shouldReportWarnWhenWhenEmptyObject() throws Exception {
53+
OpenAPI openAPI = new OpenAPIParser().readContents(Files.readString(Paths.get("src/test/resources/inconsistent-api-2.yml")), null, null).getOpenAPI();
54+
FuzzingData data = FuzzingData.builder().openApi(openAPI).method(HttpMethod.POST).pathItem(openAPI.getPaths().get("/users/{userId}")).build();
55+
emptyResponseSchemaLinterFuzzer.fuzz(data);
56+
57+
Mockito.verify(testCaseListener, Mockito.times(1)).reportResultWarn(Mockito.any(), Mockito.any(), Mockito.eq("Empty response schemas found"), Mockito.eq("Response schema on response 200 is an empty object"));
58+
}
59+
60+
@Test
61+
void shouldReportInfo() throws Exception {
62+
OpenAPI openAPI = new OpenAPIParser().readContents(Files.readString(Paths.get("src/test/resources/consistent-api.yml")), null, null).getOpenAPI();
63+
FuzzingData data = FuzzingData.builder().openApi(openAPI).method(HttpMethod.POST).pathItem(openAPI.getPaths().get("/pets/states")).reqSchema(new Schema()).build();
64+
emptyResponseSchemaLinterFuzzer.fuzz(data);
65+
66+
Mockito.verify(testCaseListener, Mockito.times(1)).reportResultInfo(Mockito.any(), Mockito.any(), Mockito.eq("All response schemas define properties, $ref, or structural composition"));
67+
}
68+
69+
@Test
70+
void shouldNotRunOnSecondAttempt() throws Exception {
71+
OpenAPI openAPI = new OpenAPIParser().readContents(Files.readString(Paths.get("src/test/resources/consistent-api.yml")), null, null).getOpenAPI();
72+
FuzzingData data = FuzzingData.builder().openApi(openAPI).pathItem(openAPI.getPaths().get("/pets/states")).method(HttpMethod.POST).reqSchema(new Schema()).build();
73+
emptyResponseSchemaLinterFuzzer.fuzz(data);
74+
75+
Mockito.verify(testCaseListener, Mockito.times(1)).reportResultInfo(Mockito.any(), Mockito.any(), Mockito.eq("All response schemas define properties, $ref, or structural composition"));
76+
77+
Mockito.reset(testCaseListener);
78+
emptyResponseSchemaLinterFuzzer.fuzz(data);
79+
Mockito.verify(testCaseListener, Mockito.times(0)).createAndExecuteTest(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
80+
}
81+
82+
@Test
83+
void shouldReturnSimpleClassNameForToString() {
84+
Assertions.assertThat(emptyResponseSchemaLinterFuzzer).hasToString(emptyResponseSchemaLinterFuzzer.getClass().getSimpleName());
85+
}
86+
87+
@Test
88+
void shouldReturnMeaningfulDescription() {
89+
Assertions.assertThat(emptyResponseSchemaLinterFuzzer.description()).isEqualTo("detects response schemas that define neither properties, $ref, nor structural composition (oneOf, anyOf, allOf)");
90+
}
91+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
openapi: "3.0.0"
2+
info:
3+
version: 1.0.0
4+
title: Swagger Petstore
5+
description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification
6+
termsOfService: http://swagger.io/terms/
7+
contact:
8+
name: Swagger API Team
9+
10+
url: http://swagger.io
11+
license:
12+
name: Apache 2.0
13+
url: https://www.apache.org/licenses/LICENSE-2.0.html
14+
servers:
15+
- url: http://petstore.swagger.io/api
16+
paths:
17+
/users/{userId}:
18+
post:
19+
summary: Update a user
20+
parameters:
21+
- in: path
22+
name: userId
23+
required: true
24+
schema:
25+
type: string
26+
requestBody:
27+
required: true
28+
content:
29+
application/json:
30+
schema:
31+
type: object
32+
responses:
33+
'200':
34+
description: No content
35+
content:
36+
application/json:
37+
schema:
38+
type: object
39+
'204':
40+
description: No Content
41+
put:
42+
summary: Update a user
43+
parameters:
44+
- in: path
45+
name: userId
46+
required: true
47+
schema:
48+
type: string
49+
requestBody:
50+
required: true
51+
content:
52+
application/json:
53+
schema:
54+
type: object
55+
responses:
56+
'200':
57+
description: No content
58+
'204':
59+
description: No Content
60+
/pets/{id}:
61+
get:
62+
description: Returns a user based on a single ID, if the user does not have access to the pet
63+
operationId: find pet by id
64+
parameters:
65+
- name: id
66+
in: path
67+
description: ID of pet to fetch
68+
required: true
69+
schema:
70+
type: integer
71+
format: int64
72+
example: 78
73+
- name: page
74+
in: query
75+
description: Page number
76+
required: true
77+
schema:
78+
type: string
79+
example: test
80+
- name: ""
81+
in: query
82+
schema:
83+
type: string
84+
example: ""
85+
responses:
86+
'200':
87+
description: pet response
88+
content:
89+
application/json:
90+
schema:
91+
$ref: '#/components/schemas/Pet'
92+
default:
93+
description: unexpected error
94+
content:
95+
application/json:
96+
schema:
97+
$ref: '#/components/schemas/Error'
98+
components:
99+
schemas:
100+
Pet:
101+
properties:
102+
id:
103+
type: integer
104+
format: int64
105+
minimum: 0
106+
code:
107+
type: string
108+
format: byte
109+
example: MjIyMjIyMg==
110+
additionalCode:
111+
type: string
112+
format: binary
113+
example: MjIyMjIyMg==
114+
Error:
115+
properties:
116+
id:
117+
type: integer
118+
format: int64
119+
minimum: 0
120+
code:
121+
type: string
122+
format: byte
123+
example: MjIyMjIyMg==
124+
additionalCode:
125+
type: string
126+
format: binary
127+
example: MjIyMjIyMg==

0 commit comments

Comments
 (0)