Skip to content

[Issue #11658] Relax CommonGrants URL pre-validation from strict to lax pydantic HttpUrl - #11659

Merged
SnowboardTechie merged 2 commits into
mainfrom
11658-relax-cg-url-validator-strict
Aug 5, 2026
Merged

[Issue #11658] Relax CommonGrants URL pre-validation from strict to lax pydantic HttpUrl#11659
SnowboardTechie merged 2 commits into
mainfrom
11658-relax-cg-url-validator-strict

Conversation

@SnowboardTechie

@SnowboardTechie SnowboardTechie commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes HHS/simpler-grants-protocol#1068

Prod logs a steady stream of Dropping URL field from CG response: failed dual validation for URLs the response pipeline serves fine, e.g. NASA NSPIRES links with raw {} in the query string. The strict-mode pre-check that drops them mirrors SDK behavior that no longer exists: the SDK dropped model-level strict=True in 0.6.x (HHS/simpler-grants-protocol#555), leaving the pre-check stricter than every validator in the actual egress path. _UrlValidator now uses lax HttpUrl, matching OpportunityBase.source.

The #9904 search-500 protection is untouched. The dual-check's marshmallow leg still drops comma-separated URLs and TLD-less hosts, and HttpUrl still restricts schemes to http/https regardless of strictness (new test covers that).

The old NASA-rejection test becomes a round-trip guard: URLs surviving validate_url() must construct OpportunityBase and load through the marshmallow response schema, so a future SDK that re-tightens URL validation fails CI instead of dropping fields in prod.

Measured impact on real data

additional_info_url comes from legacy fd_link_url (transform_util.py:252), so I ran every AdditionalInformationURL in the public grants.gov daily extract (GrantsDBExtract20260727v2, 32,170 distinct values across 82,975 records) through the dual-check both ways:

distinct occurrences
accepted before and after 30,454 50,154
newly served (was dropped and warned) 778 1,469
still dropped 938 2,638

The 778 are mostly trailing whitespace (497 distinct) and NSPIRES braces (245). Nothing in the corpus contains a tab, newline, or carriage return, so no URL gets silently rewritten by control-character stripping. The 938 still dropped are not links at all: www.grants.gov, askcsid@fema.dhs.gov, See Invitation Letter, N/A. Remaining cg_event=url_validation_error warnings become upstream data-quality signal.

Behavior notes

Validation steps

make test args="tests/src/services/common_grants tests/src/api/common_grants"

All 110 pass locally with Postgres + OpenSearch up. make format-check, make lint, make lint-security, and make db-check are clean.

After deploy, confirm the NSPIRES record's source is populated and no cg_event=url_validation_error WARNING is logged for it:

curl -X POST 'https://api.simpler.grants.gov/common-grants/opportunities/search' \
  -H 'X-API-Key: <KEY>' -H 'Content-Type: application/json' \
  -d '{"search":"NSPIRES","pagination":{"page":1,"pageSize":10}}'

The dual-validator in url_utils.py pre-screened URLs with strict-mode
HttpUrl, inherited from when SDK <=0.5.x ran model_validate() with
model_config strict=True and would crash on RFC-invalid characters.
The SDK dropped model-level strict in 0.6.x, so the strict pre-check
now rejects URLs (e.g. raw {} in query strings, common in agency
NSPIRES links) that every validator in the actual response pipeline
accepts — dropping the source field and logging a WARNING for URLs
that would serve fine.

Lax mode matches the SDK model exactly; the marshmallow leg of the
dual-check still drops the genuinely batch-breaking shapes from #9904
(comma-separated URLs, TLD-less hosts). A round-trip test guards
against a future SDK version re-tightening URL validation.
Cuts history exposition from the validator docstring and the round-trip
test, removes a transform-only NASA test that the round-trip test fully
subsumes, and extracts the shared opp-data and response-load scaffolding
used by the accept and reject halves of the dual-validator contract.
@SnowboardTechie
SnowboardTechie marked this pull request as ready for review July 27, 2026 19:38

@karinamzalez karinamzalez 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.

A few quick questions!

  • Consumers will now get characters like {}, |, and ^ in query strings. The SDKs accept them, but is there anything downstream-- e.g. API docs, the contract, consumer code-- that expects clean URLs and might trip on these? (same Q for attachments since it looks like it uses the same URL helper)
  • The 938 values that don't make it through will keep logging cg_event=url_validation_error. Since those are mostly bad data, is that log volume a concern, or are we treating them as a data-quality signal going forward?

@SnowboardTechie

Copy link
Copy Markdown
Collaborator Author

Good questions. Nothing downstream should trip on them. The marshmallow URL validator is what gates these fields on the way out, and it accepts {}, |, and ^, so the response schema and the OpenAPI it generates agree with what we're serving. Attachments go through the same helper and relax identically. An external consumer applying a stricter RFC parser would still need to handle them itself.

On the logging: not a volume concern, about 3% of records hit it. Treating it as upstream data-quality signal going forward. The remaining values aren't links at all (See Invitation Letter, N/A), and a dropped field is real suppression, so I'd keep it at warning rather than INFO.

@karinamzalez
karinamzalez self-requested a review August 5, 2026 19:29
@SnowboardTechie
SnowboardTechie merged commit d1eeed7 into main Aug 5, 2026
12 checks passed
@SnowboardTechie
SnowboardTechie deleted the 11658-relax-cg-url-validator-strict branch August 5, 2026 20:43
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.

[Catch-all] Relax CommonGrants URL pre-validation so pipeline-safe URLs aren't dropped

2 participants