@asstio-dev/storefront 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # `@asstio-dev/storefront`
2
+
3
+ Typed client for the [Asstio Storefront API](../../docs/superpowers/specs/2026-09-10-storefront-design.md).
4
+ The types are **generated** from the OpenAPI document committed at
5
+ `tests/Storefront.Api.Tests/Snapshots/openapi-v1.json`, which is the contract: it only changes in a
6
+ deliberate commit, so a change here always shows up in a diff.
7
+
8
+ ```bash
9
+ npm install @asstio-dev/storefront
10
+ ```
11
+
12
+ ```ts
13
+ import { createStorefrontClient, filterQuery } from "@asstio-dev/storefront";
14
+
15
+ // Your key: process.env.STOREFRONT_SITE_KEY on a server, import.meta.env.VITE_… in a bundler.
16
+ declare const STOREFRONT_SITE_KEY: string;
17
+
18
+ const storefront = createStorefrontClient({
19
+ baseUrl: "https://api.storefront.asstio.com",
20
+ siteKey: STOREFRONT_SITE_KEY, // publishable in the browser, secret only on the server
21
+ market: "se",
22
+ acceptLanguage: "sv-SE",
23
+ });
24
+
25
+ const { data, error } = await storefront.GET("/v1/products", {
26
+ params: {
27
+ query: {
28
+ q: "parfym",
29
+ page: 1,
30
+ pageSize: 24,
31
+ sort: "price_asc",
32
+ filter: filterQuery({ brand: ["dior", "chanel"], price: "100-500", size: undefined }),
33
+ },
34
+ },
35
+ });
36
+
37
+ if (error) throw new Error(`${error.code}: ${error.detail ?? ""}`);
38
+ console.log(data.total, data.items, data.warnings);
39
+ ```
40
+
41
+ ```ts
42
+ // A product with many variants comes back with variants: null and a variantCount; page them:
43
+ const { data: page } = await storefront.GET("/v1/products/{slugOrId}/variants", {
44
+ params: { path: { slugOrId: "manadslins" }, query: { filter: { power: "-1.00" } } },
45
+ });
46
+ // page.options[].values[].available drives cascading dropdowns: a value reached through the cascade is
47
+ // available, but honour the flag for hand-built URLs or restored state, which can select an unavailable one.
48
+ ```
49
+
50
+ Every example in this file is compiled as part of `npm run check` — the source of them is
51
+ [`examples/readme.ts`](examples/readme.ts), which also covers product lookup, ETag revalidation and crawling.
52
+
53
+ ## Headers
54
+
55
+ Every call the client makes carries these; you never set them by hand.
56
+
57
+ | Header | Required | Meaning |
58
+ | --- | --- | --- |
59
+ | `X-Site-Key` | yes | Which shop, and with which visibility. `sfk_pub_…` publishable (safe in a browser bundle), `sfk_prv_…` preview (draft content), `sfk_sec_…` secret — **server-side only**: a secret key sent from a page is rejected with `SECRET_KEY_FROM_BROWSER`. |
60
+ | `X-Market` | no | Market code, e.g. `se`. Omit for the site's default market. An unknown one is `MARKET_UNKNOWN`. |
61
+ | `Accept-Language` | no | BCP-47 tag. Omit to use the market's language. |
62
+
63
+ The client sets all three, so `params.header` is optional at every call site even though the contract marks
64
+ `X-Site-Key` required.
65
+
66
+ ## Errors
67
+
68
+ Every failure is `application/problem+json` with a stable machine-readable `code`; branch on `code`,
69
+ never on `title` or on the status alone.
70
+
71
+ ```jsonc
72
+ {
73
+ "type": "https://docs.asstio.com/storefront/errors#PRODUCT_NOT_FOUND",
74
+ "title": "PRODUCT_NOT_FOUND",
75
+ "status": 404,
76
+ "detail": "No product 'x' on this market.",
77
+ "code": "PRODUCT_NOT_FOUND" // the only stable field; `retryAfterSeconds` is added on RATE_LIMITED
78
+ }
79
+ ```
80
+
81
+ It is `StorefrontProblem` in the schema, so `error.code` and `error.retryAfterSeconds` are typed — no cast:
82
+
83
+ ```ts
84
+ if (error?.code === "RATE_LIMITED") await sleep((error.retryAfterSeconds ?? 10) * 1000);
85
+ ```
86
+
87
+ Every operation documents `400`, `401`, `403`, `404`, `429` and `503`; the entity 404s (`PRODUCT_NOT_FOUND`,
88
+ `COLLECTION_NOT_FOUND`) are called out on the operations that raise them.
89
+
90
+ Codes you will meet: `SITE_KEY_MISSING`, `SITE_KEY_INVALID`, `SITE_INACTIVE`, `SECRET_KEY_FROM_BROWSER`,
91
+ `SITE_REGISTRY_WARMING`, `MARKET_UNKNOWN`, `RATE_LIMITED` (with `retryAfterSeconds`, plus a `Retry-After`
92
+ header), `PRODUCT_NOT_FOUND`, `COLLECTION_NOT_FOUND`, `INVALID_PAGE`, `INVALID_SINCE`, `INVALID_TYPE`,
93
+ `INDEX_UNAVAILABLE` (retry — a preview index is being rebuilt), `NOT_FOUND`.
94
+
95
+ `SITE_REGISTRY_WARMING` and `INDEX_UNAVAILABLE` are retries, not bugs in your call.
96
+
97
+ ## Warnings
98
+
99
+ A request that was *almost* right still answers 200 and says so in `warnings[]`, so a listing page never
100
+ goes blank because of a bad query string:
101
+
102
+ | `code` | What happened |
103
+ | --- | --- |
104
+ | `SORT_UNKNOWN` | The `sort` value is not one this site offers; the default sort was used. |
105
+ | `COLLECTION_UNKNOWN` | The named collection does not exist; it was not applied. |
106
+ | `FILTER_IGNORED` | A `filter[…]` key is not facetable here; that filter was dropped. |
107
+ | `QUERY_TRUNCATED` | `q` was longer than 200 characters; the results are for the shortened term. |
108
+
109
+ ## Query parameters
110
+
111
+ `q`, `page`, `pageSize`, `sort` and `filter` are all in the contract, so they are typed and autocompleted.
112
+ `filter` is a `deepObject`: `{ brand: "dior,chanel" }` goes out as `filter[brand]=dior,chanel`, which is the
113
+ form the API reads. `filterQuery()` is a thin helper that joins multi-select values and drops `undefined` ones.
114
+
115
+ `/v1/sitemap` takes `type` (`product` | `collection`), `since` (ISO-8601, inclusive), `page` and `pageSize`
116
+ (max 1000, default 500).
117
+
118
+ ## Caching
119
+
120
+ Read responses carry an `ETag` and `Vary: X-Site-Key, X-Market, Accept-Language` — the answer depends on
121
+ all three, so a shared cache must key on all three. Send the ETag back as `If-None-Match` and a matching
122
+ request answers `304 Not Modified` with no body:
123
+
124
+ ```ts
125
+ const first = await storefront.GET("/v1/site", {});
126
+ const etag = first.response.headers.get("ETag")!;
127
+ const again = await storefront.GET("/v1/site", { headers: { "If-None-Match": etag } });
128
+ // again.response.status === 304
129
+ ```
130
+
131
+ Errors are never stored (`Cache-Control: no-store`) and never carry an ETag.
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ npm install
137
+ npm run build # regenerates src/schema.d.ts from the snapshot, compiles, then typechecks the examples
138
+ npm run check # fails if the committed schema.d.ts is stale, modified or untracked, or an example broke
139
+ ```
140
+
141
+ `src/schema.d.ts` is generated **and committed**: it is reviewed in pull requests exactly like the
142
+ OpenAPI snapshot it comes from. After changing the API, regenerate the snapshot
143
+ (`UPDATE_SNAPSHOTS=1 dotnet test --project tests/Storefront.Api.Tests`), then `npm run build` here.
@@ -0,0 +1,46 @@
1
+ import { type Client } from "openapi-fetch";
2
+ import type { paths } from "./schema";
3
+ export type { paths, components } from "./schema";
4
+ /**
5
+ * The API requires X-Site-Key on every call, so the generated types demand `params.header` at every call site —
6
+ * but this client sets all three headers itself. These map the generated paths to the same operations with the
7
+ * header block optional: the contract stays honest, and the caller does not repeat the key on every request.
8
+ */
9
+ type HeadersOptional<Params> = Omit<Params, "header"> & (Params extends {
10
+ header: infer H;
11
+ } ? {
12
+ header?: Partial<H>;
13
+ } : {
14
+ header?: never;
15
+ });
16
+ export type StorefrontPaths = {
17
+ [Path in keyof paths]: {
18
+ [Method in keyof paths[Path]]: paths[Path][Method] extends {
19
+ parameters: infer Params;
20
+ } ? Omit<paths[Path][Method], "parameters"> & {
21
+ parameters: HeadersOptional<Params>;
22
+ } : paths[Path][Method];
23
+ };
24
+ };
25
+ export interface StorefrontClientOptions {
26
+ /** e.g. https://api.storefront.asstio.com */
27
+ baseUrl: string;
28
+ /** publishable (browser), preview (draft mode) or secret (server only) site key */
29
+ siteKey: string;
30
+ /** market code, e.g. "se"; omit for the site's default market */
31
+ market?: string;
32
+ /** BCP-47 tag; omit to use the market's language */
33
+ acceptLanguage?: string;
34
+ fetch?: typeof fetch;
35
+ }
36
+ /** Thin typed client: every call carries X-Site-Key / X-Market / Accept-Language. Works in browsers and on the server. */
37
+ export declare function createStorefrontClient(opts: StorefrontClientOptions): Client<StorefrontPaths>;
38
+ /** The `filter` query parameter of a listing call: one facet key to one value, as the generated types spell it. */
39
+ export type FilterSelection = NonNullable<NonNullable<paths["/v1/products"]["get"]["parameters"]["query"]>["filter"]>;
40
+ /**
41
+ * Collapse a facet selection into the typed `filter` object: several values for one facet are comma-separated,
42
+ * and `undefined` facets drop out — so an unchecked checkbox needs no special case at the call site.
43
+ * `{ brand: ["dior", "chanel"], price: "100-500", size: undefined }` → `{ brand: "dior,chanel", price: "100-500" }`.
44
+ * The client sends it as `filter[brand]=dior,chanel` (deepObject), which is what the API reads.
45
+ */
46
+ export declare function filterQuery(selection: Record<string, string | string[] | undefined>): FilterSelection;
package/dist/index.js ADDED
@@ -0,0 +1,34 @@
1
+ import createClient from "openapi-fetch";
2
+ /** Thin typed client: every call carries X-Site-Key / X-Market / Accept-Language. Works in browsers and on the server. */
3
+ export function createStorefrontClient(opts) {
4
+ const client = createClient({ baseUrl: opts.baseUrl.replace(/\/$/, ""), fetch: opts.fetch });
5
+ const headers = {
6
+ onRequest({ request }) {
7
+ request.headers.set("X-Site-Key", opts.siteKey);
8
+ if (opts.market)
9
+ request.headers.set("X-Market", opts.market);
10
+ if (opts.acceptLanguage)
11
+ request.headers.set("Accept-Language", opts.acceptLanguage);
12
+ return request;
13
+ },
14
+ };
15
+ client.use(headers);
16
+ // The cast is the whole point of the type above: at runtime the middleware has already supplied the headers the
17
+ // generated types insist on.
18
+ return client;
19
+ }
20
+ /**
21
+ * Collapse a facet selection into the typed `filter` object: several values for one facet are comma-separated,
22
+ * and `undefined` facets drop out — so an unchecked checkbox needs no special case at the call site.
23
+ * `{ brand: ["dior", "chanel"], price: "100-500", size: undefined }` → `{ brand: "dior,chanel", price: "100-500" }`.
24
+ * The client sends it as `filter[brand]=dior,chanel` (deepObject), which is what the API reads.
25
+ */
26
+ export function filterQuery(selection) {
27
+ const out = {};
28
+ for (const [key, value] of Object.entries(selection)) {
29
+ if (value === undefined)
30
+ continue;
31
+ out[key] = Array.isArray(value) ? value.join(",") : value;
32
+ }
33
+ return out;
34
+ }