Spec uses `anyOf` when it should be using `oneOf`

Open
#173 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
35/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Stale
Tech stack
yaml
Domain
api

Research direction

Start by auditing the Stripe OpenAPI spec's anyOf occurrences, including components.schemas and paths request bodies, against the referenced JSON Schema semantics. Review async-stripe's purpose-built parser/generator for its anyOf handling. Done means each occurrence has a justified schema choice, with expandable fields and request variants represented correctly.

Written by the indexing model from the issue text.

Description

future

In many places (maybe even all places), I believe the Stripe OpenAPI spec should be using oneOf, but instead uses anyOf. I'll first present a bit of my journey in properly defining anyOf, so if you're not interested, you can skip a bit down to the examples in the Stripe spec.

Neither anyOf or oneOf are defined in OpenAPI v3 itself, so we have to refer to the relevant JSON Schema spec, where we find:

  • anyOf: "An instance validates successfully against this keyword if it validates successfully against at least one schema defined by this keyword's value."
  • oneOf: "An instance validates successfully against this keyword if it validates successfully against exactly one schema defined by this keyword's value."

The main difference is the "at least one schema" and "exactly one schema" wording. I take that to mean that, for anyOf, an instance can also validate against more than one schema. So for example, if we have an anyOf that looks like this:

anyOf:
  - type: object
    required:
      - foo
    properties:
        foo:
          type: string
  - type: object
    required:
      - bar
    properties:
       bar:
          type: string

Then the following JSON objects (and only the following) would validate:

  1. { "foo": "abc" }
  2. { "bar": "def" }
  3. { "foo": "abc", "bar": "def" }

Whereas if that was a oneOf instead of anyOf, only the first and second instances would successfully validate. I was still a bit uncertain about this, so I asked Claude to explain the difference between oneOf and allOf (without suggesting what my understanding was), and its answer confirmed my interpretation. I also did some searching around the web that appears to confirm.

So, back to Stripe's spec. One illustrative example (with many bits elided for brevity):

components:
  schemas:
    card:
      properties:
        account:
          anyOf:
          - maxLength: 5000
            type: string
          - $ref: '#/components/schemas/account'
      type: object

Not only is this not correct when considering how the Stripe API actually works, but it's also nonsensical: there is no way you can return a JSON blob that is both a bare string and an object with properties. So this is actually oneOf, not allOf. (This pattern is repeated many, many times in the spec when defining an expandable field that refers either to a resource ID or an expanded representation of that resource.)

I looked for more examples, and every single instance of anyOf I looked at didn't make sense. Here's another (again with many bits elided for brevity):

paths:
  /v1/customers:
    post:
      operationId: PostCustomers
      requestBody:
        content:
          application/x-www-form-urlencoded:
            schema:
              additionalProperties: false
              properties:
                address:
                  anyOf:
                  - properties:
                      # a bunch of simply-typed object properties here...
                    type: object
                  - enum:
                    - ''
                    type: string

This is again nonsensical, as the address property cannot both be a JSON object with properties, and an empty-string enum variant. It of course could be one of those things, so oneOf is appropriate there instead.

I can't find it now, but I also encountered one where two schemas listed in the anyOf are JSON objects that each have a property with the same name, but with different and incompatible object types, which is also unrepresentable. (And indeed, the Stripe API would never actually try to return something that looks like that.)

I also found some with only a single schema listed under the anyOf; I assume this is done in case future changes introduce more options, but I expect oneOf is actually the correct option for those as well.

Reading through the code of async-stripe's purpose-built OpenAPI parser/generator, I saw that their implementation of anyOf is tailored specifically for the (incorrect) usage in Stripe's spec.

My selfish reason for asking for this to be fixed: my own Rust OpenAPI parser/generator doesn't currently support anyOf, as it's actually fairly tricky to do properly, and especially to express succinctly/user-friendly-ly in a language like Rust. In the meantime I've just s/anyOf/oneOf/ in the spec, and I'm expecting that will work ok, but there are so many instances of anyOf in there, and I don't think I have the context and expertise to audit them all. (I'm using only a small number of the API's endpoints, so this will probably be fine for me regardless.)

(Regarding a Rust implementation of anyOf, consider that if I were to use an enum, each describing a particular valid combination, that means I'd need (2^n)-1 enum variants, where n is the number of schemas in the anyOf. This explodes pretty quickly and is not pleasant to deal with for the user of the generated code. Other approaches, like including all possible properties, with the values all wrapped in Option, mean more difficult work writing/generating code to validate the data. I just built a simple implementation into my generator that uses this latter approach, but without any validation. It... "works", but is pretty sub-optimal. So I've otherwise just kinda ignored the problem until now, given the difficulties.)

Dominant language
No language data
Stars
503
Forks
148
Avg merge
6d 14m
Merged PRs (30d)
5

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from stripe/openapi

All issues in stripe/openapi

Similar issues

More Backend & API Design issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.