@veluai/velu 0.1.14 → 0.1.16

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.
@@ -0,0 +1,92 @@
1
+ // Build and send a "Try It" request from the playground field values.
2
+ //
3
+ // By default the request goes through the dev server's proxy
4
+ // (/@velu-api-proxy) so it isn't blocked by CORS — the proxy forwards it
5
+ // server-side and returns { status, statusText, headers, body }. Set the
6
+ // project's api.playground.proxy to false to send straight from the browser.
7
+
8
+ const PROXY_URL = '/@velu-api-proxy';
9
+ const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
10
+
11
+ // Compose the concrete request (url + headers + body) from the operation and
12
+ // the user-entered values: { auth, path, query, header } maps + bodyText.
13
+ function buildRequest({ operation, server, values, bodyText }) {
14
+ const v = values || {};
15
+ let path = operation.path.replace(/\{([^}]+)\}/g, (m, name) => {
16
+ const val = v.path?.[name];
17
+ return val ? encodeURIComponent(val) : m; // leave {name} if unfilled
18
+ });
19
+
20
+ const query = Object.entries(v.query || {}).filter(([, val]) => val !== '' && val != null);
21
+ const qs = query.map(([k, val]) => `${encodeURIComponent(k)}=${encodeURIComponent(val)}`).join('&');
22
+ const base = (server || '').replace(/\/+$/, '');
23
+ const url = `${base}${path}${qs ? `?${qs}` : ''}`;
24
+
25
+ const headers = {};
26
+ for (const a of operation.auth || []) {
27
+ const val = v.auth?.[a.name];
28
+ if (val) headers[a.name] = a.prefix ? `${a.prefix} ${val}` : val;
29
+ }
30
+ for (const [k, val] of Object.entries(v.header || {})) {
31
+ if (val !== '' && val != null) headers[k] = val;
32
+ }
33
+
34
+ let body = null;
35
+ if (operation.body && BODY_METHODS.has(operation.method)) {
36
+ body = bodyText || '';
37
+ if (body && !headers['Content-Type']) headers['Content-Type'] = operation.body.contentType || 'application/json';
38
+ }
39
+
40
+ return { method: operation.method, url, headers, body };
41
+ }
42
+
43
+ async function readBody(res) {
44
+ const text = await res.text();
45
+ const ct = res.headers.get('content-type') || '';
46
+ if (/json/i.test(ct)) {
47
+ try {
48
+ return JSON.parse(text);
49
+ } catch {
50
+ /* fall through to raw text */
51
+ }
52
+ }
53
+ return text;
54
+ }
55
+
56
+ export async function sendApiRequest({ operation, server, proxy = true, values, bodyText }) {
57
+ const req = buildRequest({ operation, server, values, bodyText });
58
+
59
+ if (proxy) {
60
+ const res = await fetch(PROXY_URL, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify(req),
64
+ });
65
+ // The proxy returns the upstream result as JSON.
66
+ const payload = await res.json().catch(() => ({}));
67
+ if (payload && payload.error) return { error: payload.error };
68
+ return {
69
+ status: payload.status,
70
+ statusText: payload.statusText || '',
71
+ headers: payload.headers || {},
72
+ body: payload.body,
73
+ };
74
+ }
75
+
76
+ // Direct browser fetch (subject to CORS).
77
+ const res = await fetch(req.url, {
78
+ method: req.method,
79
+ headers: req.headers,
80
+ body: req.body || undefined,
81
+ });
82
+ const headers = {};
83
+ res.headers.forEach((val, key) => {
84
+ headers[key] = val;
85
+ });
86
+ return {
87
+ status: res.status,
88
+ statusText: res.statusText,
89
+ headers,
90
+ body: await readBody(res),
91
+ };
92
+ }
@@ -30,6 +30,7 @@
30
30
  @import './components/steps.css';
31
31
  @import './components/tree.css';
32
32
  @import './components/api.css';
33
+ @import './components/api-page.css';
33
34
  @import './components/ask-bar.css';
34
35
  @import './components/chatbot.css';
35
36
  @import './components/page-feedback.css';
@@ -40,6 +40,157 @@
40
40
  "type": "string",
41
41
  "description": "Path to the favicon, relative to the project root."
42
42
  },
