openapi: 3.1.0 info: title: Ratna Gems Storefront API version: 1.0.0 summary: Read-only public catalogue API for the Ratna Gems storefront. description: | Platform-neutral, built on this store's own data model. Not a WordPress or WooCommerce payload shape. **This document is the contract.** It is validated against real responses by `scripts/test-storefront-openapi.ts`, which is wired into `npm test`, and it is the intended source for Kotlin client generation. ### Money Every amount is **integer paise** (1 INR = 100 paise) plus a preformatted Indian-grouped string. Currency is never a float. ### Tax Every price in this catalogue is **GST-inclusive** — `priceIncludesTax` is always `true`. A client that adds tax on top will overcharge. The tax figures are produced by the same `calculateIncludedTaxPaise()` the cart, checkout, order creation and GST invoice use, over the same `Category.hsnCode` and `Category.gstRate`; there is no separate API-side tax table. `gstRateBasisPoints` is the integer form and is authoritative. `gstRatePercent` is the same number as a decimal for display. Real rates in this catalogue include **0.25 %** (25 bp), which is exactly the kind of value that survives a JSON round trip as `0.24999…`; prefer the basis points. The amounts are the tax already inside the effective price, **before** any order-level discount. An order's authoritative figures are computed at checkout, where coupon allocation is known. ### Nulls versus omissions A field that can be absent is always **present and `null`** — it is never omitted. The only structural difference between shapes is that `ProductSummary` has no `description` block and carries media counts rather than media arrays; `ProductDetail` has both. ### Unknown enum values `productType` and `stockStatus` are open sets. New values will be added without a major version. A client **must** treat an unrecognised value as "some other kind" and keep rendering, never crash or drop the record. ### Rate limiting 120 requests per 60 seconds, keyed on client IP, in a FIXED window. Over the limit is `429` with `Retry-After`. This is a courtesy guard on a public read-only endpoint, **not a security control**, and the contract says so rather than letting an integrator assume otherwise. The counter is per process and in memory, so on a multi-instance deployment the effective limit is 120 x instances. The client IP comes from the rightmost `X-Forwarded-For` hop (or `CF-Connecting-IP` when `TRUSTED_PROXY=cloudflare`), which is only meaningful when a proxy you control appends it; exposed directly, a caller can rotate the header. ### Query parameters Unknown parameters are rejected with `400` on **every** endpoint, including those that take none. A **repeated** parameter is also `400`, never last-wins. ### Methods Every endpoint answers `GET`, `HEAD` and `OPTIONS`. `OPTIONS` returns `204` with `Allow: GET, HEAD, OPTIONS`; every write method returns `405` with the identical `Allow`. ### Trailing slash Every path canonicalises **with** a trailing slash. `/api/storefront/v1/products` permanently redirects (308) to `/api/storefront/v1/products/`. Request the slashed form and you never take the hop. ### Compatibility Within v1 the store may **add** fields; it will not remove or retype one. A breaking change gets `/api/storefront/v2/` and v1 keeps answering. Ignore unknown fields rather than validating strictly. contact: name: Ratna Gems url: https://ratnagems.com/contact-us/ license: name: Proprietary servers: - url: https://ratnagems.com/api/storefront/v1 description: Production - url: http://localhost:3000/api/storefront/v1 description: Local development tags: - name: Products - name: Categories - name: Media paths: /: get: tags: [Service] operationId: getServiceDocument summary: What this API is and what it serves. description: | The service document at the API root. It exists because the deprecation header on `/api/v1/android-app` advertises this URL as the successor, and an advertised successor that 404s is worse than none. parameters: - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Service description. content: application/json: schema: { type: object } '304': { $ref: '#/components/responses/NotModified' } '405': { $ref: '#/components/responses/MethodNotAllowed' } '429': { $ref: '#/components/responses/RateLimited' } /products/: get: tags: [Products] operationId: listProducts summary: A page of products. description: | Ordered by the requested `sort`, then by `id` ascending. The `id` tiebreak is mandatory for correctness: every sort key is non-unique, and LIMIT/OFFSET over a partial order returns duplicated and unreachable rows. A page past the end returns an empty `data` array, **not** a 404 — clients paginate to exhaustion. parameters: - $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/PageSize' - $ref: '#/components/parameters/CategorySlug' - $ref: '#/components/parameters/Availability' - $ref: '#/components/parameters/Sort' - $ref: '#/components/parameters/MinPrice' - $ref: '#/components/parameters/MaxPrice' - $ref: '#/components/parameters/MinCarat' - $ref: '#/components/parameters/MaxCarat' - $ref: '#/components/parameters/MinRatti' - $ref: '#/components/parameters/MaxRatti' - $ref: '#/components/parameters/Origin' - $ref: '#/components/parameters/Treatment' - $ref: '#/components/parameters/Certification' - $ref: '#/components/parameters/Mukhi' - $ref: '#/components/parameters/Currency' - $ref: '#/components/parameters/IfNoneMatch' responses: '200': $ref: '#/components/responses/ProductList' '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/InvalidQuery' } '404': { $ref: '#/components/responses/NotFound' } '405': { $ref: '#/components/responses/MethodNotAllowed' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /products/{slug}/: get: tags: [Products] operationId: getProduct summary: One product, in full. description: | A sold-out stone still resolves — its page does too, and a client holding the link deserves the record rather than a 404. An **unpublished** product does not resolve. parameters: - $ref: '#/components/parameters/Slug' - $ref: '#/components/parameters/IfNoneMatch' responses: '200': $ref: '#/components/responses/ProductDetail' '304': { $ref: '#/components/responses/NotModified' } '404': { $ref: '#/components/responses/NotFound' } '405': { $ref: '#/components/responses/MethodNotAllowed' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /categories/: get: tags: [Categories] operationId: listCategories summary: Every reachable category, in display order. description: | Ordered by `sortOrder`, then `name`, then `id`. A category is **listed** when it has purchasable stock directly or in a descendant. `productCount` is the number of currently PURCHASABLE products in the category's primary available-product collection, direct members only, so a parent whose children hold the stock appears with `productCount: 0`. That is accurate; use `parentId` to build the tree. It does NOT include the optional previously-sold reference shelf that some category pages render beneath that collection. Those products are published and indexable but not purchasable, so a category page can show more product cards than `productCount` reports. parameters: - $ref: '#/components/parameters/IfNoneMatch' responses: '200': $ref: '#/components/responses/CategoryList' '304': { $ref: '#/components/responses/NotModified' } '405': { $ref: '#/components/responses/MethodNotAllowed' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /categories/{slug}/: get: tags: [Categories] operationId: getCategory summary: One category. parameters: - $ref: '#/components/parameters/Slug' - $ref: '#/components/parameters/IfNoneMatch' responses: '200': $ref: '#/components/responses/CategoryDetail' '304': { $ref: '#/components/responses/NotModified' } '404': { $ref: '#/components/responses/NotFound' } '405': { $ref: '#/components/responses/MethodNotAllowed' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Internal' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /media/video-poster/{youtubeId}/: get: tags: [Media] operationId: getVideoPoster summary: The poster image for a product video. description: | The single poster URL a client is given. Redirects (302) to this store's cached copy when it has one, and to YouTube's thumbnail CDN when it does not. Clients follow the redirect and never implement a fallback. 302 rather than 301 on purpose: a poster missing today is cached tomorrow, and a permanent redirect would pin a client to the CDN copy. parameters: - name: youtubeId in: path required: true schema: { type: string, pattern: '^[A-Za-z0-9_-]{11}$' } responses: '302': description: Redirect to the poster image. headers: Location: { schema: { type: string, format: uri-reference } } Cache-Control: { schema: { type: string } } '404': { $ref: '#/components/responses/NotFound' } '405': { $ref: '#/components/responses/MethodNotAllowed' } '429': { $ref: '#/components/responses/RateLimited' } components: parameters: Page: name: page in: query required: false schema: { type: integer, minimum: 1, maximum: 10000, default: 1 } PageSize: name: pageSize in: query required: false description: Above 100 is a `400`, never a silent clamp. schema: { type: integer, minimum: 1, maximum: 100, default: 24 } CategorySlug: name: category in: query required: false description: Category slug. Direct members only, never descendants. An unknown slug is a `404`. schema: { type: string, minLength: 1, maxLength: 200 } Availability: name: availability in: query required: false description: | Four-state, replacing the ambiguous `inStock` boolean: `false` read as "not in stock" to some callers and "do not filter" to others. - `in-stock` — published, not archived, in stock. Buyable now. - `out-of-stock` — published, not archived, sold. Its page still resolves. - `all` — the whole CURRENT catalogue. Exactly `in-stock` + `out-of-stock`. - `archived` — DISCONTINUED products, returned only when asked for by name. Never included in `all`: `all` means what the store sells, and a withdrawn piece appearing in a grid is a bug, not completeness. schema: type: string enum: [in-stock, out-of-stock, all, archived] default: in-stock Sort: name: sort in: query required: false description: | `price-asc` / `price-desc` order by the **EFFECTIVE** price — the number in `price.effective` and the number the cart charges. That is `salePrice` when it is greater than zero AND strictly below `regularPrice`, otherwise `regularPrice`. Every sort is tiebroken by `id` **ascending, in both directions**. The tiebreak makes page boundaries total; it is not an ordering you asked for, so `price-desc` does not reverse the ties. schema: type: string enum: [newest, oldest, price-asc, price-desc, title] default: newest MinPrice: name: minPrice in: query required: false description: Minimum effective price in INR (whole rupees). schema: { type: number, minimum: 0, maximum: 100000000 } MaxPrice: name: maxPrice in: query required: false description: Maximum effective price in INR (whole rupees). schema: { type: number, minimum: 0, maximum: 100000000 } MinCarat: name: minCarat in: query required: false description: Minimum gemstone weight in carats. schema: { type: number, minimum: 0, maximum: 10000 } MaxCarat: name: maxCarat in: query required: false description: Maximum gemstone weight in carats. schema: { type: number, minimum: 0, maximum: 10000 } MinRatti: name: minRatti in: query required: false description: Minimum gemstone weight in Ratti (1 Ratti = 0.91 Carats). schema: { type: number, minimum: 0, maximum: 10000 } MaxRatti: name: maxRatti in: query required: false description: Maximum gemstone weight in Ratti (1 Ratti = 0.91 Carats). schema: { type: number, minimum: 0, maximum: 10000 } Origin: name: origin in: query required: false description: Case-insensitive substring match against gemstone origin name or slug (e.g. `Ceylon`, `Zambia`, `Burma`). schema: { type: string, minLength: 1, maxLength: 100 } Treatment: name: treatment in: query required: false description: Case-insensitive substring match against disclosed treatment status (e.g. `Un-Heated`, `Heat treated`). schema: { type: string, minLength: 1, maxLength: 100 } Certification: name: certification in: query required: false description: Case-insensitive substring match against certifying gemological laboratory (e.g. `IGI`, `GTL`, `GII`, `ISO`, `GKRC`). schema: { type: string, minLength: 1, maxLength: 100 } Mukhi: name: mukhi in: query required: false description: Rudraksha mukhi filter (e.g. `5`, `7`, `Gauri Shankar`). schema: { type: string, minLength: 1, maxLength: 20 } Currency: name: currency in: query required: false description: Optional ISO 4217 currency code to include `price.convertedEffective` alongside canonical INR amounts. schema: type: string enum: [INR, USD, GBP, AED, CAD, AUD, JPY, EUR] Slug: name: slug in: path required: true schema: { type: string, minLength: 1 } IfNoneMatch: name: If-None-Match in: header required: false description: Send back the `ETag` from a previous response to get a `304` and no body. schema: { type: string } headers: ETag: description: Strong validator over the response body. Stable while the content is. schema: { type: string } CacheControl: description: | `max-age` is the **private/client** TTL; `s-maxage` is the **shared/CDN** TTL. They are separate numbers on purpose. schema: { type: string } XRequestId: description: Random per-request correlator. Carries no client information. schema: { type: string, format: uuid } RetryAfter: description: Seconds to wait before retrying. schema: { type: integer, minimum: 0 } Allow: description: Methods this endpoint accepts. schema: { type: string } responses: ProductList: description: A page of products. headers: ETag: { $ref: '#/components/headers/ETag' } Cache-Control: { $ref: '#/components/headers/CacheControl' } X-Request-Id: { $ref: '#/components/headers/XRequestId' } content: application/json: schema: { $ref: '#/components/schemas/ProductListEnvelope' } ProductDetail: description: One product. headers: ETag: { $ref: '#/components/headers/ETag' } Cache-Control: { $ref: '#/components/headers/CacheControl' } X-Request-Id: { $ref: '#/components/headers/XRequestId' } content: application/json: schema: { $ref: '#/components/schemas/ProductDetailEnvelope' } CategoryList: description: Every reachable category. headers: ETag: { $ref: '#/components/headers/ETag' } Cache-Control: { $ref: '#/components/headers/CacheControl' } X-Request-Id: { $ref: '#/components/headers/XRequestId' } content: application/json: schema: { $ref: '#/components/schemas/CategoryListEnvelope' } CategoryDetail: description: One category. headers: ETag: { $ref: '#/components/headers/ETag' } Cache-Control: { $ref: '#/components/headers/CacheControl' } X-Request-Id: { $ref: '#/components/headers/XRequestId' } content: application/json: schema: { $ref: '#/components/schemas/CategoryDetailEnvelope' } NotModified: description: The client's `If-None-Match` matched. No body. headers: ETag: { $ref: '#/components/headers/ETag' } Cache-Control: { $ref: '#/components/headers/CacheControl' } InvalidQuery: description: A query parameter failed validation. `details` names each offending field. content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } NotFound: description: No such slug or id. content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } MethodNotAllowed: description: Every v1 endpoint is read-only. headers: Allow: { $ref: '#/components/headers/Allow' } content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } RateLimited: description: Too many requests from this client. headers: Retry-After: { $ref: '#/components/headers/RetryAfter' } content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } Internal: description: Unexpected server fault. content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } ServiceUnavailable: description: Transient dependency failure. Back off and retry. headers: Retry-After: { $ref: '#/components/headers/RetryAfter' } content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } schemas: ApiVersion: type: string const: '1.0.0' RequestId: type: string format: uuid description: Random per-request correlator carrying no client information. Money: type: object additionalProperties: false required: [amountPaise, currency, formatted] properties: amountPaise: { type: integer, minimum: 0 } currency: { type: string, const: INR } formatted: { type: string, examples: ['₹23,305'] } Tax: type: object additionalProperties: false required: [hsnCode, gstRateBasisPoints, gstRatePercent, priceIncludesTax, taxableAmountPaise, taxAmountPaise] properties: hsnCode: type: string description: HSN heading this category is filed under. examples: ['710391'] gstRateBasisPoints: type: integer minimum: 0 description: Authoritative integer rate. 25 = 0.25 %, 300 = 3 %, 500 = 5 %. examples: [25] gstRatePercent: type: number minimum: 0 description: The same rate as a decimal, for display only. examples: [0.25] priceIncludesTax: type: boolean const: true description: Always true. Every price in this catalogue is GST-inclusive. taxableAmountPaise: { type: integer, minimum: 0 } taxAmountPaise: { type: integer, minimum: 0 } Price: type: object additionalProperties: false required: [regular, sale, effective, onSale, tax] properties: regular: { $ref: '#/components/schemas/Money' } sale: oneOf: - $ref: '#/components/schemas/Money' - type: 'null' effective: allOf: [{ $ref: '#/components/schemas/Money' }] description: | The price a customer pays. A sale price counts only when it is greater than zero and strictly below the regular price — the same rule the storefront, cart and checkout apply. onSale: { type: boolean } tax: oneOf: - $ref: '#/components/schemas/Tax' - type: 'null' convertedEffective: type: object additionalProperties: false required: [amount, currency, formatted] description: Optional multi-currency display conversion when `currency` query parameter is non-INR. properties: amount: { type: number, minimum: 0 } currency: type: string enum: [USD, GBP, AED, CAD, AUD, JPY, EUR] formatted: { type: string } AvailabilityBlock: type: object additionalProperties: false required: [state, stockStatus, quantity, isUniquePiece, purchasable, archivedAt] properties: state: type: string enum: [in-stock, out-of-stock, archived] description: | `archived` OUTRANKS stock. A discontinued stone is not "out of stock", which implies it could return — it is withdrawn. Do not offer "notify me when available" against an `archived` product. CLOSED SET, unlike `productType` and `stockStatus`. This vocabulary is defined by the API rather than mirrored from a database enum the business can extend, so a new value here would be a v2 change. Even so, generate Kotlin clients with `enumUnknownDefaultCase=true`: a sealed enum that throws on an unrecognised value turns any future addition into a crash in an app that is already shipped, and the phone in a customer's hand cannot be patched retroactively. stockStatus: type: string description: Open set. Treat an unrecognised value as "some other state". examples: ['IN_STOCK', 'OUT_OF_STOCK'] quantity: { type: integer, minimum: 0 } isUniquePiece: type: boolean description: A one-of-a-kind stone. Quantity is 1 and cannot be reordered. purchasable: type: boolean description: | The single field to gate a buy button on. True only when the product is not archived AND in stock — a client should never have to derive this from `state` and `stockStatus` and risk deriving it differently from the checkout. archivedAt: description: | When the store withdrew this product from sale, or `null`. ISO 8601, UTC. oneOf: - { type: string, format: date-time } - type: 'null' Attributes: type: object additionalProperties: false required: [caratWeight, rattiWeight, dimensionsMm, origin] properties: caratWeight: oneOf: [{ type: number, exclusiveMinimum: 0 }, { type: 'null' }] rattiWeight: oneOf: [{ type: number, exclusiveMinimum: 0 }, { type: 'null' }] description: | Derived server-side as `caratWeight / 0.91`, rounded half-up to two decimal places, so no client re-implements the trade constant. dimensionsMm: oneOf: [{ type: string }, { type: 'null' }] description: | Free-text as measured, e.g. `"12.4 x 9.2 x 6.1 mm"`. Not parsed and not guaranteed to any format. Currently `null` for every product. origin: oneOf: [{ type: string }, { type: 'null' }] ImageRef: type: object additionalProperties: false required: [id, url, alt] properties: id: { type: string } url: { type: string, format: uri, description: Absolute HTTPS URL on this store's media root. } alt: { type: string } Video: type: object additionalProperties: false required: [id, provider, youtubeId, watchUrl, embedUrl, posterUrl] properties: id: { type: string } provider: { type: string, const: youtube } youtubeId: { type: string, pattern: '^[A-Za-z0-9_-]{11}$' } watchUrl: { type: string, format: uri } embedUrl: oneOf: [{ type: string, format: uri }, { type: 'null' }] description: | Privacy-enhanced (`youtube-nocookie.com`), `rel=0`, `playsinline=1`, and deliberately **no `autoplay`**. Add autoplay per call, only after a real user gesture. posterUrl: type: string format: uri description: | One same-origin URL that always resolves. Follow the redirect; do not implement a fallback. SummaryMedia: type: object additionalProperties: false required: [featured, imageCount, certificateCount, hasVideo] properties: featured: oneOf: [{ $ref: '#/components/schemas/ImageRef' }, { type: 'null' }] imageCount: { type: integer, minimum: 0 } certificateCount: { type: integer, minimum: 0 } hasVideo: { type: boolean } DetailMedia: type: object additionalProperties: false required: [featured, images, certificates, video] properties: featured: oneOf: [{ $ref: '#/components/schemas/ImageRef' }, { type: 'null' }] images: type: array description: | Photographs of the stone, in display order. `featured` is `images[0]` by model invariant. Certificates are **never** in this array. items: { $ref: '#/components/schemas/ImageRef' } certificates: type: array description: | Lab report scans, in display order. A certificate is not a photograph of the stone; never render one as a product image. items: { $ref: '#/components/schemas/ImageRef' } video: oneOf: [{ $ref: '#/components/schemas/Video' }, { type: 'null' }] Description: type: object additionalProperties: false required: [short, long, metaTitle, metaDescription] properties: short: oneOf: [{ type: string }, { type: 'null' }] description: Sanitised HTML, same allowlist the product page renders through. long: oneOf: [{ type: string }, { type: 'null' }] description: Sanitised HTML, same allowlist the product page renders through. metaTitle: oneOf: [{ type: string }, { type: 'null' }] metaDescription: oneOf: [{ type: string }, { type: 'null' }] CategoryRef: type: object additionalProperties: false required: [name, slug, url] properties: name: { type: string } slug: { type: string } url: { type: string, format: uri } ProductBase: type: object required: [id, sku, slug, title, url, productType, category, price, availability, attributes, createdAt, updatedAt] properties: id: { type: string, description: This store's own stable UUID. No WordPress post id is ever exposed. } sku: { type: string } slug: { type: string } title: { type: string } url: type: string format: uri description: The absolute canonical page URL, trailing slash included. Open it; never build it. productType: type: string description: Open set. Treat an unrecognised value as "some other kind". examples: ['LOOSE_GEMSTONE', 'RUDRAKSHA', 'GEMSTONE_BRACELET'] category: oneOf: [{ $ref: '#/components/schemas/CategoryRef' }, { type: 'null' }] price: { $ref: '#/components/schemas/Price' } availability: { $ref: '#/components/schemas/AvailabilityBlock' } attributes: { $ref: '#/components/schemas/Attributes' } createdAt: { type: string, format: date-time, description: ISO 8601, UTC, millisecond precision. } updatedAt: { type: string, format: date-time, description: ISO 8601, UTC, millisecond precision. } ProductSummary: allOf: - $ref: '#/components/schemas/ProductBase' - type: object required: [media] properties: media: { $ref: '#/components/schemas/SummaryMedia' } unevaluatedProperties: false ProductDetail: allOf: - $ref: '#/components/schemas/ProductBase' - type: object required: [media, description] properties: media: { $ref: '#/components/schemas/DetailMedia' } description: { $ref: '#/components/schemas/Description' } unevaluatedProperties: false Category: type: object additionalProperties: false required: [id, slug, name, url, productType, parentId, image, tax, updatedAt] properties: id: { type: string } slug: { type: string } name: { type: string } url: { type: string, format: uri } productType: type: string description: Open set. Treat an unrecognised value as "some other kind". parentId: oneOf: [{ type: string }, { type: 'null' }] image: oneOf: - type: object additionalProperties: false required: [url, alt] properties: url: { type: string, format: uri } alt: { type: string } - type: 'null' tax: type: object additionalProperties: false required: [hsnCode, gstRateBasisPoints, gstRatePercent, priceIncludesTax] properties: hsnCode: { type: string } gstRateBasisPoints: { type: integer, minimum: 0 } gstRatePercent: { type: number, minimum: 0 } priceIncludesTax: { type: boolean, const: true } productCount: type: integer minimum: 0 description: >- Number of currently purchasable products represented by the category's primary available-product collection — the same set `GET /products/?category={slug}` returns, so it can be checked against the API rather than taken on trust. Direct members only, so a parent whose children hold the stock reports 0; use `parentId` to build the tree. It does NOT include the optional previously-sold reference shelf that some category pages render beneath that collection, whose products are published and indexable but not purchasable. updatedAt: { type: string, format: date-time } Pagination: type: object additionalProperties: false required: [page, pageSize, total, totalPages, hasNext, hasPrevious] properties: page: { type: integer, minimum: 1 } pageSize: { type: integer, minimum: 1, maximum: 100 } total: { type: integer, minimum: 0 } totalPages: { type: integer, minimum: 1 } hasNext: { type: boolean } hasPrevious: { type: boolean } ProductListEnvelope: type: object additionalProperties: false required: [apiVersion, requestId, pagination, data] properties: apiVersion: { $ref: '#/components/schemas/ApiVersion' } requestId: { $ref: '#/components/schemas/RequestId' } pagination: { $ref: '#/components/schemas/Pagination' } data: type: array items: { $ref: '#/components/schemas/ProductSummary' } ProductDetailEnvelope: type: object additionalProperties: false required: [apiVersion, requestId, data] properties: apiVersion: { $ref: '#/components/schemas/ApiVersion' } requestId: { $ref: '#/components/schemas/RequestId' } data: { $ref: '#/components/schemas/ProductDetail' } CategoryListEnvelope: type: object additionalProperties: false required: [apiVersion, requestId, data] properties: apiVersion: { $ref: '#/components/schemas/ApiVersion' } requestId: { $ref: '#/components/schemas/RequestId' } data: type: array items: { $ref: '#/components/schemas/Category' } CategoryDetailEnvelope: type: object additionalProperties: false required: [apiVersion, requestId, data] properties: apiVersion: { $ref: '#/components/schemas/ApiVersion' } requestId: { $ref: '#/components/schemas/RequestId' } data: { $ref: '#/components/schemas/Category' } ErrorEnvelope: type: object additionalProperties: false required: [apiVersion, requestId, error] properties: apiVersion: { $ref: '#/components/schemas/ApiVersion' } requestId: { $ref: '#/components/schemas/RequestId' } error: type: object additionalProperties: false required: [code, message] properties: code: type: string enum: [INVALID_QUERY, NOT_FOUND, METHOD_NOT_ALLOWED, RATE_LIMITED, INTERNAL, SERVICE_UNAVAILABLE] message: { type: string } details: type: array items: type: object additionalProperties: false required: [field, message] properties: field: { type: string } message: { type: string }