Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions api/src/api/common_grants/COMMON_GRANTS_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,31 @@ make lint-ruff
# Type checking
make lint-mypy
```


## Adding Custom Fields

To add custom fields to the openAPI sample documentation follow these steps:
1. Add any new custom fields to the marshmallow schemas in `common_grants_custom_fields.py` this file is the container for all existing custom fields
2. Inside of `common_grants_schemas.py` update the `CustomFields` class to have an additional field named after the field that has been added and set it's value equal to the newly imported custom field. Like so
```python
class CustomFields(Schema):
legacyId = fields.Nested(LegacyId, allow_none=True)
federalOpportunityNumber = fields.Nested(FederalOpportunityNumber, allow_none=True)
assistanceListing = fields.Nested(AssistanceListing, allow_none=True)
agency = fields.Nested(Agency, allow_none=True)
attachments = fields.Nested(Attachments, allow_none=True)
category = fields.Nested(Category, allow_none=True)
fiscalYear = fields.Nested(FiscalYear, allow_none=True)
costSharing = fields.Nested(CostSharing, allow_none=True)
additionalInfo = fields.Nested(AdditionalInfo, allow_none=True)
AgencyContact = fields.Nested(AgencyContact, allow_none=True)
yourNewestField = fields.Nexted(YourNewestField, allow_none=True)

