Skip to content

Eenduidige semantiek voor null en afwezige properties #341

@terborg

Description

@terborg

1. Probleem

In onze API-richtlijnen ontbreekt nu een eenduidige afspraak over het verschil
tussen een property die ontbreekt en een property met de waarde null. Daardoor
kan dezelfde payload op verschillende manieren worden geïnterpreteerd door
API-consumers, API-providers en tooling.

Dat verschil wordt vooral belangrijk bij de combinatie van:

  • GET, POST en PUT, waarbij een JSON-payload de toestand van een resource
    beschrijft;
  • PATCH met application/merge-patch+json, waarbij een JSON-payload alleen de
    wijziging op een resource beschrijft.

Voor JSON Merge Patch is het onderscheid essentieel. RFC 7396 gebruikt drie
betekenissen:

  1. Property aanwezig met een waarde: overschrijf de bestaande waarde.
  2. Property aanwezig met null: verwijder of leeg de bestaande waarde.
  3. Property afwezig: laat de bestaande waarde ongewijzigd.

Als een API in de GET-respons null-waarden weglaat, of het onderscheid
tussen null en afwezig niet consequent vastlegt in OpenAPI, kan een client
niet betrouwbaar bepalen welke PATCH nodig is. Dat vergroot de kans op
onbedoeld dataverlies en maakt automatische contractvalidatie lastiger.

2. Voorgestelde richtlijn

Leg in het API-contract expliciet vast of een property verplicht is, optioneel
is of expliciet null mag zijn. Hanteer daarbij voor JSON dezelfde betekenis in
alle operaties, en gebruik voor partiële updates altijd de interpretatie zoals in
application/merge-patch+json.

We onderscheiden twee soorten waarden:

Soort waarde Betekenis Voorbeeld
Vaste waarde De property moet aanwezig zijn en een geldige waarde bevatten. naam: string
Optionele waarde De property mag expliciet geen waarde hebben. tweedeNaam: string | null

3. Gedrag bij volledige JSON-payloads

Voor application/json bij GET, POST en PUT beschrijft de payload de
volledige toestand van de resource.

Soort waarde Toestand in payload Interpretatie OpenAPI
Vaste waarde Afwezig Fout Opnemen in required
Vaste waarde null Fout Niet nullable
Optionele waarde Afwezig Interpreteren als null Niet opnemen in required; eventueel default
Optionele waarde null Geldige lege waarde Nullable
Optionele waarde Waarde Geldige waarde Nullable

Richtlijn: stuur optionele waarden in responses bij voorkeur expliciet als
null terug. Daarmee blijft voor clients zichtbaar dat de property onderdeel is
van het contract, ook als er geen waarde is.

Bij requests betekent een afwezige optionele waarde dus dat de server geen waarde
voor die property vastlegt, alsof de client expliciet null had gestuurd.

In schema's voor volledige application/json-payloads kan default: null
eventueel als documentatie worden gebruikt. Laat de betekenis daar niet van
afhangen: default valideert niet en dwingt geen servergedrag af.

4. Gedrag bij merge-patch-payloads

Voor PATCH met application/merge-patch+json beschrijft de payload alleen de
wijziging. Een ontbrekende property betekent daarom iets anders dan in een
volledige resource-representatie.

Soort waarde Toestand in payload Interpretatie OpenAPI
Vaste of optionele waarde Afwezig Geen wijziging Niet opnemen in required
Optionele waarde null Waarde verwijderen of leegmaken Nullable
Vaste waarde null Fout, bijvoorbeeld 400 Bad Request Niet nullable
Vaste of optionele waarde Waarde Waarde overschrijven Type volgens schema

5. Voorbeeld

Een resource bevat een verplichte naam en een optionele tweedeNaam.

{
  "naam": "Ada Lovelace",
  "tweedeNaam": null
}

Een client die alleen naam wil aanpassen, stuurt:

PATCH /personen/123
Content-Type: application/merge-patch+json
{
  "naam": "Augusta Ada Lovelace"
}

De afwezige property tweedeNaam blijft ongewijzigd.

Een client die tweedeNaam wil wissen, stuurt:

{
  "tweedeNaam": null
}

Een client mag naam niet wissen, omdat naam een vaste waarde is:

{
  "naam": null
}

De server wijst die mutatie af.

