Skip to content

Commit c6a4ec1

Browse files
joseegarciaclaude
andcommitted
Support OpenAPI 3.1 top-level webhooks (#375)
OpenAPI 3.1 adds a top-level `webhooks` object: a map of named Path Item Objects describing out-of-band requests. The generator only iterated `paths`, so webhooks were ignored - no handler interface, no payload models. Add OpenApiUtil.mergeWebhooksIntoPaths, invoked right after parsing in OpenApiGenerator.processFile. Each webhook (keyed by name) is merged into `paths` under a "/"-prefixed key so the existing pipeline generates a handler interface for its operations and the request/response payload models. `paths` is created if the contract has none, and existing `paths` entries are never overwritten. Adds a 3.1.0 webhooks-only regression fixture (testWebhooks) with golden assets (NewPetApi + PetDTO). Stacked on the 6.4.0 array-type work (#376); both are part of the 3.1 support milestone, so the version stays 6.4.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4cf6aa8 commit c6a4ec1

7 files changed

Lines changed: 236 additions & 0 deletions

File tree

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ private void processPackage(final String apiPackage) {
9898
private void processFile(final SpecFile specFile) {
9999

100100
final JsonNode openAPI = OpenApiUtil.getPojoFromSpecFile(baseDir, specFile);
101+
OpenApiUtil.mergeWebhooksIntoPaths(openAPI);
101102
final String clientPackage = specFile.getClientPackage();
102103

103104
if (specFile.isCallMode()) {

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ public class OpenApiUtil {
3434

3535
public static final String PATHS = "paths";
3636

37+
public static final String WEBHOOKS = "webhooks";
38+
3739
static final Set<String> REST_VERB_SET = Set.of("get", "post", "delete", "patch", "put");
3840

3941
private OpenApiUtil() {
@@ -91,6 +93,31 @@ public static JsonNode getPojoFromSpecFile(final Path baseDir, final SpecFile sp
9193
return SchemaUtil.getPojoFromRef(baseDir.toUri(), specFile.getFilePath());
9294
}
9395

96+
/**
97+
* Merges the OpenAPI 3.1 top-level {@code webhooks} object into {@code paths} so the existing
98+
* path pipeline generates a handler interface and the request/response payload models for each
99+
* webhook. Each webhook is a Path Item Object keyed by name; it is added under a {@code "/"}-
100+
* prefixed key (webhooks have no URL) so the by-url grouping treats the webhook name as the
101+
* endpoint. Existing {@code paths} entries take precedence and are never overwritten.
102+
*
103+
* @param openApi the parsed root contract; its {@code paths} node is created/extended in place.
104+
*/
105+
public static void mergeWebhooksIntoPaths(final JsonNode openApi) {
106+
final JsonNode webhooks = openApi.get(WEBHOOKS);
107+
if (webhooks instanceof ObjectNode && openApi instanceof ObjectNode) {
108+
final ObjectNode root = (ObjectNode) openApi;
109+
final ObjectNode paths = root.has(PATHS) && root.get(PATHS).isObject()
110+
? (ObjectNode) root.get(PATHS)
111+
: root.putObject(PATHS);
112+
webhooks.fields().forEachRemaining(webhook -> {
113+
final String pathKey = webhook.getKey().startsWith("/") ? webhook.getKey() : "/" + webhook.getKey();
114+
if (!paths.has(pathKey)) {
115+
paths.set(pathKey, webhook.getValue());
116+
}
117+
});
118+
}
119+
}
120+
94121
public static Map<String, JsonNode> processPaths(final JsonNode openApi, final Map<String, JsonNode> schemaMap, SpecFile specFile) {
95122
final var basicJsonNodeMap = new HashMap<>(schemaMap);
96123

multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@ public final class OpenApiGeneratorFixtures {
147147
.clientPackage("com.sngular.multifileplugin.externalref.client").modelNamePrefix("Api")
148148
.modelNameSuffix("DTO").build());
149149

150+
static final List<SpecFile> TEST_WEBHOOKS = List
151+
.of(SpecFile.builder().filePath("openapigenerator/testWebhooks/api-test.yml")
152+
.apiPackage("com.sngular.multifileplugin.webhooks")
153+
.modelPackage("com.sngular.multifileplugin.webhooks.model")
154+
.clientPackage("com.sngular.multifileplugin.webhooks.client")
155+
.modelNameSuffix("DTO").build());
156+
150157
static final List<SpecFile> TEST_OPEN_API_31_TYPES = List
151158
.of(SpecFile.builder().filePath("openapigenerator/testOpenApi31Types/api-test.yml")
152159
.apiPackage("com.sngular.multifileplugin.openapi31types")
@@ -770,6 +777,24 @@ static Function<Path, Boolean> validateExternalRefGeneration() {
770777
DEFAULT_MODEL_API, expectedExceptionFiles, DEFAULT_EXCEPTION_API);
771778
}
772779

780+
static Function<Path, Boolean> validateWebhooks() {
781+
782+
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/webhooks";
783+
784+
final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/webhooks/model";
785+
786+
final String COMMON_PATH = "openapigenerator/testWebhooks/";
787+
788+
final String ASSETS_PATH = COMMON_PATH + "assets/";
789+
790+
final List<String> expectedTestApiFile = List.of(ASSETS_PATH + "NewPetApi.java");
791+
792+
final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "PetDTO.java");
793+
794+
return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
795+
DEFAULT_MODEL_API, Collections.emptyList(), null);
796+
}
797+
773798
static Function<Path, Boolean> validateOpenApi31Types() {
774799

775800
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/openapi31types";

multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ static Stream<Arguments> fileSpecToProcess() {
8585
OpenApiGeneratorFixtures.validateExternalRefGeneration()),
8686
Arguments.of("testOpenApi31Types", OpenApiGeneratorFixtures.TEST_OPEN_API_31_TYPES,
8787
OpenApiGeneratorFixtures.validateOpenApi31Types()),
88+
Arguments.of("testWebhooks", OpenApiGeneratorFixtures.TEST_WEBHOOKS,
89+
OpenApiGeneratorFixtures.validateWebhooks()),
8890
Arguments.of("testAnyOfInResponse", OpenApiGeneratorFixtures.TEST_ANY_OF_IN_RESPONSE,
8991
OpenApiGeneratorFixtures.validateAnyOfInResponse()),
9092
Arguments.of("testOneOfInResponse", OpenApiGeneratorFixtures.TEST_ONE_OF_IN_RESPONSE,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
# Regression for OpenAPI 3.1 top-level `webhooks` support (issue #375).
3+
# A webhooks-only contract (no `paths`): each webhook is a Path Item Object keyed by
4+
# name. The generator merges these into the path pipeline so a handler interface and the
5+
# request payload model are generated.
6+
openapi: "3.1.0"
7+
info:
8+
version: 1.0.0
9+
title: Webhooks API (OpenAPI 3.1)
10+
license:
11+
name: MIT
12+
servers:
13+
- url: http://localhost:8080/v1
14+
tags:
15+
- name: webhook
16+
webhooks:
17+
newPet:
18+
post:
19+
summary: New pet notification
20+
operationId: newPetWebhook
21+
tags:
22+
- webhook
23+
requestBody:
24+
content:
25+
application/json:
26+
schema:
27+
$ref: "#/components/schemas/Pet"
28+
responses:
29+
'200':
30+
description: Notification acknowledged
31+
components:
32+
schemas:
33+
Pet:
34+
type: object
35+
properties:
36+
id:
37+
type: integer
38+
format: int64
39+
name:
40+
type: string
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.sngular.multifileplugin.webhooks;
2+
3+
import java.util.Optional;
4+
import java.util.List;
5+
import java.util.Map;
6+
import javax.validation.Valid;
7+
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.Parameter;
10+
import io.swagger.v3.oas.annotations.media.Content;
11+
import io.swagger.v3.oas.annotations.media.Schema;
12+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
13+
import org.springframework.http.MediaType;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.http.ResponseEntity;
16+
import org.springframework.web.bind.annotation.*;
17+
import org.springframework.web.context.request.NativeWebRequest;
18+
19+
import com.sngular.multifileplugin.webhooks.model.PetDTO;
20+
21+
public interface NewPetApi {
22+
23+
/**
24+
* POST /newPet: New pet notification
25+
* @param petDTO
26+
* @return Notification acknowledged; (status code 200)
27+
*/
28+
29+
@Operation(
30+
operationId = "newPetWebhook",
31+
summary = "New pet notification",
32+
tags = {"webhook"},
33+
responses = {
34+
@ApiResponse(responseCode = "200", description = "Notification acknowledged")
35+
}
36+
)
37+
@RequestMapping(
38+
method = RequestMethod.POST,
39+
value = "/newPet",
40+
produces = {"application/json"}
41+
)
42+
43+
default ResponseEntity<Void> newPetWebhook(@Parameter(name = "petDTO", description = "", required = false, schema = @Schema(description = "")) @Valid @RequestBody PetDTO petDTO) {
44+
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
45+
}
46+
47+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.sngular.multifileplugin.webhooks.model;
2+
3+
import java.util.Objects;
4+
5+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
6+
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
7+
import com.fasterxml.jackson.annotation.JsonProperty;
8+
import io.swagger.v3.oas.annotations.media.Schema;
9+
10+
@JsonDeserialize(builder = PetDTO.PetDTOBuilder.class)
11+
public class PetDTO {
12+
13+
@JsonProperty(value ="name")
14+
private String name;
15+
@JsonProperty(value ="id")
16+
private Long id;
17+
18+
private PetDTO(PetDTOBuilder builder) {
19+
this.name = builder.name;
20+
this.id = builder.id;
21+
22+
}
23+
24+
public static PetDTO.PetDTOBuilder builder() {
25+
return new PetDTO.PetDTOBuilder();
26+
}
27+
28+
@JsonPOJOBuilder(buildMethodName = "build", withPrefix = "")
29+
public static class PetDTOBuilder {
30+
31+
private String name;
32+
private Long id;
33+
34+
public PetDTO.PetDTOBuilder name(String name) {
35+
this.name = name;
36+
return this;
37+
}
38+
39+
public PetDTO.PetDTOBuilder id(Long id) {
40+
this.id = id;
41+
return this;
42+
}
43+
44+
public PetDTO build() {
45+
PetDTO petDTO = new PetDTO(this);
46+
return petDTO;
47+
}
48+
}
49+
50+
@Schema(name = "name", required = false)
51+
public String getName() {
52+
return name;
53+
}
54+
public void setName(String name) {
55+
this.name = name;
56+
}
57+
58+
@Schema(name = "id", required = false)
59+
public Long getId() {
60+
return id;
61+
}
62+
public void setId(Long id) {
63+
this.id = id;
64+
}
65+
66+
@Override
67+
public boolean equals(Object o) {
68+
if (this == o) {
69+
return true;
70+
}
71+
if (o == null || getClass() != o.getClass()) {
72+
return false;
73+
}
74+
PetDTO petDTO = (PetDTO) o;
75+
return Objects.equals(this.name, petDTO.name) && Objects.equals(this.id, petDTO.id);
76+
}
77+
78+
@Override
79+
public int hashCode() {
80+
return Objects.hash(name, id);
81+
}
82+
83+
@Override
84+
public String toString() {
85+
StringBuilder sb = new StringBuilder();
86+
sb.append("PetDTO{");
87+
sb.append(" name:").append(name).append(",");
88+
sb.append(" id:").append(id);
89+
sb.append("}");
90+
return sb.toString();
91+
}
92+
93+
94+
}

0 commit comments

Comments
 (0)