Skip to content

11587 Add email metadata and relational validations - #5

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

11587 Add email metadata and relational validations#5
aaronbrown-nava wants to merge 15 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

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 +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.

@aaronbrown-nava aaronbrown-nava changed the title add email metadata and relational validations 11587 Add email metadata and relational validations Aug 19, 2026

@chouinar chouinar left a comment

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 only have a rough idea of what this is trying to do, and that's only because we chatted the other day. Without documentation/tests, I can't review this. With any functionality in grants-shared, documentation is probably even more important than normal, and tests are always required

Comment on lines +21 to +22
key=SchemaValidationError.INVALID,
message="Invalid input type.",

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.

Curious what made this get reformatted, either way is fine, but want to avoid reformatting code repeatedly - if someone makes another change and reformats it back to the way it is, that just adds a lot of churn back and forth.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

make format is causing this to go to two lines.

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.

Did you add the comma to the end of the line?

The way arrays/lists of values work in the formatter is that if you have a list like [1, 2,] it'll prefer to reformat it as:

[
  1,
  2,
]

So that there is a trailing comma. If you take out the comma, it'll only do that if the line is long enough.

This is fine, it's not an issue, if you had adjusted this and happened to add a comma to these, this is behaving right.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

ok that was it. comma removed and it goes back to a single line. I don't know when it got added.

Comment on lines +161 to +166
def test_email_exposes_openapi_metadata():
validator = validators.Email()

assert validator.get_openapi_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.

We don't quite need a test like this, I think we can assume that function calls return expected values. Since this is basically:

def my_func():
    return 1

assert my_func() == 1

I'd want to see it more in the context in which it is used that the format gets populated correctly - so defining a field with this validator and verifying it gets the format set as expected.

left_field="minimum",
operator=RelationalValidationOperator.LESS_THAN_OR_EQUAL,
right_field="maximum",
message="Minimum must be less than or equal to maximum",

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.

Rather than passing in the message, can we have it be something we calculate similar to the field validators https://github.com/HHS/grants-shared/blob/main/backend/grants_shared/src/grants_shared/api/schemas/extension/field_validators.py

So it'd be something like:

# Use the enum to get a nice string, so "less_than" becomes "less than"
comparison_str = operator.value.replace("_", " ")

# eg. "minimum must be less than or equal maximum"
message = "f{left_field} must be {comparison_str} {right_field}"

Main reason is to make it so the message format is consistent and just one less thing to configure.

)

if get_openapi_metadata is not None:
self.metadata.update(get_openapi_metadata())

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.

Just in the interest of avoiding some weird typing since getattr/setattr cause all typing to be ignored on that value (eg. mypy wouldn't notice if you made get_openapi_metadata return something else, including not even being a function), could we instead do:

Suggested change
self.metadata.update(get_openapi_metadata())
for validator in self.validators:
get_openapi_metadata = getattr(
validator,
"get_openapi_metadata",
None,
)
if get_openapi_metadata is not None:
if not isinstance(get_openapi_metadata, typing.Callable):
raise ... # something
# maybe also a check that the return is a dict at least?
self.metadata.update(get_openapi_metadata())

raise ValidationError(
[
MarshmallowErrorContainer(
SchemaValidationError.INVALID,

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.

Should the error differ per type? Or should we at least at a dedicated error for "invalid_comparison"?

@chouinar chouinar left a comment

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.

One last thing - we'll need to make a new version of the grants-shared package so we can pull it in from the APIs.

First, you'll need to pull from main once Hao merges a PR he should be merging shortly since it had its own version bump.

Then follow https://github.com/HHS/grants-shared/tree/main/backend/grants_shared#release-process for how to create a new released version (bump version in PR, once merged to main run a github action).

Comment on lines +21 to +22
key=SchemaValidationError.INVALID,
message="Invalid input type.",

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.

Did you add the comma to the end of the line?

The way arrays/lists of values work in the formatter is that if you have a list like [1, 2,] it'll prefer to reformat it as:

[
  1,
  2,
]

So that there is a trailing comma. If you take out the comma, it'll only do that if the line is long enough.

This is fine, it's not an issue, if you had adjusted this and happened to add a comma to these, this is behaving right.

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