diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index c79950a..ca23e61 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -54,6 +54,16 @@ import { type CheckoutCompleteRequest, type PostalAddress, } from "../models"; +import { + CheckoutNotModifiableError, + IdempotencyConflictError, + InvalidRequestError, + OutOfStockError, + PaymentFailedError, + ResourceNotFoundError, + UcpError, + ucpErrorResponse, +} from "../utils/ucp_error"; import { type IdParamContext } from "../utils/validation"; // zCompleteCheckoutRequest and CompleteCheckoutRequest are now imported from SDK models @@ -490,7 +500,9 @@ export class CheckoutService { for (const line of checkout.line_items) { const qtyAvail = getInventory(line.item.id); if (qtyAvail === undefined || qtyAvail < line.quantity) { - throw new Error(`Insufficient stock for item ${line.item.id}`); + throw new OutOfStockError( + `Insufficient stock for item ${line.item.id}` + ); } } } @@ -506,9 +518,11 @@ export class CheckoutService { const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { - return c.json( - { detail: "Idempotency key reused with different parameters" }, - 409 + return ucpErrorResponse( + c, + new IdempotencyConflictError( + "Idempotency key reused with different parameters" + ) ); } return c.json(JSON.parse(record.response_body), 201); @@ -531,12 +545,18 @@ export class CheckoutService { const quantity = reqLine.quantity; if (!productId) { - return c.json({ detail: `Line item ${i} missing product ID` }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError(`Line item ${i} missing product ID`) + ); } const product = getProduct(productId); if (!product) { - return c.json({ detail: `Product ${productId} not found` }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError(`Product ${productId} not found`) + ); } lineItems.push({ @@ -616,9 +636,11 @@ export class CheckoutService { return c.json(checkout, 201); } catch (e: unknown) { - return c.json( - { detail: e instanceof Error ? e.message : String(e) }, - 400 + return ucpErrorResponse( + c, + e instanceof UcpError + ? e + : new InvalidRequestError(e instanceof Error ? e.message : String(e)) ); } }; @@ -631,7 +653,10 @@ export class CheckoutService { const checkout = getCheckoutSession(id); if (!checkout) { - return c.json({ detail: "Checkout session not found" }, 404); + return ucpErrorResponse( + c, + new ResourceNotFoundError("Checkout session not found") + ); } return c.json(checkout, 200); }; @@ -648,9 +673,11 @@ export class CheckoutService { const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { - return c.json( - { detail: "Idempotency key reused with different parameters" }, - 409 + return ucpErrorResponse( + c, + new IdempotencyConflictError( + "Idempotency key reused with different parameters" + ) ); } return c.json(JSON.parse(record.response_body), 200); @@ -662,16 +689,21 @@ export class CheckoutService { const existing = getCheckoutSession(id); if (!existing) { - return c.json({ detail: "Checkout session not found" }, 404); + return ucpErrorResponse( + c, + new ResourceNotFoundError("Checkout session not found") + ); } if ( existing.status === CheckoutResponseStatusSchema.enum.completed || existing.status === CheckoutResponseStatusSchema.enum.canceled ) { - return c.json( - { detail: `Cannot update a ${existing.status} checkout session` }, - 409 + return ucpErrorResponse( + c, + new CheckoutNotModifiableError( + `Cannot update a ${existing.status} checkout session` + ) ); } @@ -701,11 +733,17 @@ export class CheckoutService { const quantity = reqLine.quantity; if (!productId) { - return c.json({ detail: `Line item missing product ID` }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError(`Line item missing product ID`) + ); } const product = getProduct(productId); if (!product) { - return c.json({ detail: `Product ${productId} not found` }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError(`Product ${productId} not found`) + ); } newLineItems.push({ @@ -749,9 +787,11 @@ export class CheckoutService { return c.json(existing, 200); } catch (e: unknown) { - return c.json( - { detail: e instanceof Error ? e.message : String(e) }, - 400 + return ucpErrorResponse( + c, + e instanceof UcpError + ? e + : new InvalidRequestError(e instanceof Error ? e.message : String(e)) ); } }; @@ -768,9 +808,11 @@ export class CheckoutService { const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { - return c.json( - { detail: "Idempotency key reused with different parameters" }, - 409 + return ucpErrorResponse( + c, + new IdempotencyConflictError( + "Idempotency key reused with different parameters" + ) ); } return c.json(JSON.parse(record.response_body), 200); @@ -782,7 +824,10 @@ export class CheckoutService { const checkout = getCheckoutSession(id); if (!checkout) { - return c.json({ detail: "Checkout session not found" }, 404); + return ucpErrorResponse( + c, + new ResourceNotFoundError("Checkout session not found") + ); } // Validate Fulfillment is complete. Require at least one method: an empty @@ -798,9 +843,11 @@ export class CheckoutService { ); if (!hasFulfillment) { - return c.json( - { detail: "Fulfillment address and option must be selected" }, - 400 + return ucpErrorResponse( + c, + new InvalidRequestError( + "Fulfillment address and option must be selected" + ) ); } @@ -809,13 +856,19 @@ export class CheckoutService { checkout.status === CheckoutResponseStatusSchema.enum.canceled ) { // If already completed and not caught by idempotency, it's a conflict - return c.json({ detail: `Checkout already completed or canceled` }, 409); + return ucpErrorResponse( + c, + new CheckoutNotModifiableError(`Checkout already completed or canceled`) + ); } // Process Payment const payment = rawBody.payment; if (!payment || !payment.instruments || payment.instruments.length === 0) { - return c.json({ detail: "Missing payment data" }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError("Missing payment data") + ); } const selectedInstrument = payment.instruments[0]; @@ -823,7 +876,10 @@ export class CheckoutService { const handlerId = selectedInstrument.handler_id; const credential = selectedInstrument.credential; if (!credential) { - return c.json({ detail: "Missing credentials in instrument" }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError("Missing credentials in instrument") + ); } if (selectedInstrument.type === "card" && credential.type === "card") { @@ -839,17 +895,32 @@ export class CheckoutService { if (token === "success_token") { // Success } else if (token === "fail_token") { - return c.json( - { detail: "Payment Failed: Insufficient Funds (Mock)" }, - 402 + return ucpErrorResponse( + c, + new PaymentFailedError( + "Payment Failed: Insufficient Funds (Mock)", + "INSUFFICIENT_FUNDS", + 402 + ) ); } else if (token === "fraud_token") { - return c.json( - { detail: "Payment Failed: Fraud Detected (Mock)" }, - 403 + return ucpErrorResponse( + c, + new PaymentFailedError( + "Payment Failed: Fraud Detected (Mock)", + "FRAUD_DETECTED", + 403 + ) ); } else { - return c.json({ detail: `Unknown mock token: ${token}` }, 400); + return ucpErrorResponse( + c, + new PaymentFailedError( + `Unknown mock token: ${token}`, + "UNKNOWN_TOKEN", + 400 + ) + ); } } else if ( handlerId === "google_pay" || @@ -858,9 +929,9 @@ export class CheckoutService { ) { // Mock success } else { - return c.json( - { detail: `Unsupported payment handler: ${handlerId}` }, - 400 + return ucpErrorResponse( + c, + new InvalidRequestError(`Unsupported payment handler: ${handlerId}`) ); } } @@ -879,9 +950,9 @@ export class CheckoutService { for (const reserved of reservedItems) { releaseStock(reserved.id, reserved.qty); } - return c.json( - { detail: `Item ${line.item.id} is out of stock` }, - 409 + return ucpErrorResponse( + c, + new OutOfStockError(`Item ${line.item.id} is out of stock`, 409) ); } reservedItems.push({ id: line.item.id, qty: line.quantity }); @@ -1025,9 +1096,11 @@ export class CheckoutService { const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { - return c.json( - { detail: "Idempotency key reused with different parameters" }, - 409 + return ucpErrorResponse( + c, + new IdempotencyConflictError( + "Idempotency key reused with different parameters" + ) ); } return c.json(JSON.parse(record.response_body), 200); @@ -1038,16 +1111,21 @@ export class CheckoutService { const checkout = getCheckoutSession(id); if (!checkout) { - return c.json({ detail: "Checkout session not found" }, 404); + return ucpErrorResponse( + c, + new ResourceNotFoundError("Checkout session not found") + ); } if ( checkout.status === CheckoutResponseStatusSchema.enum.completed || checkout.status === CheckoutResponseStatusSchema.enum.canceled ) { - return c.json( - { detail: `Cannot cancel a ${checkout.status} checkout session` }, - 409 + return ucpErrorResponse( + c, + new CheckoutNotModifiableError( + `Cannot cancel a ${checkout.status} checkout session` + ) ); } diff --git a/rest/nodejs/src/utils/ucp_error.ts b/rest/nodejs/src/utils/ucp_error.ts new file mode 100644 index 0000000..39746a6 --- /dev/null +++ b/rest/nodejs/src/utils/ucp_error.ts @@ -0,0 +1,109 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type Context } from "hono"; +import { type ContentfulStatusCode } from "hono/utils/http-status"; + +import { UCP_VERSION } from "./config"; + +// UCP error taxonomy, mirroring the Python reference server +// (rest/python/server/exceptions.py). Business and protocol failures answer +// with the UCP error envelope — `ucp.status: "error"` plus a `messages[]` +// entry carrying `code`, `content`, and `severity` — rather than a flat +// `{ detail }` body, per checkout.md's error responses ("the response +// contains `ucp.status: \"error\"` with `messages` describing the failure") +// and checkout-rest.md's protocol errors ("JSON body containing `code` and +// `content`"). + +export type ErrorSeverity = + | "recoverable" + | "requires_buyer_input" + | "requires_buyer_review" + | "unrecoverable"; + +export class UcpError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly statusCode: ContentfulStatusCode, + public readonly severity: ErrorSeverity = "unrecoverable" + ) { + super(message); + this.name = new.target.name; + } +} + +export class ResourceNotFoundError extends UcpError { + constructor(message: string) { + super(message, "RESOURCE_NOT_FOUND", 404); + } +} + +export class IdempotencyConflictError extends UcpError { + constructor(message: string) { + super(message, "IDEMPOTENCY_CONFLICT", 409); + } +} + +export class CheckoutNotModifiableError extends UcpError { + constructor(message: string) { + super(message, "CHECKOUT_NOT_MODIFIABLE", 409); + } +} + +export class OutOfStockError extends UcpError { + constructor(message: string, statusCode: ContentfulStatusCode = 400) { + super(message, "OUT_OF_STOCK", statusCode); + } +} + +export class PaymentFailedError extends UcpError { + constructor( + message: string, + code = "PAYMENT_FAILED", + statusCode: ContentfulStatusCode = 402 + ) { + super(message, code, statusCode, "requires_buyer_input"); + } +} + +export class InvalidRequestError extends UcpError { + constructor(message: string) { + super(message, "INVALID_REQUEST", 400); + } +} + +/** + * Renders a UcpError as the UCP error envelope, byte-shape-identical to the + * Python reference's ucp_exception_handler (rest/python/server/server.py). + */ +export function ucpErrorResponse(c: Context, error: UcpError) { + return c.json( + { + ucp: { + version: UCP_VERSION, + status: "error", + }, + messages: [ + { + type: "error", + code: error.code, + content: error.message, + severity: error.severity, + }, + ], + }, + error.statusCode + ); +} diff --git a/rest/nodejs/test/error_envelope.test.ts b/rest/nodejs/test/error_envelope.test.ts new file mode 100644 index 0000000..b9256a6 --- /dev/null +++ b/rest/nodejs/test/error_envelope.test.ts @@ -0,0 +1,229 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import assert from "node:assert/strict"; +import { before, test } from "node:test"; + +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { CheckoutService } from "../src/api/checkout"; +import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; +import { + CheckoutCompleteRequestSchema, + ExtendedCheckoutCreateRequestSchema, + ExtendedCheckoutUpdateRequestSchema, +} from "../src/models"; +import { UCP_VERSION } from "../src/utils/config"; +import { IdParamSchema, prettyValidation } from "../src/utils/validation"; + +// Checkout business/protocol failures must answer with the UCP error +// envelope — `ucp.status: "error"` plus a typed `messages[]` entry carrying +// `code` and `content` — matching the Python reference server +// (rest/python/server/exceptions.py + server.py ucp_exception_handler), +// rather than the flat `{ detail }` shape. +function buildApp() { + const svc = new CheckoutService(); + const app = new Hono<{ Variables: { logger: typeof console } }>(); + app.use(async (c, next) => { + c.set("logger", console); + await next(); + }); + app.post( + "/checkout-sessions", + zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), + svc.createCheckout + ); + app.get( + "/checkout-sessions/:id", + zValidator("param", IdParamSchema, prettyValidation), + svc.getCheckout + ); + app.put( + "/checkout-sessions/:id", + zValidator("param", IdParamSchema, prettyValidation), + zValidator("json", ExtendedCheckoutUpdateRequestSchema, prettyValidation), + svc.updateCheckout + ); + app.post( + "/checkout-sessions/:id/complete", + zValidator("param", IdParamSchema, prettyValidation), + zValidator("json", CheckoutCompleteRequestSchema, prettyValidation), + svc.completeCheckout + ); + app.post( + "/checkout-sessions/:id/cancel", + zValidator("param", IdParamSchema, prettyValidation), + svc.cancelCheckout + ); + return app; +} + +before(() => { + initDbs(":memory:", ":memory:"); + getProductsDb() + .prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ) + .run("bouquet_roses", "Red Rose", 3500, ""); + const inv = getTransactionsDb().prepare( + "INSERT INTO inventory (product_id, quantity) VALUES (?, ?)" + ); + inv.run("bouquet_roses", 100); + // A product whose stock is exhausted, for the OUT_OF_STOCK path. + getProductsDb() + .prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ) + .run("gardenias", "Gardenia", 2000, ""); + inv.run("gardenias", 0); +}); + +const JSON_HEADERS = { "Content-Type": "application/json" }; +const CREATE_BODY = { + currency: "USD", + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + payment: {}, +}; + +interface UcpErrorBody { + ucp?: { version?: string; status?: string }; + messages?: Array<{ + type?: string; + code?: string; + content?: string; + severity?: string; + }>; + detail?: string; +} + +function assertUcpError( + body: UcpErrorBody, + code: string, + severity = "unrecoverable" +) { + assert.equal(body.detail, undefined, "flat detail shape must be gone"); + assert.equal(body.ucp?.status, "error", "ucp.status must be 'error'"); + assert.equal(body.ucp?.version, UCP_VERSION); + assert.ok( + Array.isArray(body.messages) && body.messages.length > 0, + "messages[] must carry the failure" + ); + const msg = body.messages![0]; + assert.equal(msg.type, "error"); + assert.equal(msg.code, code); + assert.ok(msg.content, "content must state the failure"); + assert.equal(msg.severity, severity); +} + +async function create(app: ReturnType, extra?: object) { + return app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ ...CREATE_BODY, ...extra }), + }); +} + +test("idempotency conflict answers 409 with an IDEMPOTENCY_CONFLICT envelope", async () => { + const app = buildApp(); + const key = `key_${Date.now()}_envelope`; + const first = await app.request("/checkout-sessions", { + method: "POST", + headers: { ...JSON_HEADERS, "Idempotency-Key": key }, + body: JSON.stringify(CREATE_BODY), + }); + assert.equal(first.status, 201); + + const conflicting = await app.request("/checkout-sessions", { + method: "POST", + headers: { ...JSON_HEADERS, "Idempotency-Key": key }, + body: JSON.stringify({ + ...CREATE_BODY, + line_items: [{ item: { id: "bouquet_roses" }, quantity: 2 }], + }), + }); + assert.equal(conflicting.status, 409); + assertUcpError( + (await conflicting.json()) as UcpErrorBody, + "IDEMPOTENCY_CONFLICT" + ); +}); + +test("unknown checkout id answers 404 with a RESOURCE_NOT_FOUND envelope", async () => { + const app = buildApp(); + const res = await app.request("/checkout-sessions/no_such_session"); + assert.equal(res.status, 404); + assertUcpError((await res.json()) as UcpErrorBody, "RESOURCE_NOT_FOUND"); +}); + +test("updating a canceled checkout answers 409 with a CHECKOUT_NOT_MODIFIABLE envelope", async () => { + const app = buildApp(); + const created = (await (await create(app)).json()) as { id: string }; + const canceled = await app.request( + `/checkout-sessions/${created.id}/cancel`, + { method: "POST", headers: JSON_HEADERS } + ); + assert.equal(canceled.status, 200); + + const res = await app.request(`/checkout-sessions/${created.id}`, { + method: "PUT", + headers: JSON_HEADERS, + body: JSON.stringify(CREATE_BODY), + }); + assert.equal(res.status, 409); + assertUcpError((await res.json()) as UcpErrorBody, "CHECKOUT_NOT_MODIFIABLE"); +}); + +test("insufficient stock answers with an OUT_OF_STOCK envelope", async () => { + const app = buildApp(); + const res = await create(app, { + line_items: [{ item: { id: "gardenias" }, quantity: 1 }], + }); + assert.equal(res.status, 400); + assertUcpError((await res.json()) as UcpErrorBody, "OUT_OF_STOCK"); +}); + +test("an unknown product answers with an INVALID_REQUEST envelope", async () => { + const app = buildApp(); + const res = await create(app, { + line_items: [{ item: { id: "no_such_product" }, quantity: 1 }], + }); + assert.equal(res.status, 400); + assertUcpError((await res.json()) as UcpErrorBody, "INVALID_REQUEST"); +}); + +test("completion without fulfillment answers with an INVALID_REQUEST envelope", async () => { + const app = buildApp(); + const created = (await (await create(app)).json()) as { id: string }; + const res = await app.request(`/checkout-sessions/${created.id}/complete`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + payment: { + instruments: [ + { + id: "pi_1", + handler_id: "mock_payment_handler", + type: "card", + brand: "visa", + last_digits: "4242", + credential: { type: "network_token", token: "success_token" }, + }, + ], + }, + }), + }); + assert.equal(res.status, 400); + assertUcpError((await res.json()) as UcpErrorBody, "INVALID_REQUEST"); +}); diff --git a/rest/nodejs/test/fulfillment.test.ts b/rest/nodejs/test/fulfillment.test.ts index 3fa0ad1..69da576 100644 --- a/rest/nodejs/test/fulfillment.test.ts +++ b/rest/nodejs/test/fulfillment.test.ts @@ -257,8 +257,11 @@ test("completing a checkout with no fulfillment selected is rejected", async () body: JSON.stringify(SUCCESS_PAYMENT), }); assert.equal(res.status, 400); - const body = (await res.json()) as { detail: string }; - assert.match(body.detail, /fulfillment/i); + const body = (await res.json()) as { + messages?: Array<{ code?: string; content?: string }>; + }; + assert.equal(body.messages?.[0]?.code, "INVALID_REQUEST"); + assert.match(body.messages?.[0]?.content ?? "", /fulfillment/i); }); test("a checkout with fulfillment fully selected can be completed", async () => { @@ -310,6 +313,9 @@ test("empty fulfillment methods array blocks completion", async () => { body: JSON.stringify(SUCCESS_PAYMENT), }); assert.equal(res.status, 400); - const body = (await res.json()) as { detail: string }; - assert.match(body.detail, /fulfillment/i); + const body = (await res.json()) as { + messages?: Array<{ code?: string; content?: string }>; + }; + assert.equal(body.messages?.[0]?.code, "INVALID_REQUEST"); + assert.match(body.messages?.[0]?.content ?? "", /fulfillment/i); }); diff --git a/rest/nodejs/test/validation_flow.test.ts b/rest/nodejs/test/validation_flow.test.ts index f923dc0..f7cb86c 100644 --- a/rest/nodejs/test/validation_flow.test.ts +++ b/rest/nodejs/test/validation_flow.test.ts @@ -69,8 +69,11 @@ test("an unknown product id is rejected", async () => { { item: { id: "no_such_product" }, quantity: 1 }, ]); assert.equal(res.status, 400); - const body = (await res.json()) as { detail: string }; - assert.match(body.detail, /not found/i); + const body = (await res.json()) as { + messages?: Array<{ code?: string; content?: string }>; + }; + assert.equal(body.messages?.[0]?.code, "INVALID_REQUEST"); + assert.match(body.messages?.[0]?.content ?? "", /not found/i); }); test("ordering more than the available stock is rejected", async () => { @@ -79,8 +82,11 @@ test("ordering more than the available stock is rejected", async () => { { item: { id: "bouquet_roses" }, quantity: 5 }, ]); assert.equal(res.status, 400); - const body = (await res.json()) as { detail: string }; - assert.match(body.detail, /stock/i); + const body = (await res.json()) as { + messages?: Array<{ code?: string; content?: string }>; + }; + assert.equal(body.messages?.[0]?.code, "OUT_OF_STOCK"); + assert.match(body.messages?.[0]?.content ?? "", /stock/i); }); test("ordering within the available stock succeeds", async () => {