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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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`
Expand Down
10 changes: 10 additions & 0 deletions docs/NAMING_AND_DETERMINISM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions docs/TECHNICAL_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down
27 changes: 26 additions & 1 deletion src/openapi_get_avro/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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, ...] = (
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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(
Expand Down
86 changes: 67 additions & 19 deletions src/openapi_get_avro/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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:
Expand Down Expand Up @@ -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}")
Expand All @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions src/openapi_get_avro/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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"
Expand Down
85 changes: 85 additions & 0 deletions tests/test_cli_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down
Loading