Prolo — API Design & Data Structures (Deliverable 4)

REST interface of the core procurement system + the SupplierQuoteObject schema. Prolo's pipeline is a client: submit/update pushes across the boundary (Stage 6), search is for consumers.

Conventions: /v1 · tenant-scoped (tenantId) · idempotent on messageId · JWT Bearer · money as numeric (enables Σ = net).   Identity: quoteId = a version; quoteGroupId = the logical quote (a revision is a new version, never an in-place edit).

1 · Endpoints — what FastAPI renders at /docs (from openapi.yaml). Click a row to expand.

GET/v1/quotesSearch & retrieve quote metadata

Parameters

NameInTypeNotes
tenantIdquerystringrequiredtenancy (RLS)
vendor, quoteNumberquerystringfilters
statusqueryQuoteStatusenum
isLatestquerybooleancurrent version only
page, pageSizequeryintegerdefault 1 / 50 (≤100)
expandquerystring[]e.g. lineItems,provenance

Responses

CodeSchemaDescription
200QuoteSearchResponsepage of metadata (no line items)
401/403ProblemDetailsauth / wrong tenant
# 200 · QuoteSearchResponse
{ items: [ QuoteMetadata{ quoteId, quoteGroupId, revision, isLatest,
                          vendorIdentifier, quoteNumber, quoteDate, currency,
                          totals{tax,shipping,net}, status, confidence } ],
  page, pageSize }
GET/v1/quotes/{quoteId}Retrieve one quote (full)

Parameters

NameInType
quoteIdpathuuidrequired
expandquerystring[]

Responses

CodeSchemaDescription
200QuoteResourcemetadata + body (+ expands)
404ProblemDetailsunknown quote
# 200 · QuoteResource = QuoteMetadata +
{ …, quote: SupplierQuote{ vendorIdentifier, totals, lineItems[…] },
     provenance: [ { field, source, page, bbox } ] }
GET/v1/quotes/{quoteId}/revisionsFull version history

Parameters

quoteIdpathuuidrequired

Responses

200QuoteResource[]all versions, ordered by revision
POST/v1/quotesSubmit a newly extracted quote

Parameters

Idempotency-Keyheaderstringrequired= messageId

Request body · application/json · required

SubmitQuoteRequest {
  tenantId: string,  messageId: string,
  quote: SupplierQuote {
    vendorIdentifier: string,
    totals: { tax, shipping, net : number },
    lineItems: [ { sku?: string, description: string,
                   quantity: number, unitOfMeasure: string, price: number } ]  # ≥1
  } }

Responses

CodeSchemaDescription
201QuoteWriteResponsecreated (+ Location)
200QuoteWriteResponseidempotent replay — existing quote, no double-write
422ProblemDetailsschema invalid or Σ(qty×price)+tax+shipping ≠ net
# 201 · QuoteWriteResponse
{ quoteId, quoteGroupId, revision: 1, status: "EXTRACTED" }
POST/v1/quotes/{quoteGroupId}/revisionsSubmit a revision (new version)

Parameters

quoteGroupIdpathuuidrequired
Idempotency-Keyheaderstringrequiredthe NEW email's messageId

Request body

SubmitQuoteRequest   # same shape as submit

Responses

CodeSchemaDescription
201RevisionWriteResponsenew version; prior → SUPERSEDED (one txn)
409ProblemDetailspure duplicate, not a revision
# 201 · RevisionWriteResponse
{ quoteId, quoteGroupId, revision: 3, supersedesId, status: "EXTRACTED" }
PATCH/v1/quotes/{quoteId}Status transition (MATCHED, SUBMITTED)

Parameters

quoteIdpathuuidrequired

Request body

QuoteStatusPatch { status: QuoteStatus }   # status-only, not content

Responses

200QuoteWriteResponseupdated
404ProblemDetailsunknown quote

2 · Schema — SupplierQuoteObject

The transmitted contract: vendor identifier · totals (tax/shipping/net) · line-items array — mapped 1:1 to the brief.

{
  "title": "SupplierQuoteObject", "type": "object",
  "required": ["vendorIdentifier","totals","lineItems"],
  "properties": {
    "vendorIdentifier": { "type":"string" },
    "totals": { "required":["tax","shipping","net"],
      "properties": {
        "tax":      {"type":"number","minimum":0},
        "shipping": {"type":"number","minimum":0},
        "net":      {"type":"number","minimum":0} } },
    "lineItems": { "type":"array", "minItems":1,
      "items": { "required":["description","quantity",
                              "unitOfMeasure","price"],
        "properties": {
          "sku":           {"type":["string","null"]},
          "description":   {"type":"string"},
          "quantity":      {"type":"number","exclusiveMinimum":0},
          "unitOfMeasure": {"type":"string"},
          "price":         {"type":"number","minimum":0} } } } }
}
Brief fieldSchema path
Vendor identifiervendorIdentifier
Totals — Tax/Shipping/Nettotals.tax/.shipping/.net
Line Items — SKU/Desc/Qty/UoM/PricelineItems[].sku/.description/.quantity/.unitOfMeasure/.price

sku nullable — in the brief's list, but quotes often omit it and matching is by semantic embedding of description, not SKU.

Example

{ "vendorIdentifier":"Acme Steel Co.",
  "totals":{"tax":56,"shipping":40,"net":796},
  "lineItems":[{ "sku":"RB-12", "description":"Rebar 12mm",
    "quantity":200, "unitOfMeasure":"m", "price":3.50 }] }

200×3.50 = 700 +56 +40 = 796 = net

Appendix — depth on demand; talk to these only if asked (Pydantic types, deployment, error handling). Not part of the §4 slide.

A · Request / response types per endpoint (Pydantic)

