diff --git a/README.md b/README.md index 32d0975..ce3955e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ uv run openapi-get-avro generate \ --name-strategy operationId \ --include-status-codes 200,206,default \ --content-type application/json \ + --field-name-case snake_case \ --any-of-policy fail \ --enum-policy fail \ --unknown-object-policy fail \ @@ -74,6 +75,7 @@ uv run openapi-get-avro generate \ Accepted CLI values: - `--name-strategy`: `operationId` or `path` +- `--field-name-case`: `preserve`, `snake_case`, `camelCase`, or `PascalCase`; transforms response payload field names only - `--any-of-policy`: `fail` or `union` - `--enum-policy`: `fail`, `string`, or `sanitize` - `--unknown-object-policy`: `fail`, `map`, `string`, or `empty-record` diff --git a/docs/NAMING_AND_DETERMINISM.md b/docs/NAMING_AND_DETERMINISM.md index f2c1979..a9ada3b 100644 --- a/docs/NAMING_AND_DETERMINISM.md +++ b/docs/NAMING_AND_DETERMINISM.md @@ -35,6 +35,16 @@ GetMatchResponse.venue -> GetMatchResponseVenue GetMatchResponse.participants[] -> GetMatchResponseParticipantsItem ``` +## Field name case + +By default, OpenAPI response payload field names are preserved. With +`--field-name-case`, payload fields can be emitted as `snake_case`, `camelCase`, +or `PascalCase`. This does not change record names, enum names, enum symbols, or +the fixed root envelope fields. + +If two source properties transform to the same Avro field name, generation fails +instead of silently dropping or merging a field. + ## Deduplication If two generated names collide but refer to different schemas, append deterministic suffixes: diff --git a/docs/TECHNICAL_SPEC.md b/docs/TECHNICAL_SPEC.md index 9f5230d..090d439 100644 --- a/docs/TECHNICAL_SPEC.md +++ b/docs/TECHNICAL_SPEC.md @@ -18,6 +18,7 @@ Optional options: --content-type Response content type. Default: application/json. --strict / --lenient Strict mode fails on ambiguous constructs. Default: strict. --name-strategy operationId or path. Default: operationId. +--field-name-case preserve, snake_case, camelCase, or PascalCase. Default: preserve. --any-of-policy fail or union. Default: fail. --enum-policy fail, string, or sanitize. Default: fail. --unknown-object-policy fail, map, string, or empty-record. Default: fail. @@ -143,6 +144,11 @@ When `--remove-name-suffixes` is configured, remove exact trailing suffix matche from generated Avro named types after converting the source text to Avro name shape. Do not mutate field/property names. +When `--field-name-case` is configured, transform OpenAPI response payload field +names to the selected case after reading requiredness from the original OpenAPI +property names. Do not mutate generated record names or enum names. The fixed +root envelope fields remain unchanged. + ## Validation After generating the schema, validate it with `fastavro.parse_schema`. diff --git a/src/openapi_get_avro/cli.py b/src/openapi_get_avro/cli.py index 8c287e0..fb78946 100644 --- a/src/openapi_get_avro/cli.py +++ b/src/openapi_get_avro/cli.py @@ -12,7 +12,14 @@ from .converter import convert_openapi_to_avro from .exceptions import OpenApiAvroError -from .models import AnyOfPolicy, EnumPolicy, GenerationOptions, NameStrategy, UnknownObjectPolicy +from .models import ( + AnyOfPolicy, + EnumPolicy, + FieldNameCase, + GenerationOptions, + NameStrategy, + UnknownObjectPolicy, +) app = typer.Typer( no_args_is_help=True, help="Convert OpenAPI GET responses to Avro envelope schema" @@ -22,6 +29,12 @@ AVRO_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") NAME_STRATEGIES: tuple[NameStrategy, ...] = ("operationId", "path") +FIELD_NAME_CASES: tuple[FieldNameCase, ...] = ( + "preserve", + "snake_case", + "camelCase", + "PascalCase", +) ANY_OF_POLICIES: tuple[AnyOfPolicy, ...] = ("fail", "union") ENUM_POLICIES: tuple[EnumPolicy, ...] = ("fail", "string", "sanitize") UNKNOWN_OBJECT_POLICIES: tuple[UnknownObjectPolicy, ...] = ( @@ -107,6 +120,13 @@ def generate( help="Response naming strategy: operationId or path", ), ] = "operationId", + field_name_case: Annotated[ + str, + typer.Option( + "--field-name-case", + help="Payload field name case: preserve, snake_case, camelCase, or PascalCase", + ), + ] = "preserve", any_of_policy: Annotated[ str, typer.Option("--any-of-policy", help="anyOf handling policy: fail or union"), @@ -140,6 +160,11 @@ def generate( content_type=content_type, strict=strict, name_strategy=_parse_choice(name_strategy, NAME_STRATEGIES, "--name-strategy"), + field_name_case=_parse_choice( + field_name_case, + FIELD_NAME_CASES, + "--field-name-case", + ), any_of_policy=_parse_choice(any_of_policy, ANY_OF_POLICIES, "--any-of-policy"), enum_policy=_parse_choice(enum_policy, ENUM_POLICIES, "--enum-policy"), unknown_object_policy=_parse_choice( diff --git a/src/openapi_get_avro/converter.py b/src/openapi_get_avro/converter.py index 4dd5a4c..f3b7e01 100644 --- a/src/openapi_get_avro/converter.py +++ b/src/openapi_get_avro/converter.py @@ -330,27 +330,17 @@ def _object_to_avro( required_names = set(required) fields: list[JsonDict] = [] + avro_field_names: set[str] = set() for field_name, field_schema in properties.items(): - if not isinstance(field_name, str): - raise InvalidOpenApiError(f"Object schema {name_hint} has a non-string field name") - self._require_avro_name(field_name, f"field {name_hint}.{field_name}") - if not isinstance(field_schema, dict): - raise UnsupportedSchemaError( - f"Field schema {name_hint}.{field_name} must be an object" + fields.append( + self._property_to_field( + field_name, + field_schema, + name_hint=name_hint, + required_names=required_names, + avro_field_names=avro_field_names, ) - - avro_type = self._schema_to_avro(field_schema, f"{name_hint}{self._pascal(field_name)}") - nullable = field_name not in required_names - if nullable and not self._is_null_union(avro_type): - avro_type = self._prepend_null(avro_type) - - field: JsonDict = {"name": field_name, "type": avro_type} - if self._is_null_union(avro_type): - field["default"] = None - description = field_schema.get("description") - if isinstance(description, str): - field["doc"] = description - fields.append(field) + ) record_name = self._record_name(name_hint, name_identity, schema) record: JsonDict = {"type": "record", "name": record_name} @@ -360,6 +350,40 @@ def _object_to_avro( record["fields"] = fields return record + def _property_to_field( + self, + field_name: Any, + field_schema: Any, + *, + name_hint: str, + required_names: set[str], + avro_field_names: set[str], + ) -> JsonDict: + if not isinstance(field_name, str): + raise InvalidOpenApiError(f"Object schema {name_hint} has a non-string field name") + avro_field_name = self._field_name(field_name, name_hint) + if avro_field_name in avro_field_names: + raise AvroNameError( + f"Field name transform produced duplicate Avro field " + f"{name_hint}.{avro_field_name!r}" + ) + avro_field_names.add(avro_field_name) + if not isinstance(field_schema, dict): + raise UnsupportedSchemaError(f"Field schema {name_hint}.{field_name} must be an object") + + avro_type = self._schema_to_avro(field_schema, f"{name_hint}{self._pascal(field_name)}") + nullable = field_name not in required_names + if nullable and not self._is_null_union(avro_type): + avro_type = self._prepend_null(avro_type) + + field: JsonDict = {"name": avro_field_name, "type": avro_type} + if self._is_null_union(avro_type): + field["default"] = None + description = field_schema.get("description") + if isinstance(description, str): + field["doc"] = description + return field + def _record_name( self, name_hint: str, name_identity: NameIdentity | None, schema: JsonDict ) -> str: @@ -681,6 +705,17 @@ def _path_name(self, method: str, path: str) -> str: parts.append(self._pascal(segment)) return "".join(parts) + def _field_name(self, text: str, parent_name: str) -> str: + if self.options.field_name_case == "preserve": + return self._require_avro_name(text, f"field {parent_name}.{text}") + if self.options.field_name_case == "snake_case": + name = self._snake(text) + elif self.options.field_name_case == "camelCase": + name = self._camel(text) + else: + name = self._pascal(text) + return self._require_avro_name(name, f"field {parent_name}.{text}") + def _enum_symbol_from_text(self, text: str) -> str: symbol = "_".join(self._words(text)).upper() return self._require_enum_symbol(symbol, f"entity type derived from {text!r}") @@ -694,6 +729,19 @@ def _pascal(self, text: str) -> str: name = f"N{name}" return name + def _camel(self, text: str) -> str: + pascal = self._pascal(text) + return f"{pascal[:1].lower()}{pascal[1:]}" + + def _snake(self, text: str) -> str: + words = self._words(text) + if not words: + raise AvroNameError(f"Cannot derive an Avro field name from {text!r}") + name = "_".join(words) + if name[0].isdigit(): + name = f"n_{name}" + return name + def _named_type_base(self, text: str, context: str) -> str: return self._strip_configured_suffixes(self._pascal(text), context, source_text=text) diff --git a/src/openapi_get_avro/models.py b/src/openapi_get_avro/models.py index 51e5c83..44f3ed7 100644 --- a/src/openapi_get_avro/models.py +++ b/src/openapi_get_avro/models.py @@ -6,6 +6,7 @@ from typing import Literal NameStrategy = Literal["operationId", "path"] +FieldNameCase = Literal["preserve", "snake_case", "camelCase", "PascalCase"] UnknownObjectPolicy = Literal["map", "string", "empty-record", "fail"] AnyOfPolicy = Literal["fail", "union"] EnumPolicy = Literal["fail", "string", "sanitize"] @@ -21,6 +22,7 @@ class GenerationOptions: content_type: str = "application/json" strict: bool = True name_strategy: NameStrategy = "operationId" + field_name_case: FieldNameCase = "preserve" unknown_object_policy: UnknownObjectPolicy = "fail" any_of_policy: AnyOfPolicy = "fail" enum_policy: EnumPolicy = "fail" diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 017437d..6ffcb46 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -58,6 +58,7 @@ def test_cli_help_exposes_generation_policy_options() -> None: assert "--enum-policy" in help_output assert "--unknown-object-policy" in help_output assert "--remove-name-suffixes" in help_output + assert "--field-name-case" in help_output def test_cli_include_status_codes_preserves_requested_order(tmp_path: Path) -> None: @@ -198,6 +199,90 @@ def test_cli_name_strategy_path_overrides_operation_id(tmp_path: Path) -> None: assert data_field["type"][0]["name"] == "GetMatchesByMatchIdLineupsResponse" +def test_cli_field_name_case_transforms_payload_field_names(tmp_path: Path) -> None: + runner = CliRunner() + input_path = tmp_path / "field-case.openapi.json" + output_path = tmp_path / "schema.avsc" + input_path.write_text( + json.dumps( + { + "openapi": "3.0.3", + "info": {"title": "Field Case API"}, + "paths": { + "/lineups": { + "get": { + "operationId": "getLineup", + "tags": ["Lineup"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lineupVersion": {"type": "integer"}, + }, + } + } + } + } + }, + } + } + }, + } + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, + [ + "generate", + "--input", + str(input_path), + "--namespace", + "com.example.fieldcase", + "--rootname", + "FieldCaseEnvelope", + "--field-name-case", + "snake_case", + "--output", + str(output_path), + ], + ) + + assert result.exit_code == 0, result.output + actual = json.loads(output_path.read_text(encoding="utf-8")) + data_field = actual["fields"][-1] + branch = data_field["type"][0] + assert branch["fields"][0]["name"] == "lineup_version" + + +def test_cli_rejects_invalid_field_name_case() -> None: + runner = CliRunner() + + result = runner.invoke( + app, + [ + "generate", + "--input", + str(FIXTURES / "minimal.openapi.json"), + "--namespace", + "com.example.sports", + "--rootname", + "SportsEnvelope", + "--field-name-case", + "kebab-case", + ], + ) + + assert result.exit_code != 0 + error_output = _strip_ansi(result.output) + assert "--field-name-case must be one of: preserve, snake_case" in error_output + assert "camelCase, PascalCase" in error_output + + def test_cli_rejects_empty_remove_name_suffix() -> None: runner = CliRunner() diff --git a/tests/test_naming.py b/tests/test_naming.py index c0d092e..ffb411a 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -202,6 +202,149 @@ def test_remove_name_suffixes_renames_component_refs_and_response_records() -> N assert fields["roleDto"]["type"]["name"] == "GetAttributeResponseRole" +def test_field_name_case_snake_case_transforms_payload_fields_only() -> None: + schema = convert_openapi_to_avro( + _base_doc( + { + "/lineups": { + "get": { + "operationId": "getLineupDto", + "tags": ["Lineup"], + "responses": _json_response( + { + "type": "object", + "required": ["lineupVersion", "status"], + "properties": { + "lineupVersion": {"type": "integer"}, + "status": { + "type": "string", + "enum": ["Draft", "Published"], + }, + "teamMember": { + "type": "object", + "required": ["displayName"], + "properties": { + "displayName": {"type": "string"}, + }, + }, + }, + } + ), + } + } + } + ), + GenerationOptions( + namespace="com.example.naming", + root_name="NamingEnvelope", + field_name_case="snake_case", + ), + ) + + root_fields = schema["fields"] + assert isinstance(root_fields, list) + assert [field["name"] for field in root_fields if isinstance(field, dict)] == [ + "id", + "timestamp", + "operation", + "entity_type", + "data", + ] + + branch = _data_branches(schema)[0] + assert isinstance(branch, dict) + assert branch["name"] == "GetLineupDtoResponse" + fields = _record_fields(branch) + assert list(fields) == ["lineup_version", "status", "team_member"] + assert fields["status"]["type"] == { + "type": "enum", + "name": "GetLineupDtoResponseStatusEnum", + "symbols": ["Draft", "Published"], + } + team_member = fields["team_member"]["type"] + assert isinstance(team_member, list) + team_member_record = team_member[1] + assert isinstance(team_member_record, dict) + assert team_member_record["name"] == "GetLineupDtoResponseTeamMember" + assert list(_record_fields(team_member_record)) == ["display_name"] + + +def test_field_name_case_supports_camel_and_pascal_case() -> None: + openapi_doc = _base_doc( + { + "/lineups": { + "get": { + "operationId": "getLineup", + "tags": ["Lineup"], + "responses": _json_response( + { + "type": "object", + "properties": { + "lineup_version": {"type": "integer"}, + "home-team-id": {"type": "string"}, + }, + } + ), + } + } + } + ) + + camel = convert_openapi_to_avro( + openapi_doc, + GenerationOptions( + namespace="com.example.naming", + root_name="NamingEnvelope", + field_name_case="camelCase", + ), + ) + pascal = convert_openapi_to_avro( + openapi_doc, + GenerationOptions( + namespace="com.example.naming", + root_name="NamingEnvelope", + field_name_case="PascalCase", + ), + ) + + camel_branch = _data_branches(camel)[0] + pascal_branch = _data_branches(pascal)[0] + assert isinstance(camel_branch, dict) + assert isinstance(pascal_branch, dict) + assert list(_record_fields(camel_branch)) == ["lineupVersion", "homeTeamId"] + assert list(_record_fields(pascal_branch)) == ["LineupVersion", "HomeTeamId"] + + +def test_field_name_case_fails_when_transform_creates_duplicate_field_names() -> None: + with pytest.raises(AvroNameError, match="duplicate Avro field"): + convert_openapi_to_avro( + _base_doc( + { + "/lineups": { + "get": { + "operationId": "getLineup", + "tags": ["Lineup"], + "responses": _json_response( + { + "type": "object", + "properties": { + "lineupVersion": {"type": "integer"}, + "lineup_version": {"type": "integer"}, + }, + } + ), + } + } + } + ), + GenerationOptions( + namespace="com.example.naming", + root_name="NamingEnvelope", + field_name_case="snake_case", + ), + ) + + def test_remove_name_suffixes_resolves_collisions_deterministically() -> None: schema = convert_openapi_to_avro( _base_doc(