Skip to content

Commit 61ae0da

Browse files
HNygardpre-commit-ci[bot]HNygardStbkoxudaxi
authored
Add support for webhooks in --openapi-scopes (#2481)
* Add support for `webhooks` in `--openapi-scopes` This PR adds support for the `webhooks` scope in the `--openapi-scopes` CLI option to handle OpenAPI Documents that have a `webhooks` root element instead of or in addition to `paths` and `components`. ## Background According to the [OpenAPI 3.1 specification](https://spec.openapis.org/oas/latest.html#oasWebhooks), OpenAPI documents can contain a `webhooks` root element that describes incoming webhooks. These webhooks have a similar structure to paths - they contain operations with `requestBody` that have content `schema` attributes, making them suitable for model generation. ## Changes Made ### 1. Added `Webhooks` to OpenAPIScope enum ```python class OpenAPIScope(Enum): Schemas = "schemas" Paths = "paths" Tags = "tags" Parameters = "parameters" Webhooks = "webhooks" # NEW ``` ### 2. Implemented webhooks processing in OpenAPI parser Added logic in the `parse_raw()` method to process webhooks similar to how paths are processed: ```python if OpenAPIScope.Webhooks in self.open_api_scopes: webhooks: dict[str, dict[str, Any]] = specification.get("webhooks", {}) webhooks_path = [*path_parts, "#/webhooks"] for webhook_name, methods_ in webhooks.items(): # Process each webhook operation for operation_name, raw_operation in methods.items(): if operation_name not in OPERATION_NAMES: continue self.parse_operation(raw_operation, [*path, operation_name]) ``` ### 3. Added comprehensive test coverage - Created `tests/data/openapi/webhooks.yaml` with a complete OpenAPI spec containing webhooks using dotted naming convention (`pet.new`, `pet.updated`) - Added test functions to validate webhooks-only and combined scope processing - Created expected output files for test validation ## Usage Examples ```bash # Generate models from webhooks only datamodel-codegen --input spec.yaml --openapi-scopes webhooks # Generate models from schemas and webhooks datamodel-codegen --input spec.yaml --openapi-scopes schemas webhooks # Generate models from all scopes including webhooks datamodel-codegen --input spec.yaml --openapi-scopes schemas paths tags parameters webhooks ``` ## Benefits - ✅ Support for OpenAPI 3.1+ webhooks specification - ✅ Generate Pydantic models for webhook request bodies - ✅ Reuse existing component schemas in webhook definitions - ✅ Flexible scope combinations (webhooks can be used alone or with other scopes) - ✅ Same robust processing as paths (handles references, nested objects, validation, etc.) - ✅ Supports dotted webhook naming convention (e.g., `pet.new`, `user.updated`) - ✅ Backward compatible - no breaking changes to existing functionality The implementation reuses the existing operation processing logic since webhooks have the same structure as path operations, ensuring consistency and reliability. Change made with Github Copilot. Verified locally on own data in addition to the tests created. Fixes #2059 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Ignore PLR0912 * Removed PetNewPostRequest and PetUpdatedPostRequest classes. * Add test coverage * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Readme * Refactor test assertion to use assert_output for better readability * Add tests for OpenAPI generation with non-operation fields and webhooks * Enhance OpenAPI generation to support webhook-level parameters * Add support for X-Correlation-Id in pet.updated webhook parameters --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Hallvard Nygård <70267909+HNygardStb@users.noreply.github.com> Co-authored-by: Koudai Aono <koxudaxi@gmail.com>
1 parent 85bfefd commit 61ae0da

11 files changed

Lines changed: 315 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ OpenAPI-only options:
512512
--include-path-parameters
513513
Include path parameters in generated parameter models in addition to
514514
query parameters (Only OpenAPI)
515-
--openapi-scopes {schemas,paths,tags,parameters} [{schemas,paths,tags,parameters} ...]
515+
--openapi-scopes {schemas,paths,tags,parameters,webhooks} [{schemas,paths,tags,parameters,webhooks} ...]
516516
Scopes of OpenAPI model generation (default: schemas)
517517
--strict-nullable Treat default field as a non-nullable field (Only OpenAPI)
518518
--use-operation-id-as-name

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ OpenAPI-only options:
504504
--include-path-parameters
505505
Include path parameters in generated parameter models in addition to
506506
query parameters (Only OpenAPI)
507-
--openapi-scopes {schemas,paths,tags,parameters} [{schemas,paths,tags,parameters} ...]
507+
--openapi-scopes {schemas,paths,tags,parameters,webhooks} [{schemas,paths,tags,parameters,webhooks} ...]
508508
Scopes of OpenAPI model generation (default: schemas)
509509
--strict-nullable Treat default field as a non-nullable field (Only OpenAPI)
510510
--use-operation-id-as-name

src/datamodel_code_generator/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ class OpenAPIScope(Enum):
215215
Paths = "paths"
216216
Tags = "tags"
217217
Parameters = "parameters"
218+
Webhooks = "webhooks"
218219

219220

220221
class GraphQLScope(Enum):

src/datamodel_code_generator/parser/openapi.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
JsonSchemaParser,
3535
get_model_by_path,
3636
)
37-
from datamodel_code_generator.reference import snake_to_upper_camel
37+
from datamodel_code_generator.reference import FieldNameResolver, snake_to_upper_camel
3838
from datamodel_code_generator.types import (
3939
DataType,
4040
DataTypeManager,
@@ -457,9 +457,12 @@ def parse_tags(
457457
"""Parse operation tags."""
458458
return tags
459459

460+
_field_name_resolver: FieldNameResolver = FieldNameResolver()
461+
460462
@classmethod
461463
def _get_model_name(cls, path_name: str, method: str, suffix: str) -> str:
462-
camel_path_name = snake_to_upper_camel(path_name.replace("/", "_"))
464+
normalized = cls._field_name_resolver.get_valid_name(path_name, ignore_snake_case_field=True)
465+
camel_path_name = snake_to_upper_camel(normalized)
463466
return f"{camel_path_name}{method.capitalize()}{suffix}"
464467

465468
def parse_all_parameters(
@@ -615,7 +618,7 @@ def parse_operation(
615618
path=[*path, "tags"],
616619
)
617620

618-
def parse_raw(self) -> None: # noqa: PLR0912
621+
def parse_raw(self) -> None: # noqa: PLR0912, PLR0915
619622
"""Parse OpenAPI specification including schemas, paths, and operations."""
620623
for source, path_parts in self._get_context_source_path_parts(): # noqa: PLR1702
621624
if self.validation:
@@ -687,4 +690,36 @@ def parse_raw(self) -> None: # noqa: PLR0912
687690
[*path, operation_name],
688691
)
689692

693+
if OpenAPIScope.Webhooks in self.open_api_scopes:
694+
webhooks: dict[str, dict[str, Any]] = specification.get("webhooks", {})
695+
webhooks_path = [*path_parts, "#/webhooks"]
696+
for webhook_name, methods_ in webhooks.items():
697+
methods = self.get_ref_model(methods_["$ref"]) if "$ref" in methods_ else methods_
698+
webhook_parameters: list[dict[str, Any]] = []
699+
if "parameters" in methods:
700+
webhook_parameters = [
701+
self._get_ref_body(p["$ref"]) if "$ref" in p else p
702+
for p in methods["parameters"]
703+
if isinstance(p, dict)
704+
]
705+
relative_webhook_name = webhook_name.removeprefix("/")
706+
if relative_webhook_name:
707+
path = [*webhooks_path, relative_webhook_name]
708+
else: # pragma: no cover
709+
path = get_special_path("root", webhooks_path)
710+
for operation_name, raw_operation in methods.items():
711+
if operation_name not in OPERATION_NAMES:
712+
continue
713+
if webhook_parameters:
714+
if "parameters" in raw_operation:
715+
raw_operation["parameters"].extend(webhook_parameters)
716+
else:
717+
raw_operation["parameters"] = webhook_parameters.copy()
718+
if security is not None and "security" not in raw_operation:
719+
raw_operation["security"] = security
720+
self.parse_operation(
721+
raw_operation,
722+
[*path, operation_name],
723+
)
724+
690725
self._resolve_unparsed_json_pointer()
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# generated by datamodel-codegen:
2+
# filename: non_operations_and_security.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import List, Optional
8+
9+
from pydantic import BaseModel
10+
11+
12+
class Pet(BaseModel):
13+
id: Optional[int] = None
14+
name: Optional[str] = None
15+
16+
17+
class PetsGetResponse(BaseModel):
18+
__root__: List[Pet]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# generated by datamodel-codegen:
2+
# filename: webhooks.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Optional
8+
9+
from pydantic import BaseModel
10+
11+
12+
class Pet(BaseModel):
13+
id: int
14+
name: str
15+
tag: Optional[str] = None
16+
17+
18+
class PetUpdate(BaseModel):
19+
id: Optional[int] = None
20+
name: Optional[str] = None
21+
tag: Optional[str] = None
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# generated by datamodel-codegen:
2+
# filename: webhooks_with_parameters.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Optional
8+
9+
from pydantic import BaseModel, Field
10+
11+
12+
class Pet(BaseModel):
13+
id: int
14+
name: str
15+
16+
17+
class PetNewPostParametersQuery(BaseModel):
18+
X_Request_Id: Optional[str] = Field(None, alias='X-Request-Id')
19+
X_Webhook_Id: str = Field(..., alias='X-Webhook-Id')
20+
21+
22+
class PetUpdatedPostParametersQuery(BaseModel):
23+
X_Correlation_Id: str = Field(..., alias='X-Correlation-Id')
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
openapi: 3.1.0
2+
info:
3+
title: API with Non-Operations and Security
4+
version: 1.0.0
5+
security:
6+
- api_key: []
7+
paths:
8+
/pets:
9+
summary: "This is not an operation"
10+
description: "Also not an operation"
11+
parameters:
12+
- name: test_param
13+
in: query
14+
schema:
15+
type: string
16+
get:
17+
summary: Get pets
18+
responses:
19+
'200':
20+
description: Success
21+
content:
22+
application/json:
23+
schema:
24+
type: array
25+
items:
26+
$ref: '#/components/schemas/Pet'
27+
post:
28+
summary: Create pet
29+
security:
30+
- oauth2: ['write']
31+
requestBody:
32+
content:
33+
application/json:
34+
schema:
35+
$ref: '#/components/schemas/Pet'
36+
responses:
37+
'201':
38+
description: Created
39+
webhooks:
40+
pet.created:
41+
summary: "Not an operation for webhooks"
42+
description: "Also not an operation for webhooks"
43+
post:
44+
requestBody:
45+
content:
46+
application/json:
47+
schema:
48+
$ref: '#/components/schemas/Pet'
49+
responses:
50+
'200':
51+
description: Success
52+
components:
53+
schemas:
54+
Pet:
55+
type: object
56+
properties:
57+
id:
58+
type: integer
59+
name:
60+
type: string
61+
securitySchemes:
62+
api_key:
63+
type: apiKey
64+
in: header
65+
name: X-API-Key
66+
oauth2:
67+
type: oauth2
68+
flows:
69+
clientCredentials:
70+
tokenUrl: /token
71+
scopes:
72+
write: Write access

tests/data/openapi/webhooks.yaml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
openapi: 3.1.0
2+
info:
3+
title: Webhook Test API
4+
version: 1.0.0
5+
webhooks:
6+
pet.new:
7+
post:
8+
requestBody:
9+
description: Information about a new pet in the system
10+
content:
11+
application/json:
12+
schema:
13+
$ref: "#/components/schemas/Pet"
14+
responses:
15+
'200':
16+
description: Return a 200 status to indicate that the data was received successfully
17+
pet.updated:
18+
post:
19+
requestBody:
20+
description: Information about an updated pet
21+
content:
22+
application/json:
23+
schema:
24+
$ref: "#/components/schemas/PetUpdate"
25+
responses:
26+
'200':
27+
description: Return a 200 status to indicate that the data was received successfully
28+
components:
29+
schemas:
30+
Pet:
31+
type: object
32+
required:
33+
- id
34+
- name
35+
properties:
36+
id:
37+
type: integer
38+
format: int64
39+
name:
40+
type: string
41+
tag:
42+
type: string
43+
PetUpdate:
44+
type: object
45+
properties:
46+
id:
47+
type: integer
48+
format: int64
49+
name:
50+
type: string
51+
tag:
52+
type: string
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
openapi: 3.1.0
2+
info:
3+
title: Webhook Test API with Parameters
4+
version: 1.0.0
5+
webhooks:
6+
pet.new:
7+
parameters:
8+
- name: X-Webhook-Id
9+
in: query
10+
required: true
11+
schema:
12+
type: string
13+
post:
14+
parameters:
15+
- name: X-Request-Id
16+
in: query
17+
required: false
18+
schema:
19+
type: string
20+
requestBody:
21+
content:
22+
application/json:
23+
schema:
24+
$ref: "#/components/schemas/Pet"
25+
responses:
26+
'200':
27+
description: OK
28+
pet.updated:
29+
parameters:
30+
- name: X-Correlation-Id
31+
in: query
32+
required: true
33+
schema:
34+
type: string
35+
post:
36+
requestBody:
37+
content:
38+
application/json:
39+
schema:
40+
$ref: "#/components/schemas/Pet"
41+
responses:
42+
'200':
43+
description: OK
44+
components:
45+
schemas:
46+
Pet:
47+
type: object
48+
required:
49+
- id
50+
- name
51+
properties:
52+
id:
53+
type: integer
54+
name:
55+
type: string

0 commit comments

Comments
 (0)