```


3. To verify the changes run `make openapi-spec-common-grants` at the first `/api` directory in the repository and then run `make init make db-seed-local && make populate-search-opportunities make run-logs` to stand up the openApi endpoints

4. After the Docker containers have started go to `localhost:8080/docs` and enter the user key for the `common grants` endpoints.
5. Run the endpoints to validate responses.
231 changes: 231 additions & 0 deletions api/src/api/common_grants/common_grants_custom_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
""" Marshmallow schemas for CommonGrants Protocol customFields

This file contains Marshmallow schemas that are part of the custom field portion of the specification.

NOTE: Once added here the fields should be imported into another Marshmallow file to register them under the CustomFields class there.
At this time there is only one such file and that is common_grants_schemas.py which only supports Opportunity.py

This pattern allows for simple re-use of custom fields across different base objects since if the fields already exist
it should be as simple as importing the already existing field into the file that requires it and adding it to the CustomFields class
as a new property.
"""

from typing import Any

from src.api.schemas.extension import Schema, fields
from src.api.schemas.extension import validators as validate


class CustomFieldType(fields.String):
"""Enum field for custom field types."""

def __init__(self, **kwargs: Any) -> None:
super().__init__(
validate=validate.OneOf(["string", "number", "integer", "boolean", "object", "array"]),
metadata={
"description": "The JSON schema type to use when de-serializing the value field"
},
**kwargs
)


class CustomField(Schema):
"""Schema for defining custom fields on a record."""

fieldType: fields.String
value: fields.MixinField

name = fields.String(required=True, metadata={"example": "eligible_applicants"})
fieldType = CustomFieldType(required=True)
schema = fields.URL(allow_none=True, metadata={"example": "https://example.com/schema"})
value = fields.Raw(required=True, metadata={"example": "nonprofits, state governments"})
description = fields.String(
allow_none=True,
metadata={"example": "The types of organizations eligible to apply"},
)


# ===========================================================================================
# Custom Field Implementations
# ===========================================================================================


class legacySerialId(CustomField):
"""Storing legacy id for compatibility iwth legacy systems"""

name = fields.String(required=True, metadata={"example": "legacySerialId"})
fieldType = fields.String(required=True, metadata={"example": "integer"})
value = fields.Integer(required=True, metadata={"example": "12345"})
description = fields.String(
allow_none=True,
metadata={
"example": "An integer ID for the opportunity, needed for compatibility with legacy systems"
},
)


class federalOpportunityNumber(CustomField):
"""Federal Opportunity Number assigned to this grant opportunity"""

name = fields.String(required=True, metadata={"example": "federalOpportunityNumber"})
fieldType = fields.String(required=True, metadata={"example": "string"})
value = fields.String(required=True, metadata={"example": "ABC-123-XYZ-001"})
description = fields.String(
allow_none=True,
metadata={"example": "The federal opportunity number assigned to this grant opportunity"},
)


class AssistanceListingValue(Schema):
"""Schema for populating the AssistanceListing value field"""

assistanceListingNumber = fields.String(required=True, metadata={"example": "43.012"})
programTitle = fields.String(required=True, metadata={"example": "Space Technology"})


class assistanceListings(CustomField):
"""The assistance listing number and program title for this opportunity"""

name = fields.String(required=True, metadata={"example": "assistanceListings"})
fieldType = fields.String(required=True, metadata={"example": "array"})
value = fields.List(fields.Nested(AssistanceListingValue), required=True)
description = fields.String(
allow_none=True,
metadata={
"example": "The assistance listing number and program title for this opportunity"
},
)


class AgencyValue(Schema):
"""Schema for populating the Agency value field"""

agencyCode = fields.String(required=True, metadata={"example": "US-ABC"})
agencyName = fields.String(allow_none=True, metadata={"example": "Department of Examples"})
topLevelAgencyName = fields.String(
allow_none=True, metadata={"example": "Department of Examples"}
)


class agency(CustomField):
"""Information about the agency offering this opportunity"""

name = fields.String(required=True, metadata={"example": "agency"})
fieldType = fields.String(required=True, metadata={"example": "object"})
value = fields.Nested(AgencyValue, required=True)
description = fields.String(
allow_none=True,
metadata={"example": "Information about the agency offering this opportunity"},
)


class AttachmentValue(Schema):
downloadUrl = fields.URL(allow_none=True, metadata={"example": "https://example.com/file.pdf"})
name = fields.String(required=True, metadata={"example": "example.pdf"})
description = fields.String(
allow_none=True, metadata={"example": "A PDF file with instructions"}
)
sizeInBytes = fields.Integer(required=True, metadata={"example": 1000})
mimeType = fields.String(required=True, metadata={"example": "application/pdf"})
createdAt = fields.DateTime(required=True, metadata={"example": "2025-01-01T17:01:01.000Z"})
lastModifiedAt = fields.DateTime(
required=True, metadata={"example": "2025-01-02T17:30:00.000Z"}
)


class attachments(CustomField):
"""Attachments such as NOFOs or other supplemental documents"""

name = fields.String(required=True, metadata={"example": "attachments"})
fieldType = fields.String(required=True, metadata={"example": "array"})
value = fields.List(fields.Nested(AttachmentValue), required=True)
description = fields.String(
allow_none=True,
metadata={
"example": "Attachments such as NOFOs and supplemental documents for the opportunity"
},
)


class federalFundingSource(CustomField):
"""The category type of the grant opportunity"""

name = fields.String(required=True, metadata={"example": "federalFundingSource"})
fieldType = fields.String(required=True, metadata={"example": "string"})
value = fields.String(required=True, metadata={"example": "discretionary"})
description = fields.String(
allow_none=True,
metadata={"example": "The category type of the grant opportunity"},
)


class AgencyContactValue(Schema):
"""Schema for populating the AgencyContact value field"""

description = fields.String(
allow_none=True,
metadata={"example": "For more information, reach out to Jane Smith at agency US-ABC"},
)
emailAddress = fields.String(allow_none=True, metadata={"example": "fake_email@grants.gov"})
emailDescription = fields.String(
allow_none=True, metadata={"example": "Click me to email the agency"}
)


class contactInfo(CustomField):
"""Contact information for the agency managing this opportunity"""

name = fields.String(required=True, metadata={"example": "contactInfo"})
fieldType = fields.String(required=True, metadata={"example": "object"})
value = fields.Nested(AgencyContactValue, required=True)
description = fields.String(
allow_none=True,
metadata={"example": "Contact information for the agency managing this opportunity"},
)


class AdditionalInfoValue(Schema):
"""Schema for populating the AdditionalInfo value field"""

url = fields.String(allow_none=True, metadata={"example": "grants.gov"})
description = fields.String(allow_none=True, metadata={"example": "Click me for more info"})


class additionalInfo(CustomField):
"""URL and description for additional information about the opportunity"""

name = fields.String(required=True, metadata={"example": "additionalInfo"})
fieldType = fields.String(required=True, metadata={"example": "object"})
value = fields.Nested(AdditionalInfoValue, required=True)
description = fields.String(
allow_none=True,
metadata={
"example": "URL and description for additional information about the opportunity"
},
)


class costSharing(CustomField):
"""Whether cost sharing or matching funds are required for this opportunity"""

name = fields.String(required=True, metadata={"example": "costSharing"})
fieldType = fields.String(required=True, metadata={"example": "boolean"})
value = fields.Boolean(required=True, metadata={"example": True})
description = fields.String(
allow_none=True,
metadata={
"example": "Whether cost sharing or matching funds are required for this opportunity"
},
)


class fiscalYear(CustomField):
"""The fiscal year associated with this opportunity"""

name = fields.String(required=True, metadata={"example": "fiscalYear"})
fieldType = fields.String(required=True, metadata={"example": "number"})
value = fields.Integer(required=True, metadata={"example": 2026})
description = fields.String(
allow_none=True,
metadata={"example": "The fiscal year associated with this opportunity"},
)
69 changes: 32 additions & 37 deletions api/src/api/common_grants/common_grants_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@
This file contains all Marshmallow schemas that correspond to Pydantic models,
organized by category for better maintainability.

NOTICE: This file is COPIED directly from
simpler-grants-protocol/lib/python-sdk/common_grants_sdk/schemas/marshmallow/
and then MODIFIED to change imports as described below.

ORIGINAL:
from marshmallow import Schema, fields, validate
MODIFIED
from src.api.schemas.extension import Schema, fields, validators as validate
"""

from typing import Any

from src.api.common_grants.common_grants_custom_fields import (
additionalInfo,
agency,
assistanceListings,
attachments,
contactInfo,
costSharing,
federalFundingSource,
federalOpportunityNumber,
fiscalYear,
legacySerialId,
)
from src.api.schemas.extension import Schema, fields
from src.api.schemas.extension import validators as validate

Expand Down Expand Up @@ -215,32 +219,6 @@ class SystemMetadata(Schema):
)


class CustomFieldType(fields.String):
"""Enum field for custom field types."""

def __init__(self, **kwargs: Any) -> None:
super().__init__(
validate=validate.OneOf(["string", "number", "integer", "boolean", "object", "array"]),
metadata={
"description": "The JSON schema type to use when de-serializing the value field"
},
**kwargs
)


class CustomField(Schema):
"""Schema for defining custom fields on a record."""

name = fields.String(required=True, metadata={"example": "eligible_applicants"})
fieldType = CustomFieldType(required=True)
schema = fields.URL(allow_none=True, metadata={"example": "https://example.com/schema"})
value = fields.Raw(required=True, metadata={"example": "nonprofits, state governments"})
description = fields.String(
allow_none=True,
metadata={"example": "The types of organizations eligible to apply"},
)


# =============================================================================
# FILTER INFO MODELS
# =============================================================================
Expand Down Expand Up @@ -356,6 +334,24 @@ class OppTimeline(Schema):
)


class OpportunityCustomFields(Schema):
"""
This class serves as the collection point for all custom fields on the OpportunityBase schema.
It is passed to (camelCase) customFields on OpportunityBase with the fields.nested property
"""

legacySerialId = fields.Nested(legacySerialId, allow_none=True)
federalOpportunityNumber = fields.Nested(federalOpportunityNumber, allow_none=True)
assistanceListings = fields.Nested(assistanceListings, allow_none=True)
agency = fields.Nested(agency, allow_none=True)
attachments = fields.Nested(attachments, allow_none=True)
federalFundingSource = fields.Nested(federalFundingSource, allow_none=True)
fiscalYear = fields.Nested(fiscalYear, allow_none=True)
costSharing = fields.Nested(costSharing, allow_none=True)
additionalInfo = fields.Nested(additionalInfo, allow_none=True)
contactInfo = fields.Nested(contactInfo, allow_none=True)


class OpportunityBase(Schema):
"""Base opportunity model."""

Expand Down Expand Up @@ -415,9 +411,8 @@ class OpportunityBase(Schema):
"example": "https://grants.gov/web/grants/view-opportunity.html?oppId=12345",
},
)
customFields = fields.Dict(
keys=fields.String(),
values=fields.Nested(CustomField),
customFields = fields.Nested(
OpportunityCustomFields,
allow_none=True,
metadata={"description": "Additional custom fields specific to this opportunity"},
)
Expand Down
Loading
Loading