apple / apple/swift-openapi-generator
Required + nullable properties get a `= nil` default and are omitted on encode, so a conforming request is impossible to construct
- Dominant language
- Swift
- Stars
- 2k
- Forks
- 182
- Avg merge
- 13h 28m
- Merged PRs (30d)
- 5
Description
### Summary
For a property that is **both in `required` and nullable**, the generator emits a Swift `Optional` with a `= nil` default in the initializer, and relies on synthesized `Codable`, whose `encodeIfPresent` **omits** the key when the value is `nil`.
The result is that no value of the generated type can produce a request body that conforms to the document: the property is mandatory per the schema, but the client either sends a real value or sends nothing at all. Sending an explicit `null` — the whole point of declaring the property nullable — is unreachable.
### This is not #419, and that distinction is the point
I want to lead with this, because it looks superficially like [#419](https://github.com/apple/swift-openapi-generator/issues/419).
#419 is about **optional + nullable**, where there genuinely are three states — absent, `null`, and a value — and Swift's `T?` only models two. That needs a design (the `PotentiallyAbsent` discussion, a custom Encoder/Decoder), which is presumably why it has been open since 2023.
**Required + nullable has only two states.** Absence is forbidden by the schema, so `nil` can only ever mean JSON `null`. `T?` models it exactly. There is no ambiguity to resolve and no new type required — the current output simply discards information the schema provides.
The `typeOverrides` workaround suggested in [#419 (comment)](https://github.com/apple/swift-openapi-generator/issues/419#issuecomment-2983596172) also doesn't reach this case: it is keyed on named component schemas, and generators that emit inline request bodies have no schema name to key on.
### Reproduction
```yaml
openapi: 3.1.0
info: { title: repro, version: 1.0.0 }
paths:
/vote:
post:
operationId: castVote
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id, vote] # <- `vote` IS required
properties:
id: { type: number }
vote: { type: [boolean, "null"] } # <- and nullable
responses:
"204": { description: ok }
```
Generated:
```swift
public var vote: Swift.Bool?
public init(
id: Swift.Double,
vote: Swift.Bool? = nil // defaulted, despite being required
) { ... }
```
No custom `encode(to:)` is generated for this struct, so synthesized `Codable` applies and `vote: nil` encodes as an absent key rather than `null`.
### The clearest evidence
Generating from the same document twice — once with `vote` in `required`, once without — produces **byte-identical** Swift. The `required` array has no observable effect on a nullable property.
```
required: ["id","vote"] -> public var vote: Swift.Bool? init(… vote: Swift.Bool? = nil)
required: ["id"] -> public var vote: Swift.Bool? init(… vote: Swift.Bool? = nil)
```
For contrast, a required **non**-nullable open schema (`vote: {}`) does generate a non-optional property with no default, and `OpenAPIValueContainer` encodes `nil` as an explicit `null` via `encodeNil()`:
```swift
public var vote: OpenAPIRuntime.OpenAPIValueContainer // non-optional, no default
```
So the runtime can already express "present and null" — it is only the type-assignment layer that cannot reach it, and only by way of total type erasure.
### Where it comes from
`Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift`:
```swift
func isOptional(_ schema: JSONSchema, components: OpenAPI.Components) throws -> Bool {
if schema.nullable || !schema.required { return true }
...
}
```
`required` is never consulted once `nullable` is true, so nullable-and-required collapses into the same representation as not-required. The property then picks up a `= nil` default in `translateStructBlueprint`, and the struct keeps `OpenAPICodableStrategy.synthesized`.
### Suggested fix
Scoped to `required && nullable` only, leaving #419's harder case untouched:
1. Emit no `= nil` default for a required property, so the caller must make an explicit choice.
2. Encode with `encode` rather than inheriting synthesized `encodeIfPresent`, so `nil` serializes as `null`.
(2) needs a custom `encode(to:)` when a struct has at least one required-nullable property. `OpenAPICodableStrategy` already carries custom-encoder cases for the additional-properties path, so this looks like an additional strategy rather than new architecture. Decoding needs no change — `decodeIfPresent` already maps both `null` and absent to `nil`, and absent cannot legally occur.
I'd be glad to hear whether that framing is acceptable before anyone spends time on an implementation.
### Environment
- swift-openapi-generator **1.13.0** (also reproduced on 1.12.2)
- swift-openapi-runtime 1.12.0
- Swift 6.3.3, macOS arm64
### Impact
Any server whose contract is "you must send this field, and `null` is a meaningful value" — clearing a vote, unsetting an image — is unreachable from a generated Swift client. The server rejects the request with a validation error for a missing required property, and there is no way to construct a conforming one short of hand-writing the body type.
Contributor guide
Research direction
Start with Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift and trace isOptional into translateStructBlueprint and OpenAPICodableStrategy. Generate the supplied OpenAPI document, compare required and non-required nullable properties, and inspect the resulting Codable behavior. Done means required nullable properties require an initializer argument and encode nil as an explicit null while decoding remains unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- openapi, swift
- Domain
- api, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100