Skip to content

Add toEncoding to preserve field order - #1

Merged
svobot merged 1 commit into
masterfrom
dev-svobot-toencoding
Jul 31, 2026
Merged

Add toEncoding to preserve field order#1
svobot merged 1 commit into
masterfrom
dev-svobot-toencoding

Conversation

@svobot

@svobot svobot commented Jul 31, 2026

Copy link
Copy Markdown

encode/toEncoding of an OpenApi document re-sorted object keys alphabetically. Schema and the eight Referenced instances had no toEncoding and fell back to the default toEncoding = toEncoding . toJSON, which routes through Value and cannot represent key order. The fallback is transitive, so the whole sub-tree below them was alphabetized as well.

Schema.toEncoding existed in swagger2 but was dropped in 11f8f97 ("Merge ParamSchema into Schema"), where the merged Schema started needing inline aeson options and no encoding counterpart of sopSwaggerGenericToJSONWithOpts existed.

Data/OpenApi/Internal/AesonUtils.hs:

  • Add sopSwaggerGenericToEncodingWithOpts, the encoding counterpart of sopSwaggerGenericToJSONWithOpts.
  • Add sopSwaggerGenericToSeries, the body of sopSwaggerGenericToEncoding without the final pairs, so an instance can splice a record's fields into an enclosing object.
  • Splice sub-objects (saoSubObject) on the encoding path rather than through toJSON/Value. The caller supplies the sub-object's members as a Series via sopSwaggerGenericToEncodingWithSub, because the generic traversal only has ToJSON for them and an Encoding is an opaque Builder that cannot be taken apart. A declared sub-object with no Series is now a loud error instead of silently dropped fields.
  • Handle Newtype in the three generic traversals. They matched only ADT, so a newtype record hit the "unsupported type" error.
  • Replace the dead saoAdditionalPairs option with saoRequiredFields, which forces a field to be emitted even when it equals its default. saoAdditionalPairs existed for swagger2's constant "swagger": "2.0" field, which is a real record field in openapi3, and nothing had set it since. Drops pairsToSeries and parseAdditionalField with it.

Data/OpenApi/Internal.hs:

  • Restore Schema.toEncoding and the eight Referenced instances, the latter via a new referencedToEncoding.
  • Add toEncoding to every remaining ToJSON in the model so nothing falls back to the Value path: the hand-written instances (SecuritySchemeType, OpenApiItems, SecurityDefinitions, AdditionalProperties, ExpressionOrValue, Callback, Reference, MimeList, OpenApiSpecVersion) and the jsonPrefix-derived records and enums.
  • Move ApiKeyParams and the four OAuth2*Flow records to the generics-sop family. Their fields are spliced into an enclosing object, which needs a Series, and deriving it keeps the field names in the records instead of in string literals.
  • Emit the required-but-empty OpenApi "paths" and OAuth2Flow "scopes" objects via saoRequiredFields, which removes the special case from both toJSON and toEncoding.
  • Add the Series helpers the sub-object splices need: openApiItemsSeries, securitySchemeTypeSeries and responsesSeries, reused by the corresponding toEncoding instances so each has a single definition.
  • Drop the stale saoSubObject ?~ "paramSchema" from Schema's HasSwaggerAesonOptions. No field has produced that name since the ParamSchema merge. It cannot be corrected to "items" either: FromJSON Schema shares these options and would then try to parse the whole schema object as OpenApiItems.

test/SpecCommon.hs:

  • (<=>) now also asserts decode (toEncoding x) == Right (toJSON x), so every curated example checks that the two encoders agree.

Two behavior changes beyond key order:

  • An empty "paths" is emitted in its declared position rather than appended last.
  • parseJSON failure messages for ApiKeyParams and the four OAuth2*Flow records now read "Swagger Record Object", like the other generics-sop types, instead of naming the constructor. Successful parses and all serialized output are unchanged.

encode/toEncoding of an OpenApi document re-sorted object keys
alphabetically. Schema and the eight Referenced instances had no
toEncoding and fell back to the default `toEncoding = toEncoding .
toJSON`, which routes through Value and cannot represent key order.
The fallback is transitive, so the whole sub-tree below them was
alphabetized as well.

Schema.toEncoding existed in swagger2 but was dropped in 11f8f97
("Merge ParamSchema into Schema"), where the merged Schema started
needing inline aeson options and no encoding counterpart of
sopSwaggerGenericToJSONWithOpts existed.

Data/OpenApi/Internal/AesonUtils.hs:

* Add sopSwaggerGenericToEncodingWithOpts, the encoding counterpart of
  sopSwaggerGenericToJSONWithOpts.
* Add sopSwaggerGenericToSeries, the body of sopSwaggerGenericToEncoding
  without the final `pairs`, so an instance can splice a record's fields
  into an enclosing object.
* Splice sub-objects (saoSubObject) on the encoding path rather than
  through toJSON/Value. The caller supplies the sub-object's members as
  a Series via sopSwaggerGenericToEncodingWithSub, because the generic
  traversal only has ToJSON for them and an Encoding is an opaque
  Builder that cannot be taken apart. A declared sub-object with no
  Series is now a loud error instead of silently dropped fields.
* Handle Newtype in the three generic traversals. They matched only ADT,
  so a newtype record hit the "unsupported type" error.
* Replace the dead saoAdditionalPairs option with saoRequiredFields,
  which forces a field to be emitted even when it equals its default.
  saoAdditionalPairs existed for swagger2's constant "swagger": "2.0"
  field, which is a real record field in openapi3, and nothing had set
  it since. Drops pairsToSeries and parseAdditionalField with it.

Data/OpenApi/Internal.hs:

* Restore Schema.toEncoding and the eight Referenced instances, the
  latter via a new referencedToEncoding.
* Add toEncoding to every remaining ToJSON in the model so nothing falls
  back to the Value path: the hand-written instances
  (SecuritySchemeType, OpenApiItems, SecurityDefinitions,
  AdditionalProperties, ExpressionOrValue, Callback, Reference,
  MimeList, OpenApiSpecVersion) and the jsonPrefix-derived records and
  enums.
* Move ApiKeyParams and the four OAuth2*Flow records to the generics-sop
  family. Their fields are spliced into an enclosing object, which needs
  a Series, and deriving it keeps the field names in the records instead
  of in string literals.
* Emit the required-but-empty OpenApi "paths" and OAuth2Flow "scopes"
  objects via saoRequiredFields, which removes the special case from
  both toJSON and toEncoding.
* Add the Series helpers the sub-object splices need:
  openApiItemsSeries, securitySchemeTypeSeries and responsesSeries,
  reused by the corresponding toEncoding instances so each has a single
  definition.
* Drop the stale `saoSubObject ?~ "paramSchema"` from Schema's
  HasSwaggerAesonOptions. No field has produced that name since the
  ParamSchema merge. It cannot be corrected to "items" either: FromJSON
  Schema shares these options and would then try to parse the whole
  schema object as OpenApiItems.

test/SpecCommon.hs:

* (<=>) now also asserts `decode (toEncoding x) == Right (toJSON x)`, so
  every curated example checks that the two encoders agree.

Two behavior changes beyond key order:

* An empty "paths" is emitted in its declared position rather than
  appended last.
* parseJSON failure messages for ApiKeyParams and the four OAuth2*Flow
  records now read "Swagger Record Object", like the other generics-sop
  types, instead of naming the constructor. Successful parses and all
  serialized output are unchanged.
@svobot
svobot merged commit 6db8be3 into master Jul 31, 2026
10 of 11 checks passed
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