Each endpoint's IN/OUT as Pydantic models — validation layer + the OpenAPI (/docs) contract.

class LineItem(BaseModel):
    sku: str | None = None
    description: str
    quantity: Decimal = Field(gt=0)
    unitOfMeasure: str
    price: Decimal = Field(ge=0)
class Totals(BaseModel): tax: Decimal; shipping: Decimal; net: Decimal
class SupplierQuote(BaseModel):                    # == SupplierQuoteObject
    model_config = ConfigDict(extra="forbid")      # unknown field → 422
    vendorIdentifier: str; totals: Totals
    lineItems: list[LineItem] = Field(min_length=1)
class QuoteStatus(str, Enum): EXTRACTED; NEEDS_REVIEW; MATCHED; SUBMITTED; SUPERSEDED; FAILED
class ProblemDetails(BaseModel):                   # RFC-9457 — OUT for every 4xx/5xx
    type: str; title: str; status: int; detail: str | None; errors: list[dict] | None

1 · GET /v1/quotes

# IN  QuoteSearchParams (query):
#   tenantId, vendor?, quoteNumber?, status?,
#   isLatest?, page, pageSize(≤100), expand[]
# OUT 200 QuoteSearchResponse:
#   items: list[QuoteMetadata]; page; pageSize

2 · GET /v1/quotes/{quoteId}

# IN  path quoteId · query expand[]
# OUT 200 QuoteResource = QuoteMetadata
#     + quote: SupplierQuote + provenance[]

3 · GET …/{quoteId}/revisions

# IN  path quoteId
# OUT 200 list[QuoteResource]

4 · POST /v1/quotes

# IN  SubmitQuoteRequest:
#   tenantId, messageId, quote: SupplierQuote
# OUT 201/200 QuoteWriteResponse:
#   quoteId, quoteGroupId, revision, status

5 · POST …/{quoteGroupId}/revisions

# IN  path quoteGroupId + SubmitQuoteRequest
# OUT 201 RevisionWriteResponse:
#   …WriteResponse + supersedesId

6 · PATCH /v1/quotes/{quoteId}

# IN  QuoteStatusPatch: status
# OUT 200 QuoteWriteResponse

extra="forbid" + Decimal/Field constraints → 422 at the boundary; the Σ = net check runs after Pydantic (business rule).

B · AWS architecture for the API (Deliverable 1 depth)

FastAPI-on-Lambda (Mangum / Web Adapter), behind an internal ALB (target_type="lambda", no API Gateway), direct to Aurora PostgreSQL (no RDS Proxy). No public ingress — the AXO373 lambda_fastapi_service shape.

flowchart TB
    subgraph CLIENTS["Clients — VPC only"]
        PIPE["Prolo pipeline
Stage 6 submitter"] UI["Procurement UI (reads)"] end R53["Route 53 → ALB"] subgraph VPC["🔒 AWS VPC — private (no public ingress)"] direction TB subgraph EDGE["Ingress — ALB, not API Gateway"] WAF["WAFv2"] ALB["Internal ALB · HTTPS:443 · ACM
host-header → lambda target group"] end subgraph PRIV["Private subnets"] LAMBDA["λ Lambda — FastAPI
JWT verify (app)"] AUR[("Aurora PostgreSQL
asyncpg direct, no RDS Proxy")] end subgraph SUP["Platform"] SECR["Secrets Manager / SSM"] CW["CloudWatch"] MIG["Alembic — one-shot"] end end PIPE & UI --> R53 --> WAF --> ALB ALB -->|host-header rule| LAMBDA LAMBDA ==>|"reserved concurrency bounds pool"| AUR MIG -.->|migrate| AUR SECR -.-> LAMBDA LAMBDA -.-> CW classDef db fill:#fde047,stroke:#ca8a04,color:#000 class AUR db style CLIENTS fill:#f2f0ff,stroke:#8250df,color:#1f2328 style VPC fill:#eef4ff,stroke:#2f6fd6,color:#1f2328 style EDGE fill:#e6fbf0,stroke:#1a7f37,color:#1f2328 style PRIV fill:#e2ecff,stroke:#3b7ddd,color:#1f2328 style SUP fill:#fff4e6,stroke:#e8992e,color:#1f2328
  • No API Gateway — Lambda registered directly as an ALB target; reuses the internal ALB.
  • Direct asyncpg, no RDS Proxy — connections bounded by reserved concurrency (execs × pool ≤ max_connections).
  • Idempotency at the DB (UNIQUE(tenant,messageId)); revisions in one transaction. Migrations out of the request path.
  • Two DBs — this Aurora is the core's system of record, not Prolo's pipeline RDS; POST is the P2 projection across the boundary.
C · Error handling (Deliverable 5) + internal envelope
SituationStatusBehaviour
Schema / missing field422problem+json; not persisted
Math fails (Σ ≠ net)422rejected — deterministic guard
Duplicate submit (same key)200existing quote, no double-write
Duplicate content, not revision409conflict
Unknown id404
Missing/invalid JWT401rejected at auth dependency
Valid token, wrong tenant403sanitised message
Core API down / 5xx503transient → SQS redelivers → DLQ; idempotent retry

Internal envelope — the pipeline wraps the thin SupplierQuoteObject with trust/audit metadata (not part of the transmitted contract):

{ "quote": { /* SupplierQuoteObject — the only part the core validates */ },
  "status":"EXTRACTED|…|SUBMITTED", "confidence":{…},   // drives Human Review
  "provenance":[{ "field":"net","page":1,"bbox":[…] }] }   // no RFQ data on the quote

§4 in one line: three endpoints — GET (search), POST /quotes (submit), POST /quotes/{group}/revisions (revision-as-new-version) — over a thin SupplierQuoteObject validated by schema and the Σ = net check.