OpenAPI-componenten hergebruiken

Gebruik bij voorkeur een gedeelde basis voor de properties en maak daarna per
payloadtype een eigen schema. Zet in de gedeelde basis wel de typen en
nullability, maar niet de required-lijst. Die lijst verschilt namelijk tussen
een volledige resource en een merge-patch.

components:
  schemas:
    PersoonVelden:
      type: object
      properties:
        naam:
          type: string
        tweedeNaam:
          type: string
          nullable: true

    Persoon:
      description: Volledige resource voor GET, POST en PUT.
      allOf:
        - $ref: "#/components/schemas/PersoonVelden"
        - type: object
          required:
            - naam

    PersoonMergePatch:
      description: Delta voor PATCH met application/merge-patch+json.
      allOf:
        - $ref: "#/components/schemas/PersoonVelden"

In dit voorbeeld gebruiken Persoon en PersoonMergePatch dezelfde
propertydefinities. Daardoor blijft naam overal een vaste waarde en blijft
tweedeNaam overal nullable. Het verschil zit alleen in aanwezigheid:
Persoon vereist naam, terwijl PersoonMergePatch geen enkele property
vereist omdat afwezig bij merge-patch "geen wijziging" betekent.

Gebruik dus wel allOf met een basis zoals PersoonVelden, waarin alleen de
gedeelde properties staan. Gebruik geen allOf waarbij PersoonMergePatch is
gebaseerd op het volledige Persoon-schema. required-velden uit een
allOf-basis blijven namelijk verplicht; OpenAPI biedt geen manier om die in
een afgeleid schema weer optioneel te maken.

Andersom, Persoon afleiden van PersoonMergePatch en daarna required
toevoegen, kan technisch wel. Toch is dat minder helder: een volledige resource
is geen specialisatie van een patch-operatie. Het koppelt de resource bovendien
aan patch-specifieke keuzes, zoals het ontbreken van defaults en de betekenis
van afwezige properties. Gebruik daarom liever een neutrale basiscomponent voor
de gedeelde velden, en leid zowel Persoon als PersoonMergePatch daarvan af.

Defaultwaarden

Gebruik default niet als mechanisme om het verschil tussen null en een
afwezige property te modelleren. Een OpenAPI-default is vooral documentatie voor
tooling en clients; het is geen validatieregel en geen garantie dat de server
die waarde invult. In een schema voor een volledige application/json-payload
kan default: null dus eventueel, maar alleen als toelichting. De semantiek
moet uit de richtlijn en de serverimplementatie volgen, niet uit de default.

Defaultwaarden mogen wel als er echt een business-default bestaat die de server
toepast wanneer een client de property weglaat. Leg die default dan vast op het
requestschema waarin de client de property mag weglaten. De property moet ook
onderdeel zijn van de volledige resource-representatie, zodat de server de
uiteindelijke waarde kan teruggeven. Bijvoorbeeld voor een property
communicatietaal die ook in de volledige Persoon-resource voorkomt:

components:
  schemas:
    PersoonAanmaken:
      allOf:
        - $ref: "#/components/schemas/PersoonVelden"
        - type: object
          properties:
            communicatietaal:
              type: string
              enum:
                - nl
                - en
              default: nl
          required:
            - naam

Gebruik default nooit in een merge-patch-schema om een afwezige property
alsnog een waarde te geven. Zet default: null ook niet in een gedeeld
basisschema dat door een merge-patch-schema via $ref of allOf wordt
hergebruikt. Bij application/merge-patch+json betekent afwezig altijd: geen
wijziging. Een default kan die semantiek in de weg zitten zodra tooling of
clientcode defaults materialiseert. Dan wordt een ontbrekende property alsnog
meegestuurd: met default: null als delete of unset, en met een andere default
als overschrijving.

6. Impact en compatibiliteit

Wijziging in contract Impact op requests Impact op responses
Van vaste waarde naar optionele waarde Meestal compatibel: de server accepteert meer. Breaking change: clients kunnen opeens null ontvangen.
Van optionele waarde naar vaste waarde Breaking change: de server eist meer data. Meestal compatibel: clients ontvangen altijd een waarde.

Deze richtlijn maakt het mogelijk om wijzigingen in nullability en aanwezigheid
van properties automatisch te toetsen als onderdeel van contractvalidatie.

Referenties

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions