diff --git a/.changeset/core-response-guards.md b/.changeset/core-response-guards.md new file mode 100644 index 000000000..a40ee5f67 --- /dev/null +++ b/.changeset/core-response-guards.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-core": minor +--- + +Add response type guards to identify standardized SDK action, success, and error responses. diff --git a/.changeset/webhook-response-helpers.md b/.changeset/webhook-response-helpers.md new file mode 100644 index 000000000..98d14761a --- /dev/null +++ b/.changeset/webhook-response-helpers.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-webhooks": minor +--- + +Add webhook response helpers to identify webhook success responses and determine whether webhook action responses represent successful outcomes. diff --git a/packages/aio-commerce-lib-core/docs/guides/response-helpers.md b/packages/aio-commerce-lib-core/docs/guides/response-helpers.md index 8752d0239..06a40ca97 100644 --- a/packages/aio-commerce-lib-core/docs/guides/response-helpers.md +++ b/packages/aio-commerce-lib-core/docs/guides/response-helpers.md @@ -196,6 +196,32 @@ if (response.type === "success") { This allows TypeScript to narrow the type and provide accurate autocomplete and type checking. +## Checking Unknown Responses + +Use `isActionResponse()`, `isSuccessResponse()`, or `isErrorResponse()` when you receive an unknown value and need to confirm that it matches the SDK action response shape before reading success or error fields: + +```typescript +import { + isActionResponse, + isErrorResponse, + isSuccessResponse, +} from "@adobe/aio-commerce-lib-core/responses"; + +const result = await runAction(params); + +if (isSuccessResponse(result)) { + console.log(`Status: ${result.statusCode}`); +} + +if (isErrorResponse(result)) { + console.log(`Error Status: ${result.error.statusCode}`); +} + +if (isActionResponse(result)) { + console.log(`Response type: ${result.type}`); +} +``` + ## Custom Status Codes For custom status codes not covered by the presets, use the base functions directly: diff --git a/packages/aio-commerce-lib-core/docs/usage.md b/packages/aio-commerce-lib-core/docs/usage.md index 38341339a..1da0e7a6c 100644 --- a/packages/aio-commerce-lib-core/docs/usage.md +++ b/packages/aio-commerce-lib-core/docs/usage.md @@ -38,7 +38,12 @@ throw new ApiError("Request failed", 500); ### Response Helpers ```typescript -import { ok, badRequest } from "@adobe/aio-commerce-lib-core/responses"; +import { + ok, + badRequest, + isSuccessResponse, + isErrorResponse, +} from "@adobe/aio-commerce-lib-core/responses"; // Success response using string shorthand return ok("User retrieved"); @@ -48,6 +53,16 @@ return badRequest("Invalid input"); // Or use full object syntax for additional data return ok({ body: { message: "User retrieved", id: "123" } }); + +// Narrow unknown values before reading response fields +const result = await runAction(params); +if (isSuccessResponse(result)) { + console.log(result.statusCode); +} + +if (isErrorResponse(result)) { + console.log(result.error.statusCode); +} ``` [Read the Response Helpers Guide →](./guides/response-helpers.md) diff --git a/packages/aio-commerce-lib-core/source/responses/helpers.ts b/packages/aio-commerce-lib-core/source/responses/helpers.ts index 76b3f2df2..b50bdd61b 100644 --- a/packages/aio-commerce-lib-core/source/responses/helpers.ts +++ b/packages/aio-commerce-lib-core/source/responses/helpers.ts @@ -66,6 +66,97 @@ export type ActionResponse< | SuccessResponse | ErrorResponse; +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isHeadersRecord(value: unknown): value is HeadersRecord { + return ( + value === undefined || + (isObjectRecord(value) && + Object.values(value).every((entry) => typeof entry === "string")) + ); +} + +function isBodyRecord(value: unknown): value is BodyRecord | undefined { + return value === undefined || isObjectRecord(value); +} + +function isResponsePayload(value: unknown): value is ResponsePayload { + return ( + isObjectRecord(value) && + typeof value.statusCode === "number" && + isBodyRecord(value.body) && + isHeadersRecord(value.headers) + ); +} + +/** + * Determines whether a value is a standardized SDK success response. + * + * @param response - Value to inspect. + * @returns True when the value matches the SDK success response shape. + * + * @example + * ```typescript + * const result = await runAction(params); + * if (isSuccessResponse(result)) { + * console.log(result.statusCode); + * } + * ``` + */ +export function isSuccessResponse( + response: unknown, +): response is SuccessResponse { + return ( + isObjectRecord(response) && + response.type === "success" && + isResponsePayload(response) + ); +} + +/** + * Determines whether a value is a standardized SDK error response. + * + * @param response - Value to inspect. + * @returns True when the value matches the SDK error response shape. + * + * @example + * ```typescript + * const result = await runAction(params); + * if (isErrorResponse(result)) { + * console.log(result.error.statusCode); + * } + * ``` + */ +export function isErrorResponse(response: unknown): response is ErrorResponse { + return ( + isObjectRecord(response) && + response.type === "error" && + isResponsePayload(response.error) + ); +} + +/** + * Determines whether a value is a standardized SDK action response. + * + * @param response - Value to inspect. + * @returns True when the value matches the SDK action response shape. + * + * @example + * ```typescript + * const result = await runAction(params); + * if (isActionResponse(result) && result.type === "success") { + * console.log(result.statusCode); + * } + * ``` + */ +export function isActionResponse( + response: unknown, +): response is ActionResponse { + return isSuccessResponse(response) || isErrorResponse(response); +} + /** * Creates a standardized error response for runtime actions * @see https://developer.adobe.com/app-builder/docs/guides/runtime_guides/creating-actions#unsuccessful-response diff --git a/packages/aio-commerce-lib-core/source/responses/index.ts b/packages/aio-commerce-lib-core/source/responses/index.ts index 7f0477980..cac398480 100644 --- a/packages/aio-commerce-lib-core/source/responses/index.ts +++ b/packages/aio-commerce-lib-core/source/responses/index.ts @@ -17,7 +17,13 @@ * @packageDocumentation */ -export { buildErrorResponse, buildSuccessResponse } from "./helpers"; +export { + buildErrorResponse, + buildSuccessResponse, + isActionResponse, + isErrorResponse, + isSuccessResponse, +} from "./helpers"; export * from "./presets"; export type { ActionResponse, ErrorResponse, SuccessResponse } from "./helpers"; diff --git a/packages/aio-commerce-lib-core/test/spec/responses/helpers.test.ts b/packages/aio-commerce-lib-core/test/spec/responses/helpers.test.ts index b9c7851ab..c1215f71f 100644 --- a/packages/aio-commerce-lib-core/test/spec/responses/helpers.test.ts +++ b/packages/aio-commerce-lib-core/test/spec/responses/helpers.test.ts @@ -12,7 +12,13 @@ import { describe, expect, it } from "vitest"; -import { buildErrorResponse, buildSuccessResponse } from "#responses/helpers"; +import { + buildErrorResponse, + buildSuccessResponse, + isActionResponse, + isErrorResponse, + isSuccessResponse, +} from "#responses/helpers"; describe("responses/helpers", () => { describe("buildErrorResponse", () => { @@ -248,4 +254,84 @@ describe("responses/helpers", () => { } }); }); + + describe("isActionResponse", () => { + it("should return true for a success action response", () => { + const response = buildSuccessResponse(200, { + body: { message: "OK" }, + headers: { "X-Request-Id": "abc123" }, + }); + + expect(isActionResponse(response)).toBe(true); + expect(isSuccessResponse(response)).toBe(true); + expect(isErrorResponse(response)).toBe(false); + }); + + it("should return true for an error action response", () => { + const response = buildErrorResponse(400, { + body: { message: "Bad request" }, + headers: { "X-Request-Id": "abc123" }, + }); + + expect(isActionResponse(response)).toBe(true); + expect(isSuccessResponse(response)).toBe(false); + expect(isErrorResponse(response)).toBe(true); + }); + + it("should return false for non-object values", () => { + expect(isActionResponse(null)).toBe(false); + expect(isActionResponse(undefined)).toBe(false); + expect(isActionResponse("success")).toBe(false); + expect(isActionResponse([])).toBe(false); + expect(isSuccessResponse(null)).toBe(false); + expect(isErrorResponse(null)).toBe(false); + }); + + it("should return false when the response type is missing or unsupported", () => { + expect(isActionResponse({ statusCode: 200 })).toBe(false); + expect(isSuccessResponse({ statusCode: 200 })).toBe(false); + expect(isActionResponse({ statusCode: 200, type: "redirect" })).toBe( + false, + ); + }); + + it("should return false for an invalid success response payload", () => { + expect(isActionResponse({ statusCode: "200", type: "success" })).toBe( + false, + ); + expect(isSuccessResponse({ statusCode: "200", type: "success" })).toBe( + false, + ); + expect( + isActionResponse({ body: null, statusCode: 200, type: "success" }), + ).toBe(false); + expect( + isActionResponse({ body: [], statusCode: 200, type: "success" }), + ).toBe(false); + expect( + isActionResponse({ + headers: { "X-Request-Id": 123 }, + statusCode: 200, + type: "success", + }), + ).toBe(false); + }); + + it("should return false for an invalid error response payload", () => { + expect(isActionResponse({ type: "error" })).toBe(false); + expect(isErrorResponse({ type: "error" })).toBe(false); + expect( + isActionResponse({ + error: { body: "Bad request", statusCode: 400 }, + type: "error", + }), + ).toBe(false); + expect( + isErrorResponse({ + error: { body: "Bad request", statusCode: 400 }, + type: "error", + }), + ).toBe(false); + }); + }); }); diff --git a/packages/aio-commerce-lib-webhooks/docs/usage.md b/packages/aio-commerce-lib-webhooks/docs/usage.md index e8f501f66..f7651bd06 100644 --- a/packages/aio-commerce-lib-webhooks/docs/usage.md +++ b/packages/aio-commerce-lib-webhooks/docs/usage.md @@ -467,6 +467,26 @@ export async function customizeCheckout(params) { } ``` +### Checking Action Results + +Because webhook actions can return HTTP 200 while still blocking the triggering process with an exception operation, callers can't rely on the status code alone to know whether a webhook handler actually succeeded. Use `isWebhookSuccessful()` to inspect an SDK response body instead: + +```typescript +import { + isWebhookSuccessful, + isWebhookSuccessResponse, +} from "@adobe/aio-commerce-sdk/webhooks/responses"; + +const result = await handleWebhook(params); +if (!isWebhookSuccessful(result)) { + throw new Error("Webhook action failed"); +} + +if (isWebhookSuccessResponse(result)) { + console.log(result.body); +} +``` + ## Reference For complete API documentation and additional details, see: diff --git a/packages/aio-commerce-lib-webhooks/source/responses/helpers.ts b/packages/aio-commerce-lib-webhooks/source/responses/helpers.ts new file mode 100644 index 000000000..1dab78da2 --- /dev/null +++ b/packages/aio-commerce-lib-webhooks/source/responses/helpers.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { isWebhookSuccessResponse } from "./types"; + +/** + * Determines whether a webhook action's result represents a successful outcome. + * Adobe Commerce webhooks always respond with HTTP 200, even when the handler + * wants to block the triggering process, so the actual outcome is only visible + * in the response body's `op` field (`op: "exception"` signals a failure). + * + * @param result - The result of the instrumented webhook action. + * @returns True if the webhook response is successful, false otherwise. + * + * @example + * ```typescript + * import { isWebhookSuccessful } from "@adobe/aio-commerce-lib-webhooks/responses"; + * + * const result = await runWebhookAction(params); + * span.setStatus(isWebhookSuccessful(result) ? { code: SpanStatusCode.OK } : { code: SpanStatusCode.ERROR }); + * ``` + */ +export function isWebhookSuccessful(result: unknown): boolean { + if (!isWebhookSuccessResponse(result)) { + return false; + } + + if (!result.body) { + return true; + } + + if (Array.isArray(result.body)) { + return result.body.every((operation) => operation.op !== "exception"); + } + + return result.body.op !== "exception"; +} diff --git a/packages/aio-commerce-lib-webhooks/source/responses/index.ts b/packages/aio-commerce-lib-webhooks/source/responses/index.ts index 0f41e4347..0bf6648c1 100644 --- a/packages/aio-commerce-lib-webhooks/source/responses/index.ts +++ b/packages/aio-commerce-lib-webhooks/source/responses/index.ts @@ -12,10 +12,13 @@ /** biome-ignore-all lint/performance/noBarrelFile: This is the public API for the webhook responses entrypoint */ +// Export response helpers +export * from "./helpers"; // Export all webhook operations export * from "./operations/presets"; // Export HTTP response wrappers export * from "./presets"; +export { isWebhookSuccessResponse } from "./types"; // Export webhook operation types export type { @@ -26,3 +29,4 @@ export type { SuccessOperation, WebhookOperationResponse, } from "./operations/types"; +export type { WebhookSuccessResponse } from "./types"; diff --git a/packages/aio-commerce-lib-webhooks/source/responses/operations/types.ts b/packages/aio-commerce-lib-webhooks/source/responses/operations/types.ts index 73b14c412..e7c2108ca 100644 --- a/packages/aio-commerce-lib-webhooks/source/responses/operations/types.ts +++ b/packages/aio-commerce-lib-webhooks/source/responses/operations/types.ts @@ -81,3 +81,45 @@ export type WebhookOperationResponse = | AddOperation | ReplaceOperation | RemoveOperation; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Determines whether a value is a webhook operation response. + * + * @param body - Value to inspect. + * @returns True when the value matches one of the webhook operation response shapes. + */ +export function isWebhookOperationResponse( + body: unknown, +): body is WebhookOperationResponse { + if (!isRecord(body)) { + return false; + } + + const { op, path } = body; + + if (op === "success" || op === "exception") { + return true; + } + + if (op === "add" || op === "replace") { + return typeof path === "string" && "value" in body; + } + + return op === "remove" && typeof path === "string"; +} + +/** + * Determines whether a value is an array of webhook operation responses. + * + * @param body - Value to inspect. + * @returns True when every array item matches a webhook operation response shape. + */ +export function isWebhookOperationResponseArray( + body: unknown, +): body is WebhookOperationResponse[] { + return Array.isArray(body) && body.every(isWebhookOperationResponse); +} diff --git a/packages/aio-commerce-lib-webhooks/source/responses/types.ts b/packages/aio-commerce-lib-webhooks/source/responses/types.ts new file mode 100644 index 000000000..30a028f9d --- /dev/null +++ b/packages/aio-commerce-lib-webhooks/source/responses/types.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { HTTP_OK } from "@adobe/aio-commerce-lib-api/utils"; + +import { + isWebhookOperationResponse, + isWebhookOperationResponseArray, +} from "./operations/types"; + +import type { SuccessResponse } from "@adobe/aio-commerce-lib-core/responses"; +import type { WebhookOperationResponse } from "./operations/types"; + +/** + * Successful SDK response containing webhook operation response body data. + */ +export type WebhookSuccessResponse = Omit< + SuccessResponse, + "body" | "statusCode" +> & { + body?: WebhookOperationResponse | WebhookOperationResponse[]; + statusCode: typeof HTTP_OK; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +/** + * Determines whether a value is a successful SDK response containing webhook operation response body data. + * + * @param response - Value to inspect. + * @returns True when the value matches the webhook success response shape. + */ +export function isWebhookSuccessResponse( + response: unknown, +): response is WebhookSuccessResponse { + return ( + isRecord(response) && + response.type === "success" && + response.statusCode === HTTP_OK && + (response.body === undefined || + isWebhookOperationResponse(response.body) || + isWebhookOperationResponseArray(response.body)) + ); +} diff --git a/packages/aio-commerce-lib-webhooks/test/unit/responses/helpers.test.ts b/packages/aio-commerce-lib-webhooks/test/unit/responses/helpers.test.ts new file mode 100644 index 000000000..80e4a338f --- /dev/null +++ b/packages/aio-commerce-lib-webhooks/test/unit/responses/helpers.test.ts @@ -0,0 +1,165 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { describe, expect, it } from "vitest"; + +import { + addOperation, + exceptionOperation, + isWebhookSuccessful, + isWebhookSuccessResponse, + ok, + removeOperation, + replaceOperation, + successOperation, +} from "#responses/index"; + +describe("responses/helpers", () => { + describe("isWebhookSuccessResponse", () => { + it("returns true for SDK success responses with webhook operation bodies", () => { + expect(isWebhookSuccessResponse(ok(successOperation()))).toBe(true); + expect( + isWebhookSuccessResponse( + ok([ + addOperation("result/new_field", "value"), + removeOperation("result/old_field"), + ]), + ), + ).toBe(true); + expect(isWebhookSuccessResponse(ok(exceptionOperation("Failed")))).toBe( + true, + ); + }); + + it("returns true for SDK success responses without a body", () => { + expect( + isWebhookSuccessResponse({ statusCode: 200, type: "success" }), + ).toBe(true); + }); + + it("returns false for non-webhook success response shapes", () => { + expect(isWebhookSuccessResponse({ statusCode: 200 })).toBe(false); + expect( + isWebhookSuccessResponse({ + body: { op: "unsupported" }, + statusCode: 200, + type: "success", + }), + ).toBe(false); + expect( + isWebhookSuccessResponse({ + body: [addOperation("result/new_field", "value"), "unsupported"], + statusCode: 200, + type: "success", + }), + ).toBe(false); + expect( + isWebhookSuccessResponse({ statusCode: 500, type: "success" }), + ).toBe(false); + }); + }); + + describe("isWebhookSuccessful", () => { + it("returns false when statusCode is not 200", () => { + expect(isWebhookSuccessful({ statusCode: 500 })).toBe(false); + expect(isWebhookSuccessful({ statusCode: 400 })).toBe(false); + expect(isWebhookSuccessful({ statusCode: 500, type: "success" })).toBe( + false, + ); + }); + + it("returns false when the response is not SDK-shaped", () => { + expect(isWebhookSuccessful({ statusCode: 200 })).toBe(false); + expect(isWebhookSuccessful({ body: {}, statusCode: 200 })).toBe(false); + }); + + it("returns true for an SDK success response without a body", () => { + expect(isWebhookSuccessful({ statusCode: 200, type: "success" })).toBe( + true, + ); + }); + + it("returns false when the body has no op field", () => { + expect( + isWebhookSuccessful({ body: {}, statusCode: 200, type: "success" }), + ).toBe(false); + }); + + it("returns false when the body has an unknown op field", () => { + expect( + isWebhookSuccessful({ + body: { op: "unsupported" }, + statusCode: 200, + type: "success", + }), + ).toBe(false); + }); + + it("returns false when the body op is exception", () => { + const result = ok(exceptionOperation("Product is out of stock")); + expect(isWebhookSuccessful(result)).toBe(false); + }); + + it("returns true for a success operation", () => { + const result = ok(successOperation()); + expect(isWebhookSuccessful(result)).toBe(true); + }); + + it("returns true for add/replace/remove operations", () => { + expect(isWebhookSuccessful(ok(addOperation("result", "value")))).toBe( + true, + ); + expect( + isWebhookSuccessful(ok(replaceOperation("result/price", 10))), + ).toBe(true); + expect( + isWebhookSuccessful(ok(removeOperation("result/deprecated"))), + ).toBe(true); + }); + + it("returns true for an array of operations", () => { + const result = ok([ + addOperation("result/new_field", "value"), + replaceOperation("result/existing_field", "updated"), + removeOperation("result/old_field"), + ]); + + expect(isWebhookSuccessful(result)).toBe(true); + }); + + it("returns false when an array includes an exception operation", () => { + const result = ok([ + addOperation("result/new_field", "value"), + exceptionOperation("Product is out of stock"), + ]); + + expect(isWebhookSuccessful(result)).toBe(false); + }); + + it("returns false when an array includes a non-operation value", () => { + expect( + isWebhookSuccessful({ + body: [addOperation("result/new_field", "value"), "unsupported"], + statusCode: 200, + type: "success", + }), + ).toBe(false); + }); + + it("returns false for non-object input", () => { + expect(isWebhookSuccessful(null)).toBe(false); + expect(isWebhookSuccessful(undefined)).toBe(false); + expect(isWebhookSuccessful("success")).toBe(false); + expect(isWebhookSuccessful(200)).toBe(false); + }); + }); +});