Skip to content

add email metadata and relational validations - #5

Open
aaronbrown-nava wants to merge 7 commits into
mainfrom
aaron-brown/11587_validation_error_messaging_through_zod
Open

add email metadata and relational validations#5
aaronbrown-nava wants to merge 7 commits into
mainfrom
aaron-brown/11587_validation_error_messaging_through_zod

Conversation

@aaronbrown-nava

@aaronbrown-nava aaronbrown-nava commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Work for #11587

Adds shared schema/OpenAPI functionality needed to expose structured validation metadata to API consumers.

This is the grants-shared portion of the validation proof of concept being explored in #11587. The accompanying simpler-grants-gov PR uses this metadata to generate frontend Zod validation schemas and map validation failures to user-friendly translated messages.

Changes proposed

  • Update OpenAPI schema generation to avoid Python-specific YAML references/aliases (!!python/...) in the generated specification.
  • Add email validation metadata to schema fields so email constraints are represented correctly in OpenAPI.
  • Add reusable relational validation support for validations involving multiple fields.
  • Expose relational validation rules as x-relational-validations OpenAPI metadata.
  • Include structured information about relational validations such as:
    • left and right fields
    • comparison operator
    • fields that should receive validation errors
    • error/validation type
    • validation message
  • Support relational comparisons such as date ordering and numeric ordering while keeping the validation rule defined on the backend.

example output of a relational validator in the openapi spec

      x-relational-validations:
      - &id086
        left_field: award_ceiling
        operator: less_than_or_equal
        right_field: estimated_total_program_funding
        target_fields:
        - award_ceiling
        - estimated_total_program_funding
        error_type: invalid
        validation_type: numeric_order
        message: Award maximum must be less than or equal to estimated total program
          funding

Context for reviewers

The goal of #11587 is to investigate a less brittle way for the frontend to handle backend validation errors rather than mapping user-facing messages to raw backend error strings.

This PR provides the shared backend/OpenAPI pieces of that experiment.

The larger approach treats the backend schema as the source of truth for validation rules. grants-shared provides the validation behavior and exposes enough structured metadata through OpenAPI for API consumers to understand those rules without duplicating their definitions.

For example, a schema can define a relationship such as:

  • Publish date must be on or before Close date
  • Award minimum must be less than or equal to Award maximum
  • Award minimum/maximum must be less than or equal to Estimated total program funding

The generated OpenAPI specification can then describe that relationship through x-relational-validations.

An accompanying PR in simpler-grants-gov consumes this metadata during Zod generation. That allows the same backend-defined rules to be used for frontend field validation, submit-time validation, and mapping API 422 responses to translated frontend messages.

This is part of a broader proof of concept and is not intended to establish the final architecture by itself.

Validation steps

  1. Install/use this branch of grants-shared from the simpler-grants-gov API project.

  2. Generate the OpenAPI specification:

    cd api
    make openapi-spec

Comment on lines +45 to +46
if isinstance(self.load_default, enum.Enum):
self.metadata["default"] = self.load_default.value

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a quick test, I think this is only an issue on the enum class. If I do:

    my_field = fields.String(
        load_default=ResourceType.INTERNAL
    )

It generates fine, it's only if I set a load_default in fields.Enum. What I'd probably do here is just put this logic in the Enum class which already has some special logic (we don't use the enum class from Marshmallow because it did not work as expected).

Comment on lines +50 to +55
if isinstance(example, enum.Enum):
self.metadata["example"] = example.value
elif isinstance(example, list):
self.metadata["example"] = [
item.value if isinstance(item, enum.Enum) else item for item in example
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This however does appear to be right as example always has this behavior.

Comment on lines +98 to +103
def __init__(self, **kwargs: typing.Any) -> None:
super().__init__(**kwargs)

if any(isinstance(validator, CustomEmail) for validator in self.validators):
self.metadata["format"] = "email"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried digging into the APISpec docs https://apispec.readthedocs.io/en/latest/index.html to see if there was a better way to do it, and honestly got a bit lost. I looked at what Regexp does in order to get pattern set and it seemed to just be a custom case that didn't have a great parallel for the format field.

I don't think we'd want to do it quite like this since it would mean if I update a validator to have a format I have to also remember to come change this to make it work.

What if instead we had something like this in the base MixinField init:

...

validators=kwargs.get("validate", [])
for validator in validators:
     format_override = validator.get_format_override()

     # We would have to figure out what happens in the event two validators had a format override and probably error:
     if format_override:
           self.metadata["format"] = format_override
     

then in the validators we can add overrides to the metadata for a given type.

Email would return "email" and we'd have a mixin that the rest have that defaults to just None.

This way we make the field ask the validator if it has any special behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants