11587 Add email metadata and relational validations - #5
Conversation
| if isinstance(self.load_default, enum.Enum): | ||
| self.metadata["default"] = self.load_default.value |
There was a problem hiding this comment.
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).
| def __init__(self, **kwargs: typing.Any) -> None: | ||
| super().__init__(**kwargs) | ||
|
|
||
| if any(isinstance(validator, CustomEmail) for validator in self.validators): | ||
| self.metadata["format"] = "email" | ||
|
|
There was a problem hiding this comment.
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.
chouinar
left a comment
There was a problem hiding this comment.
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
| key=SchemaValidationError.INVALID, | ||
| message="Invalid input type.", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
make format is causing this to go to two lines.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
ok that was it. comma removed and it goes back to a single line. I don't know when it got added.
| def test_email_exposes_openapi_metadata(): | ||
| validator = validators.Email() | ||
|
|
||
| assert validator.get_openapi_metadata() == { | ||
| "format": "email", | ||
| } |
There was a problem hiding this comment.
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() == 1I'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", |
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
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:
| 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, |
There was a problem hiding this comment.
Should the error differ per type? Or should we at least at a dedicated error for "invalid_comparison"?
chouinar
left a comment
There was a problem hiding this comment.
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).
| key=SchemaValidationError.INVALID, | ||
| message="Invalid input type.", |
There was a problem hiding this comment.
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.
Summary
Work for #11587
Adds shared schema/OpenAPI functionality needed to expose structured validation metadata to API consumers.
This is the
grants-sharedportion of the validation proof of concept being explored in #11587. The accompanyingsimpler-grants-govPR uses this metadata to generate frontend Zod validation schemas and map validation failures to user-friendly translated messages.Changes proposed
!!python/...) in the generated specification.x-relational-validationsOpenAPI metadata.example output of a relational validator in the openapi spec
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-sharedprovides 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:
The generated OpenAPI specification can then describe that relationship through
x-relational-validations.An accompanying PR in
simpler-grants-govconsumes this metadata during Zod generation. That allows the same backend-defined rules to be used for frontend field validation, submit-time validation, and mapping API422responses 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
Install/use this branch of
grants-sharedfrom thesimpler-grants-govAPI project.Generate the OpenAPI specification:
cd api make openapi-spec