43
+ "api": {
44
+ "type": "object",
45
+ "additionalProperties": false,
46
+ "description": "API reference behaviour for OpenAPI-generated pages.",
47
+ "properties": {
48
+ "server": {
49
+ "type": "string",
50
+ "description": "Base URL override for the playground + code samples (otherwise the spec's first server is used)."
51
+ },
52
+ "playground": {
53
+ "type": "object",
54
+ "additionalProperties": false,
55
+ "properties": {
56
+ "display": {
57
+ "type": "string",
58
+ "enum": ["interactive", "simple", "none"],
59
+ "description": "Playground mode. \"none\" disables the proxy / live send."
60
+ },
61
+ "proxy": {
62
+ "type": "boolean",
63
+ "description": "Send Try-It requests through the dev proxy to avoid CORS (default true)."
64
+ }
65
+ }
66
+ },
67
+ "examples": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "properties": {
71
+ "languages": {
72
+ "type": "array",
73
+ "items": { "type": "string", "enum": ["curl", "javascript", "python", "ruby"] },
74
+ "description": "Which request-snippet languages to generate."
75
+ }
76
+ }
77
+ }
78
+ }
79
+ },
80
+ "logo": {
81
+ "description": "Site logo shown in the header, replacing the name wordmark. A single path used in both themes, or per-theme light/dark images with an optional click-through href. Paths are relative to the project root.",
82
+ "oneOf": [
83
+ {
84
+ "type": "string",
85
+ "minLength": 1,
86
+ "description": "Logo path used in both light and dark mode."
87
+ },
88
+ {
89
+ "type": "object",
90
+ "additionalProperties": false,
91
+ "required": ["light", "dark"],
92
+ "properties": {
93
+ "light": {
94
+ "type": "string",
95
+ "minLength": 1,
96
+ "description": "Logo shown in light mode."
97
+ },
98
+ "dark": {
99
+ "type": "string",
100
+ "minLength": 1,
101
+ "description": "Logo shown in dark mode."
102
+ },
103
+ "href": {
104
+ "type": "string",
105
+ "description": "Where clicking the logo links. Defaults to the site root."
106
+ }
107
+ }
108
+ }
109
+ ]
110
+ },
111
+ "navbar": {
112
+ "type": "object",
113
+ "additionalProperties": false,
114
+ "description": "The header's top-right area: secondary links plus a primary CTA button.",
115
+ "properties": {
116
+ "links": {
117
+ "type": "array",
118
+ "description": "Secondary header links (to the left of the primary CTA).",
119
+ "items": {
120
+ "type": "object",
121
+ "additionalProperties": false,
122
+ "required": ["href"],
123
+ "properties": {
124
+ "label": { "type": "string", "description": "Link text." },
125
+ "href": { "type": "string", "description": "Link URL." },
126
+ "icon": { "type": "string", "description": "lucide icon id." }
127
+ }
128
+ }
129
+ },
130
+ "primary": {
131
+ "description": "The prominent CTA button at the far right.",
132
+ "oneOf": [
133
+ {
134
+ "type": "object",
135
+ "additionalProperties": false,
136
+ "required": ["type", "label", "href"],
137
+ "properties": {
138
+ "type": { "const": "button" },
139
+ "label": { "type": "string", "description": "Button text." },
140
+ "href": { "type": "string", "description": "Button URL." }
141
+ }
142
+ },
143
+ {
144
+ "type": "object",
145
+ "additionalProperties": false,
146
+ "required": ["type", "href"],
147
+ "properties": {
148
+ "type": { "const": "github" },
149
+ "href": { "type": "string", "description": "GitHub repository URL." },
150
+ "label": { "type": "string", "description": "Button text (default \"GitHub\")." }
151
+ }
152
+ }
153
+ ]
154
+ }
155
+ }
156
+ },
157
+ "footer": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "description": "Site footer. With `links`, a full footer section renders; with only `socials`, the icons sit next to the \"Powered by\" line instead.",
161
+ "properties": {
162
+ "socials": {
163
+ "type": "object",
164
+ "description": "Social links as platform → URL. Recognized icons: github, x (or twitter), youtube, linkedin.",
165
+ "additionalProperties": { "type": "string" }
166
+ },
167
+ "links": {
168
+ "type": "array",
169
+ "description": "Footer link columns (max 4).",
170
+ "maxItems": 4,
171
+ "items": {
172
+ "type": "object",
173
+ "additionalProperties": false,
174
+ "required": ["header", "items"],
175
+ "properties": {
176
+ "header": { "type": "string", "description": "Column heading." },
177
+ "items": {
178
+ "type": "array",
179
+ "items": {
180
+ "type": "object",
181
+ "additionalProperties": false,
182
+ "required": ["label", "href"],
183
+ "properties": {
184
+ "label": { "type": "string", "description": "Link text." },
185
+ "href": { "type": "string", "description": "Link URL." }
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ },
43
194
  "font": {
44
195
  "type": "object",
45
196
  "description": "Typography. Maps onto the --font-sans token in base.css.",
@@ -91,6 +242,7 @@
91
242
  "icon": { "type": "string", "description": "lucide icon id." },
92
243
  "expanded": { "type": "boolean", "description": "Start expanded." },
93
244
  "root": { "type": "string", "description": "Landing page path for the group." },
245
+ "openapi": { "$ref": "#/$defs/openapiRef" },
94
246
  "pages": { "$ref": "#/$defs/pages" }
95
247
  }
96
248
  },
@@ -102,11 +254,19 @@
102
254
  "tab": { "type": "string", "description": "Tab label (top nav)." },
103
255
  "icon": { "type": "string" },
104
256
  "href": { "type": "string", "description": "External/override link instead of in-site pages." },
257
+ "openapi": { "$ref": "#/$defs/openapiRef" },
105
258
  "anchors": { "type": "array", "items": { "$ref": "#/$defs/anchor" } },
106
259
  "groups": { "type": "array", "items": { "$ref": "#/$defs/group" } },
107
260
  "pages": { "$ref": "#/$defs/pages" }
108
261
  }
109
262
  },
263
+ "openapiRef": {
264
+ "description": "Path or URL to an OpenAPI spec (JSON/YAML), or an array of them. Auto-generates a page per operation, grouped by tag.",
265
+ "oneOf": [
266
+ { "type": "string" },
267
+ { "type": "array", "items": { "type": "string" } }
268
+ ]
269
+ },
110
270
  "anchor": {
111
271
  "type": "object",
112
272
  "additionalProperties": false,
package/src/navigation.js CHANGED
@@ -186,9 +186,13 @@ function normalizeGroup(g) {
186
186
  };
187
187
  }
188
188
 
189
- /** A `pages[]` entry is either a string (page path) or a nested group. */
189
+ /** A `pages[]` entry is a string (page path), a generated OpenAPI page
190
+ * ({ apiPage, label, method }), or a nested group. */
190
191
  function normalizePageEntry(entry) {
191
192
  if (typeof entry === 'string') return { kind: 'page', pagePath: entry };
193
+ if (entry && entry.apiPage) {
194
+ return { kind: 'page', pagePath: entry.apiPage, label: entry.label, method: entry.method, api: true };
195
+ }
192
196
  return normalizeGroup(entry);
193
197
  }
194
198
 
@@ -430,5 +434,10 @@ function itemsFor(nodes, pagesMap, ctx) {
430
434
  function itemFor(pageNode, pagesMap, ctx) {
431
435
  const href = urlInCtx(pageNode.pagePath, ctx);
432
436
  const fm = pagesMap[href]?.frontmatter;
433
- return { label: fm?.title ?? titleCase(lastSeg(href)), href };
437
+ // Generated API pages carry their own label + HTTP method (for the sidebar
438
+ // method badge); normal pages source their label from frontmatter.
439
+ const label = pageNode.label ?? fm?.title ?? titleCase(lastSeg(href));
440
+ const item = { label, href };
441
+ if (pageNode.method) item.method = pageNode.method;
442
+ return item;
434
443
  }
@@ -4,7 +4,7 @@ import { MDXProvider } from '@mdx-js/react';
4
4
  // The project's pages + navigation, generated from velu.json by
5
5
  // vite-plugin-velu-site (see src/vite-plugin-velu-site.js). `pages` is
6
6
  // url → { Component, frontmatter, toc } (or { missing:true }).
7
- import { pages, navigation } from 'virtual:velu-site';
7
+ import { pages, navigation, site } from 'virtual:velu-site';
8
8
  import { resolve, normalizeUrl } from '../navigation.js';
9
9
  import {
10
10
  Stack,
@@ -38,6 +38,8 @@ import {
38
38
  defaultMdxComponents,
39
39
  resolveIcon,
40
40
  Search,
41
+ Logo,
42
+ SocialLinks,
41
43
  Tree,
42
44
  Folder,
43
45
  File,
@@ -45,6 +47,7 @@ import {
45
47
  ApiClient,
46
48
  ApiField,
47
49
  ApiSidebar,
50
+ ApiSamples,
48
51
  VeluMark,
49
52
  } from 'velu-ui';
50
53
  import { X, ChevronDown, ChevronUp } from 'lucide-react';
@@ -944,6 +947,35 @@ function DocsPage() {
944
947
  return () => scroller.removeEventListener('scroll', update);
945
948
  }, [pathname, chatOpen]);
946
949
 
950
+ // Footer config (from velu.json via virtual:velu-site). Link columns →
951
+ // full footer section; socials with no columns → inline next to PoweredBy.
952
+ const footerSocials = site.footer?.socials ?? [];
953
+ const footerHasLinks = (site.footer?.links?.length ?? 0) > 0;
954
+
955
+ // Header top-right actions, built from velu.json's `navbar`: secondary
956
+ // `links` (plain text links) then the `primary` CTA (a filled button, or an
957
+ // outlined GitHub button). Empty when unconfigured — no placeholder buttons.
958
+ const navActions = [];
959
+ for (const l of site.navbar?.links ?? []) {
960
+ navActions.push({
961
+ label: l.label,
962
+ href: l.href,
963
+ icon: l.icon || undefined,
964
+ kind: 'link',
965
+ external: l.external,
966
+ });
967
+ }
968
+ if (site.navbar?.primary) {
969
+ const p = site.navbar.primary;
970
+ navActions.push({
971
+ label: p.label,
972
+ href: p.href,
973
+ kind: p.kind === 'github' ? 'outlined' : 'primary',
974
+ icon: p.kind === 'github' ? 'github' : undefined,
975
+ external: p.external,
976
+ });
977
+ }
978
+
947
979
  const footerRef = React.useRef(null);
948
980
  const [footerOverlap, setFooterOverlap] = React.useState(0);
949
981
  React.useEffect(() => {
@@ -1026,6 +1058,7 @@ function DocsPage() {
1026
1058
  data-chat-open={chatOpen ? 'true' : 'false'}
1027
1059
  data-sidebar-open={sidebarOpen ? 'true' : 'false'}
1028
1060
  data-drawer-open={drawerOpen ? 'true' : 'false'}
1061
+ data-api={entry?.api ? 'true' : 'false'}
1029
1062
  >
1030
1063
  {/* Scrim — visible at mobile while the drawer OR the chatbot
1031
1064
  sheet is open. Sits between the article (z-0) and the
@@ -1043,7 +1076,11 @@ function DocsPage() {
1043
1076
  tabs row. Configurable: pass any number of actions / tabs. */}
1044
1077
  <PageHeader
1045
1078
  linkComponent={RouterLink}
1046
- brand={{ label: 'Velu', href: '/' }}
1079
+ brand={{
1080
+ label: site.name,
1081
+ href: site.logo?.href || '/',
1082
+ logo: site.logo,
1083
+ }}
1047
1084
  brandTrailing={
1048
1085
  versionSwitcher && (
1049
1086
  <span className="velu-hide-on-mobile">{versionSwitcher}</span>
@@ -1069,13 +1106,7 @@ function DocsPage() {
1069
1106
  )}
1070
1107
  </Cluster>
1071
1108
  }
1072
- actions={[
1073
- {
1074
- label: 'Book Demo',
1075
- kind: 'primary',
1076
- href: '#',
1077
- },
1078
- ]}
1109
+ actions={navActions}
1079
1110
  trailing={<ThemeToggle />}
1080
1111
  onMenuClick={() => setDrawerOpen((v) => !v)}
1081
1112
  breadcrumb={nav?.breadcrumb ?? []}
@@ -1111,8 +1142,14 @@ function DocsPage() {
1111
1142
  href="/"
1112
1143
  className="velu-docs-layout__drawer-brand"
1113
1144
  >
1114
- <VeluMark />
1115
- <span className="velu-header__wordmark">Velu</span>
1145
+ {site.logo ? (
1146
+ <Logo logo={site.logo} alt={site.name} />
1147
+ ) : (
1148
+ <>
1149
+ <VeluMark />
1150
+ <span className="velu-header__wordmark">{site.name}</span>
1151
+ </>
1152
+ )}
1116
1153
  </RouterLink>
1117
1154
  <ThemeToggle />
1118
1155
  <button
@@ -1228,11 +1265,27 @@ function DocsPage() {
1228
1265
  scrollPaddingBlockEnd: 'var(--s1)',
1229
1266
  }}
1230
1267
  >
1231
- <Sidebar
1232
- sections={nav?.sidebarSections ?? []}
1233
- activeHref={pathname}
1234
- linkComponent={RouterLink}
1235
- />
1268
+ {entry?.api ? (
1269
+ <ApiSidebar
1270
+ sections={(nav?.sidebarSections ?? []).map((s) => ({
1271
+ title: s.title,
1272
+ icon: s.icon,
1273
+ endpoints: (s.items ?? []).map((it) => ({
1274
+ method: it.method,
1275
+ label: it.label,
1276
+ href: it.href,
1277
+ })),
1278
+ }))}
1279
+ activeHref={pathname}
1280
+ linkComponent={RouterLink}
1281
+ />
1282
+ ) : (
1283
+ <Sidebar
1284
+ sections={nav?.sidebarSections ?? []}
1285
+ activeHref={pathname}
1286
+ linkComponent={RouterLink}
1287
+ />
1288
+ )}
1236
1289
  </div>
1237
1290
  <button
1238
1291
  type="button"
@@ -1282,7 +1335,16 @@ function DocsPage() {
1282
1335
  scrollPaddingBlockEnd: `calc(${footerOverlap}px + 2rem)`,
1283
1336
  }}
1284
1337
  >
1285
- <Toc items={toc} activeId={activeId} onSelect={scrollTo} />
1338
+ {/* API reference pages put their code samples in the right rail
1339
+ (where the TOC sits for normal pages). */}
1340
+ {entry?.api && entry.operation ? (
1341
+ <ApiSamples
1342
+ samples={entry.samples}
1343
+ responses={entry.operation.responses}
1344
+ />
1345
+ ) : (
1346
+ <Toc items={toc} activeId={activeId} onSelect={scrollTo} />
1347
+ )}
1286
1348
  </aside>
1287
1349
  )}
1288
1350
 
@@ -1417,59 +1479,40 @@ function DocsPage() {
1417
1479
  }}
1418
1480
  />
1419
1481
  )}
1420
- {/* "Powered by Velu" attribution — bottom-right of the
1421
- article column, just under the AskBar. Muted so it reads
1422
- as a footer-of-content note, not a brand statement. */}
1423
- <PoweredBy />
1482
+ {/* Content foot — "Powered by Velu" at the bottom-right of the
1483
+ article column. When the footer has no link columns, the
1484
+ configured social icons sit on the LEFT of this same row (no
1485
+ full footer section); with link columns they live in the full
1486
+ footer below instead. */}
1487
+ {footerHasLinks ? (
1488
+ <PoweredBy />
1489
+ ) : (
1490
+ <div className="velu-content-foot">
1491
+ <SocialLinks socials={footerSocials} />
1492
+ <PoweredBy />
1493
+ </div>
1494
+ )}
1424
1495
  </div>
1425
1496
  </main>
1426
1497
  </div>
1427
1498
 
1428
- {/* Site footer — spans full width below the article. Its raised
1429
- z-index ensures it visually eclipses the bottoms of the fixed
1430
- left sidebar and right TOC as the page scrolls into it. The
1431
- ref is watched so the asides can pad their bottom by the same
1432
- overlap amount, keeping every item scrollable into view. */}
1433
- <div
1434
- ref={footerRef}
1435
- data-velu-footer
1436
- style={{ position: 'relative', zIndex: 20 }}
1437
- >
1438
- <PageFooter
1439
- brand={{ href: '#' }}
1440
- columns={[
1441
- {
1442
- title: 'Resources',
1443
- items: [
1444
- { label: 'Showcase', href: '#' },
1445
- { label: 'Enterprise', href: '#' },
1446
- { label: 'Status', href: '#' },
1447
- ],
1448
- },
1449
- {
1450
- title: 'Company',
1451
- items: [
1452
- { label: 'Careers', href: '#' },
1453
- { label: 'Blog', href: '#' },
1454
- { label: 'Community', href: '#' },
1455
- ],
1456
- },
1457
- {
1458
- title: 'Policies',
1459
- items: [
1460
- { label: 'Subprocessors', href: '#' },
1461
- { label: 'Terms of Service', href: '#' },
1462
- ],
1463
- },
1464
- ]}
1465
- socials={[
1466
- { kind: 'github', href: 'https://github.com/aravindc26/velu-cli' },
1467
- { kind: 'x', href: 'https://x.com/' },
1468
- { kind: 'youtube', href: 'https://youtube.com/' },
1469
- { kind: 'linkedin', href: 'https://linkedin.com/' },
1470
- ]}
1471
- />
1472
- </div>
1499
+ {/* Full site footer — only when the config provides link columns. Spans
1500
+ full width below the article; its raised z-index eclipses the bottoms
1501
+ of the fixed sidebar and TOC as the page scrolls into it, and the ref
1502
+ is watched so the asides can pad their bottom by the overlap. */}
1503
+ {footerHasLinks && (
1504
+ <div
1505
+ ref={footerRef}
1506
+ data-velu-footer
1507
+ style={{ position: 'relative', zIndex: 20 }}
1508
+ >
1509
+ <PageFooter
1510
+ brand={{ href: site.logo?.href || '/' }}
1511
+ columns={site.footer.links}
1512
+ socials={footerSocials}
1513
+ />
1514
+ </div>
1515
+ )}
1473
1516
 
1474
1517
  {/* Ask-AI chatbot — slides in from the inline-end edge. */}
1475
1518
  <Chatbot
@@ -1,28 +1,43 @@
1
1
  ---
2
2
  title: Introduction
3
- description: How to read this API reference.
3
+ description: How this API reference is generated.
4
4
  ---
5
5
 
6
- This section documents the example API. Endpoints are grouped in the
7
- sidebar; each page shows the request, parameters, and a sample
8
- response.
6
+ The endpoints in this tab are generated automatically from an OpenAPI
7
+ spec (`openapi.json`). Each operation becomes its own page — with
8
+ parameters, request body, and responses — and an interactive **Try It**
9
+ playground you can use to send real requests.
9
10
 
10
11
  <Callout type="note">
11
12
  This is a second tab ("API Reference"). Tabs let you keep guides and
12
- reference docs in separate top-level sections of the same site.
13
+ reference docs in separate top-level sections of the same site. Point
14
+ the tab's `openapi` field at your own spec to replace this example.
13
15
  </Callout>
14
16
 
15
- ## Base URL
17
+ ## How it works
16
18
 
17
- ```
18
- https://api.example.com/v1
19
+ Add an `openapi` field to a tab (or group) in `velu.json`:
20
+
21
+ ```json
22
+ {
23
+ "tab": "API Reference",
24
+ "openapi": "/openapi.json"
25
+ }
19
26
  ```
20
27
 
21
- ## Authentication
28
+ Velu reads the spec, groups the operations by their tag, and renders a
29
+ page per endpoint. No hand-written endpoint pages required.
22
30
 
23
- Send your key as a bearer token:
31
+ ## The example API
24
32
 
25
- ```bash
26
- curl https://api.example.com/v1/items \
27
- -H "Authorization: Bearer YOUR_TOKEN"
28
- ```
33
+ This starter points at a live demo API — a small **product catalog**
34
+ backed by [dummyjson.com](https://dummyjson.com). Open any endpoint in
35
+ the sidebar, hit **Try It**, and send a real request:
36
+
37
+ - **Get a product** — try `id` = `1`
38
+ - **Search products** — try `q` = `phone`
39
+ - **Add a product** — the body is pre-filled; Send returns a created product
40
+
41
+ Requests are routed through the dev server's proxy so they aren't blocked
42
+ by CORS. Set `api.playground.proxy` to `false` to send straight from the
43
+ browser.