pythonmypypydanticjson-api

How can I implement multiple optional fields but with at least one of them mandatory in MyPy or Pydantic?


I'm trying to write a minimal, custom, JSON:API 1.1 implementation using Python.

For the top level, the RFC/doc says:

https://jsonapi.org/format/#document-top-level

A document MUST contain at least one of the following top-level members:

  • data: the document’s “primary data”.
  • errors: an array of error objects.
  • meta: a meta object that contains non-standard meta-information.
  • a member defined by an applied extension.

The members data and errors MUST NOT coexist in the same document.

The way I read that is:

However, at least 1 of them needs to be present in the document. Also "data" and "errors" must never be in the same document.

I'm having a hard time modeling this. Is there a way to do it using the type system or do I have to do custom validation of some sort?

Data|Errors|Meta|ExtensionMember doesn't cut it.


Solution

  • With Pydantic you can write a validator for this. Here is an example:

    from pydantic import BaseModel, model_validator, ValidationError
    
    
    class Document(BaseModel):
        data: str | None
        error: str | None
        meta: str | None
        extension: str | None
    
        @model_validator(mode="after")
        def _has_necessary_member(self):
            """Ensure the document has one of "data", "error", "meta" or "extension" set."""
            has_data = self.data is not None
            has_error = self.error is not None
            has_meta = self.meta is not None
            has_extension = self.extension is not None
    
            if not (has_data or has_error or has_meta or has_extension):
                raise ValueError('This model has to have one of "data", "error", "meta" or "extension" set.')
    
            return self
    
        @model_validator(mode="after")
        def _data_and_error_are_mutex(self):
            """Ensure the document has EITHER data, OR an error."""
            has_data = self.data is not None
            has_error = self.error is not None
    
            if has_data and has_error:
                raise ValueError("This model can't have data and error set at the same time.")
    
            return self
    
    
    if __name__ == '__main__':
        d = Document(data="Foo", error=None, meta=None, extension=None)  # works
        print(d)
    
        try:
            Document(data="Foo", error="Error", meta=None, extension=None)  # fails
        except ValidationError as e:
            print(e)
    
        try:
            Document(data=None, error=None, meta=None, extension=None)  # fails
        except ValidationError as e:
            print(e)
    

    You could also choose to use a union to represent the data/error, and validate this.

    class Document(BaseModel):
        result: Data | Error | None
        meta: Meta | None
        extension: Extension | None
    

    This is a matter of opinion, and the choice is yours. The validators are similar in both cases.

    Note that Pydantic will only give you runtime checks and not static analysis like mypy does. There's a mypy plugin for pydantic, but personally I find it to be a bit hit or miss sometimes.