Skip to content

feat(codegen): support non-conforming OpenAPI property keys in generated models - #67

Merged
davidkallesen merged 5 commits into
mainfrom
feature/underscore-support-in-models
Aug 28, 2026
Merged

feat(codegen): support non-conforming OpenAPI property keys in generated models#67
davidkallesen merged 5 commits into
mainfrom
feature/underscore-support-in-models

Conversation

@davidkallesen

Copy link
Copy Markdown
Contributor

Summary

Generated models normalize OpenAPI property keys to PascalCase C# names. When the original key
does not round-trip through the default camelCase naming policy, System.Text.Json binding
silently breaks — properties deserialize as null with no error at compile time or runtime.

This affects any spec using underscores, dots or custom acronym casing in property keys. The
Eloverblik API in sample/ThridParty-Typed-Clients/EloverblikThirdPartyApiClient is a
real-world example:

OpenAPI key Generated name Bound before
MyEnergyData_MarketDocument MyEnergyDataMarketDocument
sender_MarketParticipant.name SenderMarketParticipantName
sender_MarketParticipant.mRID SenderMarketParticipantMRid
period.timeInterval PeriodTimeInterval
measurement_Unit.name MeasurementUnitName
out_Quantity.quantity OutQuantityQuantity
mRID MRid
createdDateTime CreatedDateTime

The fix emits [property: JsonPropertyName("<original key>")] whenever the original key does not
match the default convention, leaving conforming keys untouched so existing output is unchanged.

Changes

Detection — new JsonPropertyNameHelper decides whether a property needs an explicit
attribute: false on an exact ordinal match or when the key equals the camelCased property name,
true otherwise.

Emission — wired into all five record extraction paths: ExtractGenericPaginatedRecord,
ExtractRecordFromSchema and ExtractRecordFromSchemaWithInlineEnums in SchemaExtractor, plus
both entry points in InlineSchemaExtractor via a shared BuildAttributes helper. The
JsonPropertyName attribute is placed first so validation attributes append cleanly.

Using directives — resolved independently by the two generation paths, so both needed
updating:

  • Source-generator path: SchemaExtractor.BuildHeaderContent
  • CLI / file-based path: CodeGenerationService.GenerateModels via a new
    UsingStatementHelper.RecordUsesJsonPropertyName

The second was a genuine latent bug — CLI-generated models referenced JsonPropertyName without
importing it and failed to compile. It was only surfaced by writing the integration test.

Example output

public sealed record MyEnergyDataMarketDocument(
    [property: JsonPropertyName("mRID")] string? MRid,
    string? CreatedDateTime,
    [property: JsonPropertyName("sender_MarketParticipant.name")] string? SenderMarketParticipantName,
    [property: JsonPropertyName("sender_MarketParticipant.mRID")] EIC SenderMarketParticipantMRid,
    [property: JsonPropertyName("period.timeInterval")] PeriodtimeInterval PeriodTimeInterval,
    List<TimeSeries>? TimeSeries);

Testing

Layer Coverage
JsonPropertyNameHelperTests Naming rules: exact match, camelCase equivalence, underscore/dot keys, leading-lowercase acronyms, null/empty guards
GenerateContentForRecordsTests Atc.CodeGeneration.CSharp renders property-targeted attributes, alone and combined with validation attributes
SchemaExtractorJsonPropertyNameTests, InlineSchemaExtractorTests Attribute emission and ordering across every extraction path
JsonPropertyNameGenerationTests End-to-end CodeGenerationService.GenerateModels output, including RequiredUsings
RecordSerializationTests The generated contract actually serializes and deserializes
MeterDataGetTimeSeriesPayloadTests Real production payload regression guard

The last one is the strongest guard. It uses an actual response body from
POST /thirdpartyapi/api/meterdata/gettimeseries/{dateFrom}/{dateTo}/{aggregation} — a five-level
object graph with 96 quarter-hour data points. GeneratedModels_CanMapEveryPropertyKeyInRealPayload
runs SchemaExtractor over the real api-1.yaml, derives the set of bindable keys from the emitted
attributes, recursively collects every key in the payload and asserts none are left unmapped. This
ties the fixture directly to generator output rather than to hand-written mirrors, so it will fail
if the generator ever regresses.

