Repository: Materials-Consortia/optimade-python-tools
Pull request #453 (2020-08-05 14:43:13)

SHOULD/MUST/OPTIONAL fields in models
Following the discussion in #399, this PR is a place to try out ways of incorporating the "SHOULD" level of support for some fields in the specification. This PR does the following:

- adds a couple of wrappers around pydantic's `Field` (namely `OptimadeField` and `StrictField`).
    - `StrictField` disallows keys outside of the pydantic `Field` signature, plus a couple of extras that we use (`unit`, `pattern` and `uniqueItems`), unfortunately. It emits a warning if a description is not supplied. Any tips on how to do this in a more pydantic way would be appreciated...
    - `OptimadeField` calls `StrictField` but also forces all fields to supply a `queryable` and a `support` attribute for all fields
- ~switch away from `pattern=...` to `regex=...` inside `Field(...)` where possible so that regexes are applied automatically on validation.~
- fix a couple of fields that @CasperWA spotted elsewhere that have a typo `decsription` that means the schema never saw their descriptions... this is basically what `StrictField` will prevent in the future.

codecov[bot]: # [Codecov](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/453?src=pr&el=h1) Report
> Merging [#453](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/453?src=pr&el=desc) into [master](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/commit/e965bc8051b776c88ca93f3ff4d5a48ea99b88ef&el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/453/graphs/tree.svg?width=650&height=150&src=pr&token=UJAtmqkZZO)](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/453?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##           master     #453   +/-   ##
=======================================
  Coverage   91.26%   91.26%           
=======================================
  Files          60       60           
  Lines        2815     2815           
=======================================
  Hits         2569     2569           
  Misses        246      246           
```

| Flag | Coverage Δ | |
|---|---|---|
| #unittests | `91.26% <100.00%> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/453?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [optimade/models/structures.py](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/453/diff?src=pr&el=tree#diff-b3B0aW1hZGUvbW9kZWxzL3N0cnVjdHVyZXMucHk=) | `95.62% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/453?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/453?src=pr&el=footer). Last update [e965bc8...8b00825](https://codecov.io/gh/Materials-Consortia/optimade-python-tools/pull/453?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

CasperWA: > Following the discussion in #399, this PR is a place to try out ways of incorporating the "SHOULD" level of support for some fields in the specification. This PR does the following:
> 
>     * adds a couple of wrappers around pydantic's `Field` (namely `OptimadeField` and `StrictField`).
>       
>       * `StrictField` disallows keys outside of the pydantic `Field` signature, plus a couple of extras that we use (`unit`, `pattern` and `uniqueItems`), unfortunately. It emits a warning if a description is not supplied. Any tips on how to do this in a more pydantic way would be appreciated...
>       * `OptimadeField` calls `StrictField` but also forces all fields to supply a `queryable` and a `support` attribute for all fields
> 
>     * switch away from `pattern=...` to `regex=...` inside `Field(...)` where possible so that regexes are applied automatically on validation.

Some of these were done on purpose, since we were either already testing this, or I found that the additional validation was time-consuming and irrelevant. The latter may be the case for the fields with constant values...
ml-evs: >  I am not sure why you're getting the issues concerning the tests and `re' matching. But I've provided a suggested change for you to try out :)

+1

 > I wonder if we can do this in Config of a parent model instead, but perhaps not. Perhaps this is the best way. It's at least quite elegant in the end, I think. At least for what it is.

Yeah, I think the only other option is to define OptimadeModel(BaseModel) and StrictModel(BaseModel), but I think that would involve a fair bit more hackery.

 > Otherwise we should create a custom JSON encoder that can do all this and be used when creating the OpenAPI specification?

Yep, I think we have no other choice if we want this stuff in the schema.

ml-evs: > > ```
> > * switch away from `pattern=...` to `regex=...` inside `Field(...)` where possible so that regexes are applied automatically on validation.
> > ```
> 
> Some of these were done on purpose, since we were either already testing this, or I found that the additional validation was time-consuming and irrelevant. The latter may be the case for the fields with constant values...

I only changed them because I didn't like that I had to allow `pattern` through `StrictField` as a special case, but then I couldn't get rid of it for the URL regex anyway. I guess you're right, it is redundant for the const case (I doubt it even runs the regex?) Happy to revert this one if you want.
CasperWA: > > > ```
> > > * switch away from `pattern=...` to `regex=...` inside `Field(...)` where possible so that regexes are applied automatically on validation.
> > > ```
> > 
> > 
> > Some of these were done on purpose, since we were either already testing this, or I found that the additional validation was time-consuming and irrelevant. The latter may be the case for the fields with constant values...
> 
> I only changed them because I didn't like that I had to allow `pattern` through `StrictField` as a special case, but then I couldn't get rid of it for the URL regex anyway. I guess you're right, it is redundant for the const case (I doubt it even runs the regex?) Happy to revert this one if you want.

I'm pretty sure the `const` value is simply set at some point and the validation is then run. So for these values I'd rather just revert to not uneccesarily clutter the list of validators. It's a form of an OpenAPI schema hack already as it is ;)
And indeed, `pattern` is added as a result of the `regex`, but `regex` also adds a validator.
ml-evs: > I'm pretty sure the `const` value is simply set at some point and the validation is then run. So for these values I'd rather just revert to not uneccesarily clutter the list of validators. It's a form of an OpenAPI schema hack already as it is ;)

Yep, fair, reverted.
ml-evs: Have squashed this down to 3 commits, so ready when you are happy with it @CasperWA.
ml-evs: Annoyingly GH was hiding some remaining unresolved comments, so feel free to disagree with me on those still...

Patch:
diff --git a/openapi/openapi.json b/openapi/openapi.json
index cb4f49c6..8a315390 100644
--- a/openapi/openapi.json
+++ b/openapi/openapi.json
@@ -2116,7 +2116,8 @@
         "properties": {
           "name": {
             "title": "Name",
-            "type": "string"
+            "type": "string",
+            "description": "Full name of the person, REQUIRED."
           },
           "firstname": {
             "title": "Firstname",
@@ -2128,7 +2129,8 @@
             "type": "string",
             "description": "Last name of the person."
           }
-        }
+        },
+        "description": "A person, i.e., an author, editor or other."
       },
       "Provider": {
         "title": "Provider",
@@ -2836,7 +2838,8 @@
         "properties": {
           "name": {
             "title": "Name",
-            "type": "string"
+            "type": "string",
+            "description": "Gives the name of the species; the **name** value MUST be unique in the `species` list."
           },
           "chemical_symbols": {
             "title": "Chemical Symbols",
diff --git a/optimade/models/baseinfo.py b/optimade/models/baseinfo.py
index 78796084..71d013d8 100644
--- a/optimade/models/baseinfo.py
+++ b/optimade/models/baseinfo.py
@@ -5,7 +5,7 @@ from typing import Dict, List, Optional
 from pydantic import BaseModel, AnyHttpUrl, Field, validator, root_validator
 
 from optimade.models.jsonapi import Resource
-from optimade.models.utils import SemanticVersion
+from optimade.models.utils import SemanticVersion, StrictField
 
 
 __all__ = ("AvailableApiVersion", "BaseInfoAttributes", "BaseInfoResource")
@@ -14,13 +14,13 @@ __all__ = ("AvailableApiVersion", "BaseInfoAttributes", "BaseInfoResource")
 class AvailableApiVersion(BaseModel):
     """A JSON object containing information about an available API version"""
 
-    url: AnyHttpUrl = Field(
+    url: AnyHttpUrl = StrictField(
         ...,
         description="A string specifying a versioned base URL that MUST adhere to the rules in section Base URL",
         pattern=r".+/v[0-1](\.[0-9]+)*/?$",
     )
 
-    version: SemanticVersion = Field(
+    version: SemanticVersion = StrictField(
         ...,
         description="""A string containing the full version number of the API served at that versioned base URL.
 The version number string MUST NOT be prefixed by, e.g., 'v'.
@@ -59,27 +59,27 @@ Examples: `1.0.0`, `1.0.0-rc.2`.""",
 class BaseInfoAttributes(BaseModel):
     """Attributes for Base URL Info endpoint"""
 
-    api_version: SemanticVersion = Field(
+    api_version: SemanticVersion = StrictField(
         ...,
         description="""Presently used full version of the OPTIMADE API.
 The version number string MUST NOT be prefixed by, e.g., "v".
 Examples: `1.0.0`, `1.0.0-rc.2`.""",
     )
-    available_api_versions: List[AvailableApiVersion] = Field(
+    available_api_versions: List[AvailableApiVersion] = StrictField(
         ...,
         description="A list of dictionaries of available API versions at other base URLs",
     )
-    formats: List[str] = Field(
+    formats: List[str] = StrictField(
         default=["json"], description="List of available output formats."
     )
-    available_endpoints: List[str] = Field(
+    available_endpoints: List[str] = StrictField(
         ...,
         description="List of available endpoints (i.e., the string to be appended to the versioned base URL).",
     )
-    entry_types_by_format: Dict[str, List[str]] = Field(
+    entry_types_by_format: Dict[str, List[str]] = StrictField(
         ..., description="Available entry endpoints as a function of output formats."
     )
-    is_index: Optional[bool] = Field(
+    is_index: Optional[bool] = StrictField(
         default=False,
         description="If true, this is an index meta-database base URL (see section Index Meta-Database). "
         "If this member is not provided, the client MUST assume this is not an index meta-database base URL "
diff --git a/optimade/models/entries.py b/optimade/models/entries.py
index a0f16769..9a74189a 100644
--- a/optimade/models/entries.py
+++ b/optimade/models/entries.py
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, validator  # pylint: disable=no-name-in-m
 
 from optimade.models.jsonapi import Relationships, Attributes, Resource
 from optimade.models.optimade_json import Relationship, DataType
+from optimade.models.utils import StrictField, OptimadeField, SupportLevel
 
 
 __all__ = (
@@ -42,12 +43,12 @@ class StructureRelationship(TypedRelationship):
 class EntryRelationships(Relationships):
     """This model wraps the JSON API Relationships to include type-specific top level keys. """
 
-    references: Optional[ReferenceRelationship] = Field(
+    references: Optional[ReferenceRelationship] = StrictField(
         None,
         description="Object containing links to relationships with entries of the `references` type.",
     )
 
-    structures: Optional[StructureRelationship] = Field(
+    structures: Optional[StructureRelationship] = StrictField(
         None,
         description="Object containing links to relationships with entries of the `structures` type.",
     )
@@ -56,7 +57,7 @@ class EntryRelationships(Relationships):
 class EntryResourceAttributes(Attributes):
     """Contains key-value pairs representing the entry's properties."""
 
-    immutable_id: Optional[str] = Field(
+    immutable_id: Optional[str] = OptimadeField(
         None,
         description="""The entry's immutable ID (e.g., an UUID). This is important for databases having preferred IDs that point to "the latest version" of a record, but still offer access to older variants. This ID maps to the version-specific record, in case it changes in the future.
 
@@ -69,9 +70,11 @@ class EntryResourceAttributes(Attributes):
 - **Examples**:
     - `"8bd3e750-b477-41a0-9b11-3a799f21b44f"`
     - `"fjeiwoj,54;@=%<>#32"` (Strings that are not URL-safe are allowed.)""",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.MUST,
     )
 
-    last_modified: datetime = Field(
+    last_modified: datetime = OptimadeField(
         ...,
         description="""Date and time representing when the entry was last modified.
 
@@ -84,13 +87,15 @@ class EntryResourceAttributes(Attributes):
 
 - **Example**:
     - As part of JSON response format: `"2007-04-05T14:30:20Z"` (i.e., encoded as an [RFC 3339 Internet Date/Time Format](https://tools.ietf.org/html/rfc3339#section-5.6) string.)""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.MUST,
     )
 
 
 class EntryResource(Resource):
     """The base model for an entry resource."""
 
-    id: str = Field(
+    id: str = OptimadeField(
         ...,
         description="""An entry's ID as defined in section Definition of Terms.
 
@@ -107,6 +112,8 @@ class EntryResource(Resource):
     - `"cod/2000000@1234567"`
     - `"nomad/L1234567890"`
     - `"42"`""",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.MUST,
     )
 
     type: str = Field(
@@ -122,15 +129,17 @@ class EntryResource(Resource):
     - The entry of type `<type>` and ID `<id>` MUST be returned in response to a request for `/<type>/<id>` under the versioned base URL.
 
 - **Example**: `"structures"`""",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.MUST,
     )
 
-    attributes: EntryResourceAttributes = Field(
+    attributes: EntryResourceAttributes = StrictField(
         ...,
         description="""A dictionary, containing key-value pairs representing the entry's properties, except for `type` and `id`.
 Database-provider-specific properties need to include the database-provider-specific prefix (see section on Database-Provider-Specific Namespace Prefixes).""",
     )
 
-    relationships: Optional[EntryRelationships] = Field(
+    relationships: Optional[EntryRelationships] = StrictField(
         None,
         description="""A dictionary containing references to other entries according to the description in section Relationships encoded as [JSON API Relationships](https://jsonapi.org/format/1.0/#document-resource-object-relationships).
 The OPTIONAL human-readable description of the relationship MAY be provided in the `description` field inside the `meta` dictionary of the JSON API resource identifier object.""",
@@ -139,24 +148,24 @@ The OPTIONAL human-readable description of the relationship MAY be provided in t
 
 class EntryInfoProperty(BaseModel):
 
-    description: str = Field(
+    description: str = StrictField(
         ..., description="A human-readable description of the entry property"
     )
 
-    unit: Optional[str] = Field(
+    unit: Optional[str] = StrictField(
         None,
         description="""The physical unit of the entry property.
 This MUST be a valid representation of units according to version 2.1 of [The Unified Code for Units of Measure](https://unitsofmeasure.org/ucum.html).
 It is RECOMMENDED that non-standard (non-SI) units are described in the description for the property.""",
     )
 
-    sortable: Optional[bool] = Field(
+    sortable: Optional[bool] = StrictField(
         None,
         description="""Defines whether the entry property can be used for sorting with the "sort" parameter.
 If the entry listing endpoint supports sorting, this key MUST be present for sortable properties with value `true`.""",
     )
 
-    type: Optional[DataType] = Field(
+    type: Optional[DataType] = StrictField(
         None,
         description="""The type of the property's value.
 This MUST be any of the types defined in the Data types section.
@@ -168,18 +177,18 @@ E.g., for the entry resource `structures`, the `species` property is defined as
 
 class EntryInfoResource(BaseModel):
 
-    formats: List[str] = Field(
+    formats: List[str] = StrictField(
         ..., description="List of output formats available for this type of entry."
     )
 
-    description: str = Field(..., description="Description of the entry.")
+    description: str = StrictField(..., description="Description of the entry.")
 
-    properties: Dict[str, EntryInfoProperty] = Field(
+    properties: Dict[str, EntryInfoProperty] = StrictField(
         ...,
         description="A dictionary describing queryable properties for this entry type, where each key is a property name.",
     )
 
-    output_fields_by_format: Dict[str, List[str]] = Field(
+    output_fields_by_format: Dict[str, List[str]] = StrictField(
         ...,
         description="Dictionary of available output fields for this entry type, where the keys are the values of the `formats` list and the values are the keys of the `properties` dictionary.",
     )
diff --git a/optimade/models/index_metadb.py b/optimade/models/index_metadb.py
index de8e7672..c84f866b 100644
--- a/optimade/models/index_metadb.py
+++ b/optimade/models/index_metadb.py
@@ -6,6 +6,7 @@ from typing import Union, Dict
 
 from optimade.models.jsonapi import BaseResource
 from optimade.models.baseinfo import BaseInfoAttributes, BaseInfoResource
+from optimade.models.utils import StrictField
 
 
 __all__ = (
@@ -25,7 +26,7 @@ class DefaultRelationship(Enum):
 class IndexInfoAttributes(BaseInfoAttributes):
     """Attributes for Base URL Info endpoint for an Index Meta-Database"""
 
-    is_index: bool = Field(
+    is_index: bool = StrictField(
         True,
         const=True,
         description="This must be `true` since this is an index meta-database (see section Index Meta-Database).",
@@ -35,13 +36,13 @@ class IndexInfoAttributes(BaseInfoAttributes):
 class RelatedLinksResource(BaseResource):
     """A related Links resource object"""
 
-    type: str = Field("links", const="links", pattern="^links$")
+    type: str = Field("links", const="links", regex="^links$")
 
 
 class IndexRelationship(BaseModel):
     """Index Meta-Database relationship"""
 
-    data: Union[None, RelatedLinksResource] = Field(
+    data: Union[None, RelatedLinksResource] = StrictField(
         ...,
         description="""[JSON API resource linkage](http://jsonapi.org/format/1.0/#document-links).
 It MUST be either `null` or contain a single Links identifier object with the fields `id` and `type`""",
@@ -52,7 +53,9 @@ class IndexInfoResource(BaseInfoResource):
     """Index Meta-Database Base URL Info endpoint resource"""
 
     attributes: IndexInfoAttributes = Field(...)
-    relationships: Union[None, Dict[DefaultRelationship, IndexRelationship]] = Field(
+    relationships: Union[
+        None, Dict[DefaultRelationship, IndexRelationship]
+    ] = StrictField(
         ...,
         description="""Reference to the Links identifier object under the `links` endpoint that the provider has chosen as their 'default' OPTIMADE API database.
 A client SHOULD present this database as the first choice when an end-user chooses this provider.""",
diff --git a/optimade/models/jsonapi.py b/optimade/models/jsonapi.py
index 34bf1e4e..910fc8ed 100644
--- a/optimade/models/jsonapi.py
+++ b/optimade/models/jsonapi.py
@@ -5,10 +5,10 @@ from datetime import datetime, timezone
 from pydantic import (  # pylint: disable=no-name-in-module
     BaseModel,
     AnyUrl,
-    Field,
     parse_obj_as,
     root_validator,
 )
+from optimade.models.utils import StrictField
 
 
 __all__ = (
@@ -39,8 +39,8 @@ class Meta(BaseModel):
 class Link(BaseModel):
     """A link **MUST** be represented as either: a string containing the link's URL or a link object."""
 
-    href: AnyUrl = Field(..., description="a string containing the link’s URL.")
-    meta: Optional[Meta] = Field(
+    href: AnyUrl = StrictField(..., description="a string containing the link’s URL.")
+    meta: Optional[Meta] = StrictField(
         None,
         description="a meta object containing non-standard meta-information about the link.",
     )
@@ -49,29 +49,35 @@ class Link(BaseModel):
 class JsonApi(BaseModel):
     """An object describing the server's implementation"""
 
-    version: str = Field(default="1.0", description="Version of the json API used")
-    meta: Optional[Meta] = Field(None, description="Non-standard meta information")
+    version: str = StrictField(
+        default="1.0", description="Version of the json API used"
+    )
+    meta: Optional[Meta] = StrictField(
+        None, description="Non-standard meta information"
+    )
 
 
 class ToplevelLinks(BaseModel):
     """A set of Links objects, possibly including pagination"""
 
-    self: Optional[Union[AnyUrl, Link]] = Field(None, description="A link to itself")
-    related: Optional[Union[AnyUrl, Link]] = Field(
+    self: Optional[Union[AnyUrl, Link]] = StrictField(
+        None, description="A link to itself"
+    )
+    related: Optional[Union[AnyUrl, Link]] = StrictField(
         None, description="A related resource link"
     )
 
     # Pagination
-    first: Optional[Union[AnyUrl, Link]] = Field(
+    first: Optional[Union[AnyUrl, Link]] = StrictField(
         None, description="The first page of data"
     )
-    last: Optional[Union[AnyUrl, Link]] = Field(
+    last: Optional[Union[AnyUrl, Link]] = StrictField(
         None, description="The last page of data"
     )
-    prev: Optional[Union[AnyUrl, Link]] = Field(
+    prev: Optional[Union[AnyUrl, Link]] = StrictField(
         None, description="The previous page of data"
     )
-    next: Optional[Union[AnyUrl, Link]] = Field(
+    next: Optional[Union[AnyUrl, Link]] = StrictField(
         None, description="The next page of data"
     )
 
@@ -94,7 +100,7 @@ class ToplevelLinks(BaseModel):
 class ErrorLinks(BaseModel):
     """A Links object specific to Error objects"""
 
-    about: Optional[Union[AnyUrl, Link]] = Field(
+    about: Optional[Union[AnyUrl, Link]] = StrictField(
         None,
         description="A link that leads to further details about this particular occurrence of the problem.",
     )
@@ -103,12 +109,12 @@ class ErrorLinks(BaseModel):
 class ErrorSource(BaseModel):
     """an object containing references to the source of the error"""
 
-    pointer: Optional[str] = Field(
+    pointer: Optional[str] = StrictField(
         None,
         description="a JSON Pointer [RFC6901] to the associated entity in the request document "
         '[e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute].',
     )
-    parameter: Optional[str] = Field(
+    parameter: Optional[str] = StrictField(
         None,
         description="a string indicating which URI query parameter caused the error.",
     )
@@ -117,34 +123,34 @@ class ErrorSource(BaseModel):
 class Error(BaseModel):
     """An error response"""
 
-    id: Optional[str] = Field(
+    id: Optional[str] = StrictField(
         None,
         description="A unique identifier for this particular occurrence of the problem.",
     )
-    links: Optional[ErrorLinks] = Field(
+    links: Optional[ErrorLinks] = StrictField(
         None, description="A links object storing about"
     )
-    status: Optional[str] = Field(
+    status: Optional[str] = StrictField(
         None,
         description="the HTTP status code applicable to this problem, expressed as a string value.",
     )
-    code: Optional[str] = Field(
+    code: Optional[str] = StrictField(
         None,
         description="an application-specific error code, expressed as a string value.",
     )
-    title: Optional[str] = Field(
+    title: Optional[str] = StrictField(
         None,
         description="A short, human-readable summary of the problem. "
         "It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.",
     )
-    detail: Optional[str] = Field(
+    detail: Optional[str] = StrictField(
         None,
         description="A human-readable explanation specific to this occurrence of the problem.",
     )
-    source: Optional[ErrorSource] = Field(
+    source: Optional[ErrorSource] = StrictField(
         None, description="An object containing references to the source of the error"
     )
-    meta: Optional[Meta] = Field(
+    meta: Optional[Meta] = StrictField(
         None,
         description="a meta object containing non-standard meta-information about the error.",
     )
@@ -156,8 +162,8 @@ class Error(BaseModel):
 class BaseResource(BaseModel):
     """Minimum requirements to represent a Resource"""
 
-    id: str = Field(..., description="Resource ID")
-    type: str = Field(..., description="Resource type")
+    id: str = StrictField(..., description="Resource ID")
+    type: str = StrictField(..., description="Resource type")
 
     class Config:
         @staticmethod
@@ -189,14 +195,14 @@ class RelationshipLinks(BaseModel):
 
     """
 
-    self: Optional[Union[AnyUrl, Link]] = Field(
+    self: Optional[Union[AnyUrl, Link]] = StrictField(
         None,
         description="""A link for the relationship itself (a 'relationship link').
 This link allows the client to directly manipulate the relationship.
 When fetched successfully, this link returns the [linkage](https://jsonapi.org/format/1.0/#document-resource-object-linkage) for the related resources as its primary data.
 (See [Fetching Relationships](https://jsonapi.org/format/1.0/#fetching-relationships).)""",
     )
-    related: Optional[Union[AnyUrl, Link]] = Field(
+    related: Optional[Union[AnyUrl, Link]] = StrictField(
         None,
         description="A [related resource link](https://jsonapi.org/format/1.0/#document-resource-object-related-resource-links).",
     )
@@ -216,14 +222,14 @@ When fetched successfully, this link returns the [linkage](https://jsonapi.org/f
 class Relationship(BaseModel):
     """Representation references from the resource object in which it’s defined to other resource objects."""
 
-    links: Optional[RelationshipLinks] = Field(
+    links: Optional[RelationshipLinks] = StrictField(
         None,
         description="a links object containing at least one of the following: self, related",
     )
-    data: Optional[Union[BaseResource, List[BaseResource]]] = Field(
-        None, description="Resource linkage", uniqueItems=True
+    data: Optional[Union[BaseResource, List[BaseResource]]] = StrictField(
+        None, description="Resource linkage"
     )
-    meta: Optional[Meta] = Field(
+    meta: Optional[Meta] = StrictField(
         None,
         description="a meta object that contains non-standard meta-information about the relationship.",
     )
@@ -262,7 +268,7 @@ class Relationships(BaseModel):
 class ResourceLinks(BaseModel):
     """A Resource Links object"""
 
-    self: Optional[Union[AnyUrl, Link]] = Field(
+    self: Optional[Union[AnyUrl, Link]] = StrictField(
         None,
         description="A link that identifies the resource represented by the resource object.",
     )
@@ -295,18 +301,18 @@ class Attributes(BaseModel):
 class Resource(BaseResource):
     """Resource objects appear in a JSON API document to represent resources."""
 
-    links: Optional[ResourceLinks] = Field(
+    links: Optional[ResourceLinks] = StrictField(
         None, description="a links object containing links related to the resource."
     )
-    meta: Optional[Meta] = Field(
+    meta: Optional[Meta] = StrictField(
         None,
         description="a meta object containing non-standard meta-information about a resource that can not be represented as an attribute or relationship.",
     )
-    attributes: Optional[Attributes] = Field(
+    attributes: Optional[Attributes] = StrictField(
         None,
         description="an attributes object representing some of the resource’s data.",
     )
-    relationships: Optional[Relationships] = Field(
+    relationships: Optional[Relationships] = StrictField(
         None,
         description="""[Relationships object](https://jsonapi.org/format/1.0/#document-resource-object-relationships)
 describing relationships between the resource and other JSON API resources.""",
@@ -316,23 +322,23 @@ describing relationships between the resource and other JSON API resources.""",
 class Response(BaseModel):
     """A top-level response"""
 
-    data: Optional[Union[None, Resource, List[Resource]]] = Field(
+    data: Optional[Union[None, Resource, List[Resource]]] = StrictField(
         None, description="Outputted Data", uniqueItems=True
     )
-    meta: Optional[Meta] = Field(
+    meta: Optional[Meta] = StrictField(
         None,
         description="A meta object containing non-standard information related to the Success",
     )
-    errors: Optional[List[Error]] = Field(
+    errors: Optional[List[Error]] = StrictField(
         None, description="A list of unique errors", uniqueItems=True
     )
-    included: Optional[List[Resource]] = Field(
+    included: Optional[List[Resource]] = StrictField(
         None, description="A list of unique included resources", uniqueItems=True
     )
-    links: Optional[ToplevelLinks] = Field(
+    links: Optional[ToplevelLinks] = StrictField(
         None, description="Links associated with the primary data or errors"
     )
-    jsonapi: Optional[JsonApi] = Field(
+    jsonapi: Optional[JsonApi] = StrictField(
         None, description="Information about the JSON API used"
     )
 
diff --git a/optimade/models/links.py b/optimade/models/links.py
index c6e6c57a..4598435a 100644
--- a/optimade/models/links.py
+++ b/optimade/models/links.py
@@ -2,7 +2,6 @@
 from enum import Enum
 
 from pydantic import (  # pylint: disable=no-name-in-module
-    Field,
     AnyUrl,
     root_validator,
 )
@@ -10,6 +9,7 @@ from typing import Union, Optional
 
 from optimade.models.jsonapi import Link, Attributes
 from optimade.models.entries import EntryResource
+from optimade.models.utils import StrictField
 
 
 __all__ = (
@@ -39,31 +39,31 @@ class Aggregate(Enum):
 class LinksResourceAttributes(Attributes):
     """Links endpoint resource object attributes"""
 
-    name: str = Field(
+    name: str = StrictField(
         ...,
         description="Human-readable name for the OPTIMADE API implementation, e.g., for use in clients to show the name to the end-user.",
     )
-    description: str = Field(
+    description: str = StrictField(
         ...,
         description="Human-readable description for the OPTIMADE API implementation, e.g., for use in clients to show a description to the end-user.",
     )
-    base_url: Optional[Union[AnyUrl, Link]] = Field(
+    base_url: Optional[Union[AnyUrl, Link]] = StrictField(
         ...,
         description="JSON API links object, pointing to the base URL for this implementation",
     )
 
-    homepage: Optional[Union[AnyUrl, Link]] = Field(
+    homepage: Optional[Union[AnyUrl, Link]] = StrictField(
         ...,
         description="JSON API links object, pointing to a homepage URL for this implementation",
     )
 
-    link_type: LinkType = Field(
+    link_type: LinkType = StrictField(
         ...,
         description="""The type of the linked relation.
 MUST be one of these values: 'child', 'root', 'external', 'providers'.""",
     )
 
-    aggregate: Optional[Aggregate] = Field(
+    aggregate: Optional[Aggregate] = StrictField(
         "ok",
         description="""A string indicating whether a client that is following links to aggregate results from different OPTIMADE implementations should follow this link or not.
 This flag SHOULD NOT be indicated for links where `link_type` is not `child`.
@@ -77,7 +77,7 @@ A client MAY follow the link anyway if it has reason to do so (e.g., if the clie
 If specified, it MUST be one of the values listed in section Link Aggregate Options.""",
     )
 
-    no_aggregate_reason: Optional[str] = Field(
+    no_aggregate_reason: Optional[str] = StrictField(
         None,
         description="""An OPTIONAL human-readable string indicating the reason for suggesting not to aggregate results following the link.
 It SHOULD NOT be present if `aggregate`=`ok`.""",
@@ -87,14 +87,14 @@ It SHOULD NOT be present if `aggregate`=`ok`.""",
 class LinksResource(EntryResource):
     """A Links endpoint resource object"""
 
-    type: str = Field(
+    type: str = StrictField(
         "links",
         const="links",
         description="These objects are described in detail in the section Links Endpoint",
         pattern="^links$",
     )
 
-    attributes: LinksResourceAttributes = Field(
+    attributes: LinksResourceAttributes = StrictField(
         ...,
         description="A dictionary containing key-value pairs representing the Links resource's properties.",
     )
diff --git a/optimade/models/optimade_json.py b/optimade/models/optimade_json.py
index 07ad1840..75372afd 100644
--- a/optimade/models/optimade_json.py
+++ b/optimade/models/optimade_json.py
@@ -2,13 +2,13 @@
 # pylint: disable=no-self-argument,no-name-in-module
 from enum import Enum
 
-from pydantic import Field, root_validator, BaseModel, AnyHttpUrl, AnyUrl, EmailStr
+from pydantic import root_validator, BaseModel, AnyHttpUrl, AnyUrl, EmailStr
 from typing import Optional, Union, List, Dict, Type, Any
 
 from datetime import datetime
 
 from optimade.models import jsonapi
-from optimade.models.utils import SemanticVersion
+from optimade.models.utils import SemanticVersion, StrictField
 
 
 __all__ = (
@@ -126,7 +126,7 @@ class DataType(Enum):
 class OptimadeError(jsonapi.Error):
     """detail MUST be present"""
 
-    detail: str = Field(
+    detail: str = StrictField(
         ...,
         description="A human-readable explanation specific to this occurrence of the problem.",
     )
@@ -144,7 +144,7 @@ class Warnings(OptimadeError):
 
     """
 
-    type: str = Field(
+    type: str = StrictField(
         "warning",
         const="warning",
         description='Warnings must be of type "warning"',
@@ -182,7 +182,7 @@ class Warnings(OptimadeError):
 class ResponseMetaQuery(BaseModel):
     """ Information on the query that was requested. """
 
-    representation: str = Field(
+    representation: str = StrictField(
         ...,
         description="""A string with the part of the URL following the versioned or unversioned base URL that serves the API.
 Query parameters that have not been used in processing the request MAY be omitted.
@@ -194,18 +194,18 @@ Example: `/structures?filter=nelements=2`""",
 class Provider(BaseModel):
     """Information on the database provider of the implementation."""
 
-    name: str = Field(..., description="a short name for the database provider")
+    name: str = StrictField(..., description="a short name for the database provider")
 
-    description: str = Field(
+    description: str = StrictField(
         ..., description="a longer description of the database provider"
     )
 
-    prefix: str = Field(
+    prefix: str = StrictField(
         ...,
         description="database-provider-specific prefix as found in section Database-Provider-Specific Namespace Prefixes.",
     )
 
-    homepage: Optional[Union[AnyHttpUrl, jsonapi.Link]] = Field(
+    homepage: Optional[Union[AnyHttpUrl, jsonapi.Link]] = StrictField(
         None,
         description="a [JSON API links object](http://jsonapi.org/format/1.0#document-links) "
         "pointing to homepage of the database provider, either "
@@ -216,29 +216,29 @@ class Provider(BaseModel):
 class ImplementationMaintainer(BaseModel):
     """Details about the maintainer of the implementation"""
 
-    email: EmailStr = Field(..., description="the maintainer's email address")
+    email: EmailStr = StrictField(..., description="the maintainer's email address")
 
 
 class Implementation(BaseModel):
     """Information on the server implementation"""
 
-    name: Optional[str] = Field(None, description="name of the implementation")
+    name: Optional[str] = StrictField(None, description="name of the implementation")
 
-    version: Optional[str] = Field(
+    version: Optional[str] = StrictField(
         None, description="version string of the current implementation"
     )
 
-    homepage: Optional[Union[AnyHttpUrl, jsonapi.Link]] = Field(
+    homepage: Optional[Union[AnyHttpUrl, jsonapi.Link]] = StrictField(
         None,
         description="A [JSON API links object](http://jsonapi.org/format/1.0/#document-links) pointing to the homepage of the implementation.",
     )
 
-    source_url: Optional[Union[AnyUrl, jsonapi.Link]] = Field(
+    source_url: Optional[Union[AnyUrl, jsonapi.Link]] = StrictField(
         None,
         description="A [JSON API links object](http://jsonapi.org/format/1.0/#document-links) pointing to the implementation source, either downloadable archive or version control system.",
     )
 
-    maintainer: Optional[ImplementationMaintainer] = Field(
+    maintainer: Optional[ImplementationMaintainer] = StrictField(
         None,
         description="A dictionary providing details about the maintainer of the implementation.",
     )
@@ -255,24 +255,24 @@ class ResponseMeta(jsonapi.Meta):
     database-provider-specific prefix.
     """
 
-    query: ResponseMetaQuery = Field(
+    query: ResponseMetaQuery = StrictField(
         ..., description="Information on the Query that was requested"
     )
 
-    api_version: SemanticVersion = Field(
+    api_version: SemanticVersion = StrictField(
         ...,
         description="""Presently used full version of the OPTIMADE API.
 The version number string MUST NOT be prefixed by, e.g., "v".
 Examples: `1.0.0`, `1.0.0-rc.2`.""",
     )
 
-    more_data_available: bool = Field(
+    more_data_available: bool = StrictField(
         ...,
         description="`false` if the response contains all data for the request (e.g., a request issued to a single entry endpoint, or a `filter` query at the last page of a paginated response) and `true` if the response is incomplete in the sense that multiple objects match the request, and not all of them have been included in the response (e.g., a query with multiple pages that is not at the last page).",
     )
 
     # start of "SHOULD" fields for meta response
-    optimade_schema: Optional[Union[AnyHttpUrl, jsonapi.Link]] = Field(
+    optimade_schema: Optional[Union[AnyHttpUrl, jsonapi.Link]] = StrictField(
         None,
         alias="schema",
         description="""A [JSON API links object](http://jsonapi.org/format/1.0/#document-links) that points to a schema for the response.
@@ -281,40 +281,40 @@ It is possible that future versions of this specification allows for alternative
 Hence, if the `meta` field of the JSON API links object is provided and contains a field `schema_type` that is not equal to the string `OpenAPI` the client MUST not handle failures to parse the schema or to validate the response against the schema as errors.""",
     )
 
-    time_stamp: Optional[datetime] = Field(
+    time_stamp: Optional[datetime] = StrictField(
         None,
         description="A timestamp containing the date and time at which the query was executed.",
     )
 
-    data_returned: Optional[int] = Field(
+    data_returned: Optional[int] = StrictField(
         None,
         description="An integer containing the total number of data resource objects returned for the current `filter` query, independent of pagination.",
         ge=0,
     )
 
-    provider: Optional[Provider] = Field(
+    provider: Optional[Provider] = StrictField(
         None, description="information on the database provider of the implementation."
     )
 
     # start of "MAY" fields for meta response
-    data_available: Optional[int] = Field(
+    data_available: Optional[int] = StrictField(
         None,
         description="An integer containing the total number of data resource objects available in the database for the endpoint.",
     )
 
-    last_id: Optional[str] = Field(
+    last_id: Optional[str] = StrictField(
         None, description="a string containing the last ID returned"
     )
 
-    response_message: Optional[str] = Field(
+    response_message: Optional[str] = StrictField(
         None, description="response string from the server"
     )
 
-    implementation: Optional[Implementation] = Field(
+    implementation: Optional[Implementation] = StrictField(
         None, description="a dictionary describing the server implementation"
     )
 
-    warnings: Optional[List[Warnings]] = Field(
+    warnings: Optional[List[Warnings]] = StrictField(
         None,
         description="""A list of warning resource objects representing non-critical errors or warnings.
 A warning resource object is defined similarly to a [JSON API error object](http://jsonapi.org/format/1.0/#error-objects), but MUST also include the field `type`, which MUST have the value `"warning"`.
@@ -328,7 +328,7 @@ This is an exclusive field for error resource objects.""",
 class Success(jsonapi.Response):
     """errors are not allowed"""
 
-    meta: ResponseMeta = Field(
+    meta: ResponseMeta = StrictField(
         ..., description="A meta object containing non-standard information"
     )
 
@@ -351,7 +351,7 @@ class Success(jsonapi.Response):
 class BaseRelationshipMeta(jsonapi.Meta):
     """Specific meta field for base relationship resource"""
 
-    description: str = Field(
+    description: str = StrictField(
         ..., description="OPTIONAL human-readable description of the relationship"
     )
 
@@ -359,7 +359,7 @@ class BaseRelationshipMeta(jsonapi.Meta):
 class BaseRelationshipResource(jsonapi.BaseResource):
     """Minimum requirements to represent a relationship resource"""
 
-    meta: Optional[BaseRelationshipMeta] = Field(
+    meta: Optional[BaseRelationshipMeta] = StrictField(
         None,
         description="Relationship meta field. MUST contain 'description' if supplied.",
     )
@@ -370,4 +370,4 @@ class Relationship(jsonapi.Relationship):
 
     data: Optional[
         Union[BaseRelationshipResource, List[BaseRelationshipResource]]
-    ] = Field(None, description="Resource linkage", uniqueItems=True)
+    ] = StrictField(None, description="Resource linkage", uniqueItems=True)
diff --git a/optimade/models/references.py b/optimade/models/references.py
index 90570ae0..83a8c7d9 100644
--- a/optimade/models/references.py
+++ b/optimade/models/references.py
@@ -1,6 +1,5 @@
 # pylint: disable=line-too-long,no-self-argument
 from pydantic import (  # pylint: disable=no-name-in-module
-    Field,
     BaseModel,
     AnyUrl,
     validator,
@@ -8,15 +7,35 @@ from pydantic import (  # pylint: disable=no-name-in-module
 from typing import List, Optional
 
 from optimade.models.entries import EntryResource, EntryResourceAttributes
+from optimade.models.utils import OptimadeField, SupportLevel
 
 
 __all__ = ("Person", "ReferenceResourceAttributes", "ReferenceResource")
 
 
 class Person(BaseModel):
-    name: str = Field(..., decsription="""Full name of the person, REQUIRED.""")
-    firstname: Optional[str] = Field(None, description="""First name of the person.""")
-    lastname: Optional[str] = Field(None, description="""Last name of the person.""")
+    """A person, i.e., an author, editor or other."""
+
+    name: str = OptimadeField(
+        ...,
+        description="""Full name of the person, REQUIRED.""",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.OPTIONAL,
+    )
+
+    firstname: Optional[str] = OptimadeField(
+        None,
+        description="""First name of the person.""",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
+    )
+
+    lastname: Optional[str] = OptimadeField(
+        None,
+        description="""Last name of the person.""",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
+    )
 
 
 class ReferenceResourceAttributes(EntryResourceAttributes):
@@ -27,87 +46,186 @@ class ReferenceResourceAttributes(EntryResourceAttributes):
 
     """
 
-    authors: Optional[List[Person]] = Field(
+    authors: Optional[List[Person]] = OptimadeField(
         None,
         description="List of person objects containing the authors of the reference.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    editors: Optional[List[Person]] = Field(
+
+    editors: Optional[List[Person]] = OptimadeField(
         None,
         description="List of person objects containing the editors of the reference.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    doi: Optional[str] = Field(
-        None, description="The digital object identifier of the reference."
+    doi: Optional[str] = OptimadeField(
+        None,
+        description="The digital object identifier of the reference.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    url: Optional[AnyUrl] = Field(None, description="The URL of the reference.")
+    url: Optional[AnyUrl] = OptimadeField(
+        None,
+        description="The URL of the reference.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
+    )
 
-    address: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+    address: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    annote: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    annote: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    booktitle: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    booktitle: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    chapter: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    chapter: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    crossref: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    crossref: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    edition: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    edition: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    howpublished: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    howpublished: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    institution: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    institution: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    journal: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    journal: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    key: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    key: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    month: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    month: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    note: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    note: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    number: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    number: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    organization: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    organization: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    pages: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    pages: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    publisher: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    publisher: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    school: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    school: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    series: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    series: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    title: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    title: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    bib_type: Optional[str] = Field(
+
+    bib_type: Optional[str] = OptimadeField(
         None,
         description="Type of the reference, corresponding to the **type** property in the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    volume: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    volume: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
-    year: Optional[str] = Field(
-        None, description="Meaning of property matches the BiBTeX specification."
+
+    year: Optional[str] = OptimadeField(
+        None,
+        description="Meaning of property matches the BiBTeX specification.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
 
 
@@ -130,7 +248,7 @@ class ReferenceResource(EntryResource):
 
     """
 
-    type: str = Field(
+    type: str = OptimadeField(
         "references",
         const="references",
         description="""The name of the type of an entry.
@@ -143,6 +261,8 @@ class ReferenceResource(EntryResource):
     - The entry of type <type> and ID <id> MUST be returned in response to a request for `/<type>/<id>` under the versioned base URL.
 - **Example**: `"structures"`""",
         pattern="^references$",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.MUST,
     )
     attributes: ReferenceResourceAttributes
 
diff --git a/optimade/models/responses.py b/optimade/models/responses.py
index 61fe03a4..6662fa26 100644
--- a/optimade/models/responses.py
+++ b/optimade/models/responses.py
@@ -11,6 +11,7 @@ from optimade.models.links import LinksResource
 from optimade.models.optimade_json import Success, ResponseMeta, OptimadeError
 from optimade.models.references import ReferenceResource
 from optimade.models.structures import StructureResource
+from optimade.models.utils import StrictField
 
 
 __all__ = (
@@ -31,10 +32,10 @@ __all__ = (
 class ErrorResponse(Response):
     """errors MUST be present and data MUST be skipped"""
 
-    meta: ResponseMeta = Field(
+    meta: ResponseMeta = StrictField(
         ..., description="A meta object containing non-standard information"
     )
-    errors: List[OptimadeError] = Field(
+    errors: List[OptimadeError] = StrictField(
         ...,
         description="A list of OPTIMADE-specific JSON API error objects, where the field detail MUST be present.",
         uniqueItems=True,
@@ -48,17 +49,21 @@ class ErrorResponse(Response):
 
 
 class IndexInfoResponse(Success):
-    data: IndexInfoResource = Field(..., description="Index meta-database /info data")
+    data: IndexInfoResource = StrictField(
+        ..., description="Index meta-database /info data"
+    )
 
 
 class EntryInfoResponse(Success):
-    data: EntryInfoResource = Field(
+    data: EntryInfoResource = StrictField(
         ..., description="OPTIMADE information for an entry endpoint"
     )
 
 
 class InfoResponse(Success):
-    data: BaseInfoResource = Field(..., description="The implementations /info data")
+    data: BaseInfoResource = StrictField(
+        ..., description="The implementations /info data"
+    )
 
 
 class EntryResponseOne(Success):
@@ -78,7 +83,7 @@ class EntryResponseMany(Success):
 
 
 class LinksResponse(EntryResponseMany):
-    data: Union[List[LinksResource], List[Dict[str, Any]]] = Field(
+    data: Union[List[LinksResource], List[Dict[str, Any]]] = StrictField(
         ...,
         description="List of unique OPTIMADE links resource objects",
         uniqueItems=True,
@@ -86,13 +91,13 @@ class LinksResponse(EntryResponseMany):
 
 
 class StructureResponseOne(EntryResponseOne):
-    data: Union[StructureResource, Dict[str, Any], None] = Field(
+    data: Union[StructureResource, Dict[str, Any], None] = StrictField(
         ..., description="A single structures entry resource"
     )
 
 
 class StructureResponseMany(EntryResponseMany):
-    data: Union[List[StructureResource], List[Dict[str, Any]]] = Field(
+    data: Union[List[StructureResource], List[Dict[str, Any]]] = StrictField(
         ...,
         description="List of unique OPTIMADE structures entry resource objects",
         uniqueItems=True,
@@ -100,13 +105,13 @@ class StructureResponseMany(EntryResponseMany):
 
 
 class ReferenceResponseOne(EntryResponseOne):
-    data: Union[ReferenceResource, Dict[str, Any], None] = Field(
+    data: Union[ReferenceResource, Dict[str, Any], None] = StrictField(
         ..., description="A single references entry resource"
     )
 
 
 class ReferenceResponseMany(EntryResponseMany):
-    data: Union[List[ReferenceResource], List[Dict[str, Any]]] = Field(
+    data: Union[List[ReferenceResource], List[Dict[str, Any]]] = StrictField(
         ...,
         description="List of unique OPTIMADE references entry resource objects",
         uniqueItems=True,
diff --git a/optimade/models/structures.py b/optimade/models/structures.py
index 09acafef..93a03c27 100644
--- a/optimade/models/structures.py
+++ b/optimade/models/structures.py
@@ -3,11 +3,16 @@ from enum import IntEnum, Enum
 from sys import float_info
 from typing import List, Optional, Union
 
-from pydantic import Field, BaseModel, validator, root_validator, conlist
+from pydantic import BaseModel, validator, root_validator, conlist
 
 from optimade.models.entries import EntryResourceAttributes, EntryResource
-from optimade.models.utils import CHEMICAL_SYMBOLS, EXTRA_SYMBOLS
-
+from optimade.models.utils import (
+    CHEMICAL_SYMBOLS,
+    EXTRA_SYMBOLS,
+    OptimadeField,
+    StrictField,
+    SupportLevel,
+)
 
 EXTENDED_CHEMICAL_SYMBOLS = CHEMICAL_SYMBOLS + EXTRA_SYMBOLS
 
@@ -64,12 +69,14 @@ class Species(BaseModel):
 
     """
 
-    name: str = Field(
+    name: str = OptimadeField(
         ...,
-        decsription="""Gives the name of the species; the **name** value MUST be unique in the `species` list.""",
+        description="""Gives the name of the species; the **name** value MUST be unique in the `species` list.""",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    chemical_symbols: List[str] = Field(
+    chemical_symbols: List[str] = OptimadeField(
         ...,
         description="""MUST be a list of strings of all chemical elements composing this species. Each item of the list MUST be one of the following:
 
@@ -78,9 +85,11 @@ class Species(BaseModel):
 - the special value `"vacancy"` to represent that this site has a non-zero probability of having a vacancy (the respective probability is indicated in the `concentration` list, see below).
 
 If any one entry in the `species` list has a `chemical_symbols` list that is longer than 1 element, the correct flag MUST be set in the list `structure_features`.""",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    concentration: List[float] = Field(
+    concentration: List[float] = OptimadeField(
         ...,
         description="""MUST be a list of floats, with same length as `chemical_symbols`. The numbers represent the relative concentration of the corresponding chemical symbol in this species. The numbers SHOULD sum to one. Cases in which the numbers do not sum to one typically fall only in the following two categories:
 
@@ -88,29 +97,39 @@ If any one entry in the `species` list has a `chemical_symbols` list that is lon
 - Experimental errors in the data present in the database. In this case, it is the responsibility of the client to decide how to process the data.
 
 Note that concentrations are uncorrelated between different site (even of the same species).""",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    mass: Optional[float] = Field(
+    mass: Optional[float] = OptimadeField(
         None,
         description="""If present MUST be a float expressed in a.m.u.""",
         unit="a.m.u.",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    original_name: Optional[str] = Field(
+    original_name: Optional[str] = OptimadeField(
         None,
         description="""Can be any valid Unicode string, and SHOULD contain (if specified) the name of the species that is used internally in the source database.
 
 Note: With regards to "source database", we refer to the immediate source being queried via the OPTIMADE API implementation.""",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    attached: Optional[List[str]] = Field(
+    attached: Optional[List[str]] = OptimadeField(
         None,
         description="""If provided MUST be a list of length 1 or more of strings of chemical symbols for the elements attached to this site, or "X" for a non-chemical element.""",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    nattached: Optional[List[int]] = Field(
+    nattached: Optional[List[int]] = OptimadeField(
         None,
         description="""If provided MUST be a list of length 1 or more of integers indicating the number of attached atoms of the kind specified in the value of the :field:`attached` key.""",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
 
     @validator("chemical_symbols", each_item=True)
@@ -172,21 +191,25 @@ class Assembly(BaseModel):
 
     """
 
-    sites_in_groups: List[List[int]] = Field(
+    sites_in_groups: List[List[int]] = OptimadeField(
         ...,
         description="""Index of the sites (0-based) that belong to each group for each assembly.
 
 - **Examples**:
     - `[[1], [2]]`: two groups, one with the second site, one with the third.
     - `[[1,2], [3]]`: one group with the second and third site, one with the fourth.""",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    group_probabilities: List[float] = Field(
+    group_probabilities: List[float] = OptimadeField(
         ...,
         description="""Statistical probability of each group. It MUST have the same length as `sites_in_groups`.
 It SHOULD sum to one.
 See below for examples of how to specify the probability of the occurrence of a vacancy.
 The possible reasons for the values not to sum to one are the same as already specified above for the `concentration` of each `species`.""",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.OPTIONAL,
     )
 
     @validator("sites_in_groups")
@@ -212,7 +235,7 @@ The possible reasons for the values not to sum to one are the same as already sp
 class StructureResourceAttributes(EntryResourceAttributes):
     """This class contains the Field for the attributes used to represent a structure, e.g. unit cell, atoms, positions."""
 
-    elements: List[str] = Field(
+    elements: List[str] = OptimadeField(
         ...,
         description="""Names of the different elements present in the structure.
 
@@ -232,9 +255,11 @@ class StructureResourceAttributes(EntryResourceAttributes):
 - **Query examples**:
     - A filter that matches all records of structures that contain Si, Al **and** O, and possibly other elements: `elements HAS ALL "Si", "Al", "O"`.
     - To match structures with exactly these three elements, use `elements HAS ALL "Si", "Al", "O" AND elements LENGTH 3`.""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.MUST,
     )
 
-    nelements: int = Field(
+    nelements: int = OptimadeField(
         ...,
         description="""Number of different elements in the structure as an integer.
 
@@ -251,9 +276,11 @@ class StructureResourceAttributes(EntryResourceAttributes):
     - Note: queries on this property can equivalently be formulated using `elements LENGTH`.
     - A filter that matches structures that have exactly 4 elements: `nelements=4`.
     - A filter that matches structures that have between 2 and 7 elements: `nelements>=2 AND nelements<=7`.""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.MUST,
     )
 
-    elements_ratios: List[float] = Field(
+    elements_ratios: List[float] = OptimadeField(
         ...,
         description="""Relative proportions of different elements in the structure.
 
@@ -273,9 +300,11 @@ class StructureResourceAttributes(EntryResourceAttributes):
     - Note: Useful filters can be formulated using the set operator syntax for correlated values.
       However, since the values are floating point values, the use of equality comparisons is generally inadvisable.
     - OPTIONAL: a filter that matches structures where approximately 1/3 of the atoms in the structure are the element Al is: `elements:elements_ratios HAS ALL "Al":>0.3333, "Al":<0.3334`.""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.MUST,
     )
 
-    chemical_formula_descriptive: str = Field(
+    chemical_formula_descriptive: str = OptimadeField(
         ...,
         description="""The chemical formula for a structure as a string in a form chosen by the API implementation.
 
@@ -299,9 +328,11 @@ class StructureResourceAttributes(EntryResourceAttributes):
     - Note: the free-form nature of this property is likely to make queries on it across different databases inconsistent.
     - A filter that matches an exactly given formula: `chemical_formula_descriptive="(H2O)2 Na"`.
     - A filter that does a partial match: `chemical_formula_descriptive CONTAINS "H2O"`.""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.MUST,
     )
 
-    chemical_formula_reduced: str = Field(
+    chemical_formula_reduced: str = OptimadeField(
         ...,
         description="""The reduced chemical formula for a structure as a string with element symbols and integer chemical proportion numbers.
 The proportion number MUST be omitted if it is 1.
@@ -326,9 +357,11 @@ The proportion number MUST be omitted if it is 1.
 
 - **Query examples**:
     - A filter that matches an exactly given formula is `chemical_formula_reduced="H2NaO"`.""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.MUST,
     )
 
-    chemical_formula_hill: Optional[str] = Field(
+    chemical_formula_hill: Optional[str] = OptimadeField(
         None,
         description="""The chemical formula for a structure in [Hill form](https://dx.doi.org/10.1021/ja02046a005) with element symbols followed by integer chemical proportion numbers. The proportion number MUST be omitted if it is 1.
 
@@ -354,9 +387,11 @@ The proportion number MUST be omitted if it is 1.
 
 - **Query examples**:
     - A filter that matches an exactly given formula is `chemical_formula_hill="H2O2"`.""",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    chemical_formula_anonymous: str = Field(
+    chemical_formula_anonymous: str = OptimadeField(
         ...,
         description="""The anonymous formula is the `chemical_formula_reduced`, but where the elements are instead first ordered by their chemical proportion number, and then, in order left to right, replaced by anonymous symbols A, B, C, ..., Z, Aa, Ba, ..., Za, Ab, Bb, ... and so on.
 
@@ -373,9 +408,11 @@ The proportion number MUST be omitted if it is 1.
 
 - **Querying**:
     - A filter that matches an exactly given formula is `chemical_formula_anonymous="A2B"`.""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.MUST,
     )
 
-    dimension_types: conlist(Periodicity, min_items=3, max_items=3) = Field(
+    dimension_types: conlist(Periodicity, min_items=3, max_items=3) = OptimadeField(
         ...,
         description="""List of three integers.
 For each of the three directions indicated by the three lattice vectors (see property `lattice_vectors`), this list indicates if the direction is periodic (value `1`) or non-periodic (value `0`).
@@ -394,9 +431,11 @@ Note: the elements in this list each refer to the direction of the corresponding
     - For a wire along the direction specified by the third lattice vector: `[0, 0, 1]`
     - For a 2D surface/slab, periodic on the plane defined by the first and third lattice vectors: `[1, 0, 1]`
     - For a bulk 3D system: `[1, 1, 1]`""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    nperiodic_dimensions: int = Field(
+    nperiodic_dimensions: int = OptimadeField(
         ...,
         description="""An integer specifying the number of periodic dimensions in the structure, equivalent to the number of non-zero entries in `dimension_types`.
 
@@ -414,9 +453,13 @@ Note: the elements in this list each refer to the direction of the corresponding
 - **Query examples**:
     - Match only structures with exactly 3 periodic dimensions: `nperiodic_dimensions=3`
     - Match all structures with 2 or fewer periodic dimensions: `nperiodic_dimensions<=2`""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.MUST,
     )
 
-    lattice_vectors: conlist(Vector3D_unknown, min_items=3, max_items=3) = Field(
+    lattice_vectors: conlist(
+        Vector3D_unknown, min_items=3, max_items=3
+    ) = OptimadeField(
         ...,
         description="""The three lattice vectors in Cartesian coordinates, in ångström (Å).
 
@@ -438,9 +481,11 @@ Note: the elements in this list each refer to the direction of the corresponding
 - **Examples**:
     - `[[4.0,0.0,0.0],[0.0,4.0,0.0],[0.0,1.0,4.0]]` represents a cell, where the first vector is `(4, 0, 0)`, i.e., a vector aligned along the `x` axis of length 4 Å; the second vector is `(0, 4, 0)`; and the third vector is `(0, 1, 4)`.""",
         unit="Å",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    cartesian_site_positions: List[Vector3D] = Field(
+    cartesian_site_positions: List[Vector3D] = OptimadeField(
         ...,
         description="""Cartesian positions of each site in the structure.
 A site is usually used to describe positions of atoms; what atoms can be encountered at a given site is conveyed by the `species_at_sites` property, and the species themselves are described in the `species` property.
@@ -457,9 +502,11 @@ A site is usually used to describe positions of atoms; what atoms can be encount
 - **Examples**:
     - `[[0,0,0],[0,0,2]]` indicates a structure with two sites, one sitting at the origin and one along the (positive) *z*-axis, 2 Å away from the origin.""",
         unit="Å",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    nsites: int = Field(
+    nsites: int = OptimadeField(
         ...,
         description="""An integer specifying the length of the `cartesian_site_positions` property.
 
@@ -475,9 +522,11 @@ A site is usually used to describe positions of atoms; what atoms can be encount
 - **Query examples**:
     - Match only structures with exactly 4 sites: `nsites=4`
     - Match structures that have between 2 and 7 sites: `nsites>=2 AND nsites<=7`""",
+        queryable=SupportLevel.MUST,
+        support=SupportLevel.SHOULD,
     )
 
-    species: List[Species] = Field(
+    species: List[Species] = OptimadeField(
         ...,
         description="""A list describing the species of the sites of this structure.
 Species can represent pure chemical elements, virtual-crystal atoms representing a statistical occupation of a given site by multiple chemical elements, and/or a location to which there are attached atoms, i.e., atoms whose precise location are unknown beyond that they are attached to that position (frequently used to indicate hydrogen atoms attached to another element, e.g., a carbon with three attached hydrogens might represent a methyl group, -CH3).
@@ -539,9 +588,11 @@ Species can represent pure chemical elements, virtual-crystal atoms representing
     - `[ {"name": "C12", "chemical_symbols": ["C"], "concentration": [1.0], "mass": 12.0} ]`: any site with this species is occupied by a carbon isotope with mass 12.
     - `[ {"name": "C13", "chemical_symbols": ["C"], "concentration": [1.0], "mass": 13.0} ]`: any site with this species is occupied by a carbon isotope with mass 13.
     - `[ {"name": "CH3", "chemical_symbols": ["C"], "concentration": [1.0], "attached": ["H"], "nattached": [3]} ]`: any site with this species is occupied by a methyl group, -CH3, which is represented without specifying precise positions of the hydrogen atoms.""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    species_at_sites: List[str] = Field(
+    species_at_sites: List[str] = OptimadeField(
         ...,
         description="""Name of the species at each site (where values for sites are specified with the same order of the property `cartesian_site_positions`).
 The properties of the species are found in the property `species`.
@@ -561,9 +612,11 @@ The properties of the species are found in the property `species`.
 - **Examples**:
     - `["Ti","O2"]` indicates that the first site is hosting a species labeled `"Ti"` and the second a species labeled `"O2"`.
     - `["Ac", "Ac", "Ag", "Ir"]` indicating the first two sites contains the `"Ac"` species, while the third and fourth sites contain the `"Ag"` and `"Ir"` species, respectively.""",
+        support=SupportLevel.SHOULD,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    assemblies: Optional[List[Assembly]] = Field(
+    assemblies: Optional[List[Assembly]] = OptimadeField(
         None,
         description="""A description of groups of sites that are statistically correlated.
 
@@ -667,9 +720,11 @@ The properties of the species are found in the property `species`.
         Site 0 is present with a probability of 20 % and site 1 with a probability of 80 %. These two sites are correlated (either site 0 or 1 is present). Similarly, site 2 is present with a probability of 30 % and site 3 with a probability of 70 %.
         These two sites are correlated (either site 2 or 3 is present).
         However, the presence or absence of sites 0 and 1 is not correlated with the presence or absence of sites 2 and 3 (in the specific example, the pair of sites (0, 2) can occur with 0.2*0.3 = 6 % probability; the pair (0, 3) with 0.2*0.7 = 14 % probability; the pair (1, 2) with 0.8*0.3 = 24 % probability; and the pair (1, 3) with 0.8*0.7 = 56 % probability).""",
+        support=SupportLevel.OPTIONAL,
+        queryable=SupportLevel.OPTIONAL,
     )
 
-    structure_features: List[StructureFeatures] = Field(
+    structure_features: List[StructureFeatures] = OptimadeField(
         ...,
         description="""A list of strings that flag which special features are used by the structure.
 
@@ -692,6 +747,8 @@ The properties of the species are found in the property `species`.
         - `assemblies`: this flag MUST be present if the property `assemblies` is present.
 
 - **Examples**: A structure having implicit atoms and using assemblies: `["assemblies", "implicit_atoms"]`""",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.MUST,
     )
 
     @validator("elements", each_item=True)
@@ -868,7 +925,7 @@ The properties of the species are found in the property `species`.
 class StructureResource(EntryResource):
     """Representing a structure."""
 
-    type: str = Field(
+    type: str = StrictField(
         "structures",
         const="structures",
         description="""The name of the type of an entry.
@@ -885,6 +942,8 @@ class StructureResource(EntryResource):
 - **Examples**:
     - `"structures"`""",
         pattern="^structures$",
+        support=SupportLevel.MUST,
+        queryable=SupportLevel.MUST,
     )
 
     attributes: StructureResourceAttributes
diff --git a/optimade/models/utils.py b/optimade/models/utils.py
index 8a32a7f8..7876dfd3 100644
--- a/optimade/models/utils.py
+++ b/optimade/models/utils.py
@@ -1,8 +1,116 @@
+import inspect
+import warnings
 import re
+from enum import Enum
+from typing import Optional
+
+from pydantic import Field
+
+_PYDANTIC_FIELD_KWARGS = list(inspect.signature(Field).parameters.keys())
 
 __all__ = ("CHEMICAL_SYMBOLS", "EXTRA_SYMBOLS", "ATOMIC_NUMBERS", "SemanticVersion")
 
 
+class SupportLevel(Enum):
+    MUST = "must"
+    SHOULD = "should"
+    OPTIONAL = "optional"
+
+
+def StrictField(
+    *args,
+    description: str = None,
+    **kwargs,
+) -> Field:
+    """A wrapper around `pydantic.Field` that does the following:
+
+    - Forbids any "extra" keys that would be passed to `pydantic.Field`,
+      except those used elsewhere to modify the schema in-place,
+      e.g. "uniqueItems", "pattern" and those added by OptimadeField, e.g.
+      "unit", "queryable" and "sortable".
+    - Emits a warning when no description is provided.
+
+    Arguments:
+        *args: Positional arguments passed through to `Field`.
+        description: The description of the `Field`; if this is not
+            specified then a `UserWarning` will be emitted.
+        **kwargs: Extra keyword arguments to be passed to `Field`.
+
+    Raises:
+        RuntimeError: If `**kwargs` contains a key not found in the
+            function signature of `Field`, or in the extensions used
+            by models in this package (see above).
+
+    Returns:
+        The pydantic `Field`.
+
+    """
+
+    allowed_keys = [
+        "unit",
+        "pattern",
+        "uniqueItems",
+        "support",
+        "queryable",
+        "sortable",
+    ]
+    _banned = [k for k in kwargs if k not in set(_PYDANTIC_FIELD_KWARGS + allowed_keys)]
+
+    if _banned:
+        raise RuntimeError(
+            f"Not creating StrictField({args}, {kwargs}) with forbidden keywords {_banned}."
+        )
+
+    if description is not None:
+        kwargs["description"] = description
+
+    if description is None:
+        warnings.warn(
+            f"No description provided for StrictField specified by {args}, {kwargs}."
+        )
+
+    return Field(*args, **kwargs)
+
+
+def OptimadeField(
+    *args,
+    support: Optional[SupportLevel] = None,
+    queryable: Optional[SupportLevel] = None,
+    unit: Optional[str] = None,
+    **kwargs,
+) -> Field:
+    """A wrapper around `pydantic.Field` that adds OPTIMADE-specific
+    field paramters `queryable`, `support` and `unit`, indicating
+    the corresponding support level in the specification and the
+    physical unit of the field.
+
+    Arguments:
+        support: The support level of the field itself, i.e. whether the field
+            can be null or omitted by an implementation.
+        queryable: The support level corresponding to the queryablility
+            of this field.
+        unit: A string describing the unit of the field.
+
+    Returns:
+        The pydantic field with extra validation provided by [`StrictField`][optimade.models.utils.StrictField].
+
+    """
+
+    # Collect non-null keyword arguments to add to the Field schema
+    if unit is not None:
+        kwargs["unit"] = unit
+    if queryable is not None:
+        if isinstance(queryable, str):
+            queryable = SupportLevel(queryable.lower())
+        kwargs["queryable"] = queryable
+    if support is not None:
+        if isinstance(support, str):
+            support = SupportLevel(support.lower())
+        kwargs["support"] = support
+
+    return StrictField(*args, **kwargs)
+
+
 class SemanticVersion(str):
     """A custom type for a semantic version, using the recommended
     semver regexp from


Test patch:
diff --git a/tests/models/test_utils.py b/tests/models/test_utils.py
new file mode 100644
index 00000000..279d4d68
--- /dev/null
+++ b/tests/models/test_utils.py
@@ -0,0 +1,76 @@
+import pytest
+from pydantic import BaseModel
+from optimade.models.utils import OptimadeField, StrictField, SupportLevel
+from typing import List, Callable
+
+
+def make_bad_models(field: Callable):
+    """Check that models using `field` to replace `Field` provide
+    appropriate warnings and errors.
+
+    """
+    with pytest.raises(RuntimeError, match="with forbidden keywords"):
+
+        class BadModel(BaseModel):
+            bad_field: int = field(..., random_key="disallowed")
+
+    with pytest.warns(UserWarning, match="No description"):
+
+        class AnotherBadModel(BaseModel):
+            bad_field: int = field(...)
+
+
+def test_strict_field():
+    """Test `StrictField` creation for failure on bad keys, and
+    warnings with no description.
+
+    """
+    make_bad_models(StrictField)
+
+
+def test_optimade_field():
+    """Test `OptimadeField` creation for failure on bad keys, and
+    warnings with no description.
+
+    """
+    make_bad_models(OptimadeField)
+
+
+def test_compatible_strict_optimade_field():
+    """This test checks that OptimadeField and StrictField
+    produce the same schemas when given the same arguments.
+
+    """
+
+    class CorrectModelWithStrictField(BaseModel):
+        # check that unit and uniqueItems are passed through
+        good_field: List[str] = StrictField(
+            ...,
+            support=SupportLevel.MUST,
+            queryable=SupportLevel.OPTIONAL,
+            description="Unit test to make sure that StrictField allows through OptimadeField keys",
+            pattern="^structures$",
+            unit="stringiness",
+            uniqueItems=True,
+            sortable=True,
+        )
+
+    class CorrectModelWithOptimadeField(BaseModel):
+
+        good_field: List[str] = OptimadeField(
+            ...,
+            # Only difference here is that OptimadeField allows case-insensitive
+            # strings to be passed instead of support levels directly
+            support="must",
+            queryable="optional",
+            description="Unit test to make sure that StrictField allows through OptimadeField keys",
+            pattern="^structures$",
+            uniqueItems=True,
+            unit="stringiness",
+            sortable=True,
+        )
+
+    optimade_schema = CorrectModelWithOptimadeField.schema()
+    strict_schema = CorrectModelWithStrictField.schema()
+    strict_schema["title"] = optimade_schema["title"]
+    assert strict_schema == optimade_schema