Full suite green. Sample EloverblikThirdPartyApiClient builds with 0 warnings and emits the
expected attributes.

Backwards compatibility

No change for specs whose property keys already follow camelCase or PascalCase conventions — the
helper returns false and no attribute is emitted. Specs that previously produced silently broken
models will now produce correct ones, changing generated output but only where it was already wrong.

Commits

Commit Scope
17ccdf95 feat(codegen): add JsonPropertyNameHelper
3ebf9036 feat(codegen): emit the attribute across extraction paths
a00319d6 fix(codegen): System.Text.Json.Serialization using on the file-based path
86c541f2 test(codegen): round-trip verification
e40dfbec test(codegen): real Eloverblik payload guard

…operty keys

OpenAPI property keys are normalized to PascalCase C# names, which silently breaks
System.Text.Json binding whenever the original key does not round-trip through the
default camelCase naming policy - for example underscores, dots and custom acronym
casing such as `MyEnergyData_MarketDocument`, `sender_MarketParticipant.name`,
`period.timeInterval` and `mRID`.

Add JsonPropertyNameHelper with:

- RequiresJsonPropertyName(jsonKey, csharpPropertyName), returning false for an exact
  ordinal match or when the key equals the camelCased property name, and true otherwise.
- CreateJsonPropertyNameAttribute(jsonKey), producing the AttributeParameters used by
  the record generators.

No call sites yet; wiring follows in a later commit.
…roperty keys

Wire JsonPropertyNameHelper into every record extraction path so generated models bind
the original OpenAPI wire keys instead of the normalized C# names.

- SchemaExtractor: ExtractGenericPaginatedRecord, ExtractRecordFromSchema and
  ExtractRecordFromSchemaWithInlineEnums now build a combined attribute list, with the
  JsonPropertyName attribute first so validation attributes append cleanly, and pass
  null when the list ends up empty.
- SchemaExtractor.BuildHeaderContent adds `using System.Text.Json.Serialization;` when
  any emitted parameter carries the attribute.
- InlineSchemaExtractor: both extraction sites delegate to a new private BuildAttributes
  helper mirroring the SchemaExtractor behaviour.

The raw `prop.Key` is always used as the JSON key, so properties renamed to avoid a
collision with their enclosing type (Status -> StatusValue) stay correctly mapped.
…sed model path

Using directives are resolved independently by the two generation paths:
SchemaExtractor.BuildHeaderContent for the source generator, and
GeneratedType.RequiredUsings for the CLI/file-based path in
CodeGenerationService.GenerateModels. Only the former had been updated, so models
emitted through the CLI referenced JsonPropertyName without importing it and failed
to compile.

Add UsingStatementHelper.RecordUsesJsonPropertyName(record) to detect the attribute on
the record model, and consume it in GenerateModels alongside the existing
RecordUsesSystemTypes check.
- JsonPropertyNameGenerationTests: drive CodeGenerationService.GenerateModels end to end
  and assert both the exact emitted parameter text and that
  System.Text.Json.Serialization lands in GeneratedType.RequiredUsings. Note that
  GeneratedType.Content holds the type body only, so using directives must be asserted
  against RequiredUsings.
- RecordSerializationTests: prove the generated contract actually serializes and
  deserializes, using records that mirror the generator output.
…payload

Capture a production response body from
POST /thirdpartyapi/api/meterdata/gettimeseries/{dateFrom}/{dateTo}/{aggregation} and
use it as a regression fixture. The payload is dense with non-conforming keys -
MyEnergyData_MarketDocument, mRID, sender_MarketParticipant.mRID, period.timeInterval,
measurement_Unit.name and out_Quantity.quantity - across a five-level object graph.

- GeneratedModels_CanMapEveryPropertyKeyInRealPayload runs SchemaExtractor over the real
  api-1.yaml, derives the set of bindable keys from the emitted JsonPropertyName
  attributes plus default name matching, and asserts no payload key is left unmapped.
- Three further tests assert deserialization values, that serialization re-emits the
  original wire keys, and that a full round-trip preserves the 96-point series.

Wire the payload and a link to the sample specification into the test project as
copy-to-output content.
@davidkallesen
davidkallesen merged commit 6447724 into main Aug 28, 2026
7 checks passed
@davidkallesen
davidkallesen deleted the feature/underscore-support-in-models branch August 28, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant