@owlmeans/api 0.1.18-rc.40 → 0.1.18-rc.42

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 CHANGED
@@ -12,7 +12,7 @@ HTTP API client service for OwlMeans client contexts — carries entrypoint call
12
12
  ## Installation
13
13
 
14
14
  ```bash
15
- bun add @owlmeans/api@^0.1.18-rc.40
15
+ bun add @owlmeans/api@^0.1.18-rc.42
16
16
  ```
17
17
 
18
18
  ## Usage
@@ -41,6 +41,14 @@ Creates an HTTP client service. `alias` defaults to `DEFAULT_ALIAS` (`'web-clien
41
41
 
42
42
  Registers the API client in the context.
43
43
 
44
+ ### Request bodies
45
+
46
+ Objects and arrays go as JSON. A string, number or boolean body on a `POST` without a
47
+ `content-type`, or under a JSON `content-type` on any method, is `JSON.stringify`'d, so the server
48
+ parses back the same value (`'abc'` travels as `"abc"`). A string that already is JSON text is sent
49
+ as it is — except under a `type: 'string'` body contract, where only a JSON string literal counts
50
+ as serialized and `'123'` arrives as the string `'123'`.
51
+
44
52
  ### Error Classes
45
53
 
46
54
  - `ApiError` — base API error
@@ -65,7 +73,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
65
73
  your project's skill store (`.agents/skills/`):
66
74
 
67
75
  ```sh
68
- npx @owlmeans/agent-skills@^0.1.18-rc.41
76
+ npx @owlmeans/agent-skills@^0.1.18-rc.44
69
77
  ```
70
78
 
71
79
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/api",
4
- "version": "0.1.18-rc.40",
5
- "generatedAt": "2026-09-28T02:06:37.895Z",
4
+ "version": "0.1.18-rc.42",
5
+ "generatedAt": "2026-09-30T09:41:02.842Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/api
9
9
 
10
10
  **Layer:** Core
11
- **Install:** `"@owlmeans/api": "^0.1.18-rc.40"` in `dependencies`
11
+ **Install:** `"@owlmeans/api": "^0.1.18-rc.42"` in `dependencies`
12
12
 
13
13
  ## Key Exports
14
14
 
@@ -51,8 +51,27 @@ These live on the request the caller passes and are forwarded to the transport:
51
51
  peer hangs forever unless a caller bounds it.
52
52
  - `signal` — an `AbortSignal`, which aborts the request in flight.
53
53
  - `headers` — a `content-type` of `application/x-www-form-urlencoded` makes the body serialize
54
- through `qs` instead of JSON. A string body on a `POST` with no `content-type` gets
55
- `application/json` and is sent verbatim rather than re-quoted.
54
+ through `qs` instead of JSON; any other `content-type` the caller sets is kept.
55
+
56
+ ## Request bodies
57
+
58
+ The body travels as JSON the server parses back to the value the caller passed:
59
+
60
+ | Body | On the wire |
61
+ |---|---|
62
+ | Object or array | JSON, serialized by axios — as always |
63
+ | String, number or boolean on a `POST` with no `content-type` | `content-type: application/json`, `JSON.stringify`'d (`'abc'` → `"abc"`, `42`, `true`) |
64
+ | String, number or boolean under a JSON `content-type` (`application/json`, `*+json`), any method | `JSON.stringify`'d |
65
+ | A string that already IS JSON text (`'{"a":1}'`, `'"abc"'`) | Sent as it is — a caller that serialized it itself keeps working |
66
+ | Any scalar under another `content-type` (`text/plain`), or with none on a non-`POST` | Untouched |
67
+
68
+ Under a body schema of `type: 'string'` (the protocol's contract, read from the entrypoint's
69
+ `filter.body`) a string is always a value: `'123'`, `'true'` or `'{…}'` arrive as those strings,
70
+ and only a JSON string literal counts as pre-serialized. Without a string contract a string that
71
+ parses as JSON is taken as serialized, so send a plain string that looks like JSON through a
72
+ `type: 'string'` contract. The caller's `headers` object is never rewritten. A bare unquoted string
73
+ under `application/json` is invalid JSON — `@owlmeans/server-api` answers it 400 — which is why it
74
+ is never sent.
56
75
 
57
76
  ## Reading the answer
58
77
 
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAM3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAA;AAa3D,KAAK,MAAM,GAAG,iBAAiB,CAAA;AAE/B,UAAU,OAAO,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,YAAY,CAAC,CAAC,CAAC;CACnE;AAED,eAAO,MAAM,gBAAgB,WAAW,MAAM,KAAmB,SA+FhE,CAAA;AAED,eAAO,MAAM,eAAe,GAAI,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,UAAS,MAAM,KAAmB,CAU/G,CAAA"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAO3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAA;AAW3D,KAAK,MAAM,GAAG,iBAAiB,CAAA;AAE/B,UAAU,OAAO,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,YAAY,CAAC,CAAC,CAAC;CACnE;AAED,eAAO,MAAM,gBAAgB,WAAW,MAAM,KAAmB,SAsFhE,CAAA;AAED,eAAO,MAAM,eAAe,GAAI,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,UAAS,MAAM,KAAmB,CAU/G,CAAA"}
package/build/service.js CHANGED
@@ -2,10 +2,9 @@ import { assertContext, createService } from '@owlmeans/context';
2
2
  import { extractParams } from '@owlmeans/client-route';
3
3
  import axios from 'axios';
4
4
  import { DEFAULT_ALIAS, UNAUTHORIZED_ERROR } from './consts.js';
5
+ import { requestBodyOf } from './utils/body.js';
5
6
  import { processResponse } from './utils/handler.js';
6
- import qs from 'qs';
7
7
  import { makeSecurityHelper } from '@owlmeans/config';
8
- import { RouteMethod } from '@owlmeans/route';
9
8
  import { AUTH_HEADER, DEF_AUTH_SRV, TOKEN_UPDATE } from '@owlmeans/auth-common';
10
9
  /** Whether a request carried an authentication header of its own. */
11
10
  const presented = (headers) => headers != null && Object.entries(headers).some(([key, value]) => key.toLowerCase() === AUTH_HEADER && value != null && value !== '');
@@ -31,22 +30,18 @@ export const createApiService = (alias = DEFAULT_ALIAS) => {
31
30
  // The entrypoint answers where it lives — host, port, base, protocol and whether the hop is
32
31
  // TLS — from its declaration and the service it names. A per-request host still overrides it.
33
32
  const url = helper.makeUrl(module.address(), path, { host: request.host, base: request.base, forceUnsecure: request.unsecure });
34
- let transformer = undefined;
35
- let body = request.body != null && Object.entries((request.headers ?? {})).find(([key, value]) => key.toLowerCase() === 'content-type' && value?.includes('application/x-www-form-urlencoded')) ? qs.stringify(request.body) : request.body;
36
- // @TODO Probably this hack needs to be made a little bit less dirty
37
- if (typeof body === 'string' && route.method === RouteMethod.POST) {
38
- if (Object.keys(request.headers).every(key => key.toLowerCase() !== 'content-type')) {
39
- request.headers['content-type'] = 'application/json';
40
- // It fixes the case when final rest api under application/json can't
41
- // properly accept canonized jsoned string value (put into quotes)
42
- transformer = data => data;
43
- }
44
- }
33
+ // A scalar JSON body (a string, a number, a boolean) is serialized here, so the server parses
34
+ // back the value the caller sent; objects and arrays are left to axios.
35
+ const body = requestBodyOf(request.body, request.headers, route.method, module.filter?.body);
36
+ const transformer = body.verbatim ? data => data : undefined;
37
+ const requestHeaders = body.contentType != null
38
+ ? { ...request.headers, 'content-type': body.contentType }
39
+ : request.headers;
45
40
  const response = await axios.request({
46
41
  url, method: route.method,
47
42
  params: request.query,
48
- data: body,
49
- headers: request.headers,
43
+ data: body.data,
44
+ headers: requestHeaders,
50
45
  transformRequest: transformer,
51
46
  validateStatus: () => true,
52
47
  // Per-request timeout (ms); axios aborts the request and rejects on expiry.
@@ -1 +1 @@
1
- {"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import { RouteMethod } from '@owlmeans/route';
2
+ export declare const JSON_CONTENT_TYPE = "application/json";
3
+ /** The request's own `content-type`, whatever case its key is written in. */
4
+ export declare const contentTypeOf: (headers?: Record<string, unknown>) => string | undefined;
5
+ /** `application/json` or a `+json` suffix type, with or without parameters. */
6
+ export declare const isJsonContentType: (type?: string) => boolean;
7
+ /**
8
+ * The JSON text a scalar body travels as, so the server parses it back to the same value.
9
+ *
10
+ * A number or a boolean is `JSON.stringify`'d. A string is a value unless it is already JSON text:
11
+ * one that does not parse is quoted; one that parses is sent as it is (the caller serialized it
12
+ * itself) — except under a body schema of `type: 'string'`, where only a JSON string literal
13
+ * counts as serialized and `123`, `true` or `{…}` are the text the caller means.
14
+ */
15
+ export declare const jsonScalarBody: (body: string | number | boolean, schema?: object) => string;
16
+ export interface RequestBody {
17
+ /** What axios is handed as `data`. */
18
+ data: unknown;
19
+ /** A `content-type` the request lacked and must carry. */
20
+ contentType?: string;
21
+ /** `data` is final text — axios must not transform it again. */
22
+ verbatim: boolean;
23
+ }
24
+ /**
25
+ * What a request's body becomes on the wire.
26
+ *
27
+ * - A `x-www-form-urlencoded` request: the body through `qs`.
28
+ * - A string, number or boolean under a JSON `content-type` — the caller's, or `application/json`
29
+ * supplied for a `POST` that names none — becomes JSON text ({@link jsonScalarBody}).
30
+ * - Anything else — objects, arrays, a scalar under another content type — goes to axios as it is,
31
+ * which serializes an object or an array as JSON.
32
+ */
33
+ export declare const requestBodyOf: (body: unknown, headers: Record<string, unknown> | undefined, method?: RouteMethod, schema?: object) => RequestBody;
34
+ //# sourceMappingURL=body.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"body.d.ts","sourceRoot":"","sources":["../../src/utils/body.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAE7C,eAAO,MAAM,iBAAiB,qBAAqB,CAAA;AAInD,6EAA6E;AAC7E,eAAO,MAAM,aAAa,aAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,MAAM,GAAG,SAM1E,CAAA;AAED,+EAA+E;AAC/E,eAAO,MAAM,iBAAiB,UAAW,MAAM,KAAG,OAC+B,CAAA;AAoBjF;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,SAAU,MAAM,GAAG,MAAM,GAAG,OAAO,WAAW,MAAM,KAAG,MAQjF,CAAA;AAED,MAAM,WAAW,WAAW;IAC1B,sCAAsC;IACtC,IAAI,EAAE,OAAO,CAAA;IACb,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gEAAgE;IAChE,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,SAClB,OAAO,WAAW,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,WAAW,WAAW,WAAW,MAAM,KACjG,WAgBF,CAAA"}
@@ -0,0 +1,71 @@
1
+ import qs from 'qs';
2
+ import { RouteMethod } from '@owlmeans/route';
3
+ export const JSON_CONTENT_TYPE = 'application/json';
4
+ const FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded';
5
+ /** The request's own `content-type`, whatever case its key is written in. */
6
+ export const contentTypeOf = (headers) => {
7
+ const value = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === 'content-type')?.[1];
8
+ if (value == null || value === '') {
9
+ return undefined;
10
+ }
11
+ return Array.isArray(value) ? value.join(', ') : `${value}`;
12
+ };
13
+ /** `application/json` or a `+json` suffix type, with or without parameters. */
14
+ export const isJsonContentType = (type) => type != null && /^application\/(?:[^\s;]+\+)?json\s*(?:;|$)/i.test(type.trim());
15
+ /** A JSON value no serializer quotes for us: a string, a number or a boolean. */
16
+ const isScalar = (value) => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
17
+ /** Does the entrypoint's body schema declare a string? */
18
+ const stringContract = (schema) => {
19
+ const type = schema?.type;
20
+ return (Array.isArray(type) ? type : [type]).includes('string');
21
+ };
22
+ const parse = (text) => {
23
+ try {
24
+ return { json: true, value: JSON.parse(text) };
25
+ }
26
+ catch {
27
+ return { json: false };
28
+ }
29
+ };
30
+ /**
31
+ * The JSON text a scalar body travels as, so the server parses it back to the same value.
32
+ *
33
+ * A number or a boolean is `JSON.stringify`'d. A string is a value unless it is already JSON text:
34
+ * one that does not parse is quoted; one that parses is sent as it is (the caller serialized it
35
+ * itself) — except under a body schema of `type: 'string'`, where only a JSON string literal
36
+ * counts as serialized and `123`, `true` or `{…}` are the text the caller means.
37
+ */
38
+ export const jsonScalarBody = (body, schema) => {
39
+ if (typeof body !== 'string') {
40
+ return JSON.stringify(body);
41
+ }
42
+ const parsed = parse(body);
43
+ const serialized = parsed.json && (!stringContract(schema) || typeof parsed.value === 'string');
44
+ return serialized ? body : JSON.stringify(body);
45
+ };
46
+ /**
47
+ * What a request's body becomes on the wire.
48
+ *
49
+ * - A `x-www-form-urlencoded` request: the body through `qs`.
50
+ * - A string, number or boolean under a JSON `content-type` — the caller's, or `application/json`
51
+ * supplied for a `POST` that names none — becomes JSON text ({@link jsonScalarBody}).
52
+ * - Anything else — objects, arrays, a scalar under another content type — goes to axios as it is,
53
+ * which serializes an object or an array as JSON.
54
+ */
55
+ export const requestBodyOf = (body, headers, method, schema) => {
56
+ const type = contentTypeOf(headers);
57
+ if (body != null && type?.includes(FORM_CONTENT_TYPE) === true) {
58
+ return { data: qs.stringify(body), verbatim: false };
59
+ }
60
+ if (!isScalar(body)) {
61
+ return { data: body, verbatim: false };
62
+ }
63
+ if (type == null && method === RouteMethod.POST) {
64
+ return { data: jsonScalarBody(body, schema), contentType: JSON_CONTENT_TYPE, verbatim: true };
65
+ }
66
+ if (isJsonContentType(type)) {
67
+ return { data: jsonScalarBody(body, schema), verbatim: true };
68
+ }
69
+ return { data: body, verbatim: false };
70
+ };
71
+ //# sourceMappingURL=body.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"body.js","sourceRoot":"","sources":["../../src/utils/body.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/api",
3
- "version": "0.1.18-rc.40",
3
+ "version": "0.1.18-rc.42",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -29,10 +29,10 @@
29
29
  }
30
30
  },
31
31
  "dependencies": {
32
- "@owlmeans/auth-common": "^0.1.18-rc.40",
33
- "@owlmeans/client-config": "^0.1.18-rc.38",
34
- "@owlmeans/client-route": "^0.1.18-rc.41",
35
- "@owlmeans/config": "^0.1.18-rc.38",
32
+ "@owlmeans/auth-common": "^0.1.18-rc.42",
33
+ "@owlmeans/client-config": "^0.1.18-rc.39",
34
+ "@owlmeans/client-route": "^0.1.18-rc.43",
35
+ "@owlmeans/config": "^0.1.18-rc.39",
36
36
  "@owlmeans/context": "^0.1.18-rc.32",
37
37
  "@owlmeans/error": "^0.1.18-rc.36",
38
38
  "@owlmeans/entrypoint": "^0.1.18-rc.37",
@@ -42,6 +42,7 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@owlmeans/dep-config": "workspace:*",
45
+ "@owlmeans/resource": "^0.1.18-rc.37",
45
46
  "@types/qs": "^6.9.16",
46
47
  "nodemon": "^3.1.14",
47
48
  "typescript": "^7.0.2"
package/src/service.ts CHANGED
@@ -6,11 +6,10 @@ import axios, { AxiosHeaders } from 'axios'
6
6
  import type { AxiosRequestTransformer } from 'axios'
7
7
  import type { CommonEntrypoint } from '@owlmeans/entrypoint'
8
8
  import { DEFAULT_ALIAS, UNAUTHORIZED_ERROR } from './consts.js'
9
+ import { requestBodyOf } from './utils/body.js'
9
10
  import { processResponse } from './utils/handler.js'
10
11
  import { BasicClientConfig } from '@owlmeans/client-config'
11
- import qs from 'qs'
12
12
  import { makeSecurityHelper } from '@owlmeans/config'
13
- import { RouteMethod } from '@owlmeans/route'
14
13
  import { AUTH_HEADER, DEF_AUTH_SRV, TOKEN_UPDATE } from '@owlmeans/auth-common'
15
14
  import type { AuthService } from '@owlmeans/auth-common'
16
15
 
@@ -54,28 +53,19 @@ export const createApiService = (alias: string = DEFAULT_ALIAS): ApiClient => {
54
53
  { host: request.host, base: request.base, forceUnsecure: request.unsecure }
55
54
  )
56
55
 
57
- let transformer: AxiosRequestTransformer | undefined = undefined
58
-
59
- let body = request.body != null && Object.entries((request.headers ?? {})).find(
60
- ([key, value]) =>
61
- key.toLowerCase() === 'content-type' && value?.includes('application/x-www-form-urlencoded')
62
- ) ? qs.stringify(request.body) : request.body
63
-
64
- // @TODO Probably this hack needs to be made a little bit less dirty
65
- if (typeof body === 'string' && route.method === RouteMethod.POST) {
66
- if (Object.keys(request.headers).every(key => key.toLowerCase() !== 'content-type')) {
67
- request.headers['content-type'] = 'application/json'
68
- // It fixes the case when final rest api under application/json can't
69
- // properly accept canonized jsoned string value (put into quotes)
70
- transformer = data => data
71
- }
72
- }
56
+ // A scalar JSON body (a string, a number, a boolean) is serialized here, so the server parses
57
+ // back the value the caller sent; objects and arrays are left to axios.
58
+ const body = requestBodyOf(request.body, request.headers, route.method, module.filter?.body)
59
+ const transformer: AxiosRequestTransformer | undefined = body.verbatim ? data => data : undefined
60
+ const requestHeaders = body.contentType != null
61
+ ? { ...request.headers, 'content-type': body.contentType }
62
+ : request.headers
73
63
 
74
64
  const response = await axios.request({
75
65
  url, method: route.method,
76
66
  params: request.query,
77
- data: body,
78
- headers: request.headers,
67
+ data: body.data,
68
+ headers: requestHeaders,
79
69
  transformRequest: transformer,
80
70
  validateStatus: () => true,
81
71
  // Per-request timeout (ms); axios aborts the request and rejects on expiry.
@@ -0,0 +1,93 @@
1
+ import qs from 'qs'
2
+ import { RouteMethod } from '@owlmeans/route'
3
+
4
+ export const JSON_CONTENT_TYPE = 'application/json'
5
+
6
+ const FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded'
7
+
8
+ /** The request's own `content-type`, whatever case its key is written in. */
9
+ export const contentTypeOf = (headers?: Record<string, unknown>): string | undefined => {
10
+ const value = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === 'content-type')?.[1]
11
+ if (value == null || value === '') {
12
+ return undefined
13
+ }
14
+ return Array.isArray(value) ? value.join(', ') : `${value}`
15
+ }
16
+
17
+ /** `application/json` or a `+json` suffix type, with or without parameters. */
18
+ export const isJsonContentType = (type?: string): boolean =>
19
+ type != null && /^application\/(?:[^\s;]+\+)?json\s*(?:;|$)/i.test(type.trim())
20
+
21
+ /** A JSON value no serializer quotes for us: a string, a number or a boolean. */
22
+ const isScalar = (value: unknown): value is string | number | boolean =>
23
+ typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
24
+
25
+ /** Does the entrypoint's body schema declare a string? */
26
+ const stringContract = (schema?: object): boolean => {
27
+ const type = (schema as { type?: unknown } | undefined)?.type
28
+ return (Array.isArray(type) ? type : [type]).includes('string')
29
+ }
30
+
31
+ const parse = (text: string): { json: true, value: unknown } | { json: false } => {
32
+ try {
33
+ return { json: true, value: JSON.parse(text) }
34
+ } catch {
35
+ return { json: false }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * The JSON text a scalar body travels as, so the server parses it back to the same value.
41
+ *
42
+ * A number or a boolean is `JSON.stringify`'d. A string is a value unless it is already JSON text:
43
+ * one that does not parse is quoted; one that parses is sent as it is (the caller serialized it
44
+ * itself) — except under a body schema of `type: 'string'`, where only a JSON string literal
45
+ * counts as serialized and `123`, `true` or `{…}` are the text the caller means.
46
+ */
47
+ export const jsonScalarBody = (body: string | number | boolean, schema?: object): string => {
48
+ if (typeof body !== 'string') {
49
+ return JSON.stringify(body)
50
+ }
51
+ const parsed = parse(body)
52
+ const serialized = parsed.json && (!stringContract(schema) || typeof parsed.value === 'string')
53
+
54
+ return serialized ? body : JSON.stringify(body)
55
+ }
56
+
57
+ export interface RequestBody {
58
+ /** What axios is handed as `data`. */
59
+ data: unknown
60
+ /** A `content-type` the request lacked and must carry. */
61
+ contentType?: string
62
+ /** `data` is final text — axios must not transform it again. */
63
+ verbatim: boolean
64
+ }
65
+
66
+ /**
67
+ * What a request's body becomes on the wire.
68
+ *
69
+ * - A `x-www-form-urlencoded` request: the body through `qs`.
70
+ * - A string, number or boolean under a JSON `content-type` — the caller's, or `application/json`
71
+ * supplied for a `POST` that names none — becomes JSON text ({@link jsonScalarBody}).
72
+ * - Anything else — objects, arrays, a scalar under another content type — goes to axios as it is,
73
+ * which serializes an object or an array as JSON.
74
+ */
75
+ export const requestBodyOf = (
76
+ body: unknown, headers: Record<string, unknown> | undefined, method?: RouteMethod, schema?: object
77
+ ): RequestBody => {
78
+ const type = contentTypeOf(headers)
79
+ if (body != null && type?.includes(FORM_CONTENT_TYPE) === true) {
80
+ return { data: qs.stringify(body), verbatim: false }
81
+ }
82
+ if (!isScalar(body)) {
83
+ return { data: body, verbatim: false }
84
+ }
85
+ if (type == null && method === RouteMethod.POST) {
86
+ return { data: jsonScalarBody(body, schema), contentType: JSON_CONTENT_TYPE, verbatim: true }
87
+ }
88
+ if (isJsonContentType(type)) {
89
+ return { data: jsonScalarBody(body, schema), verbatim: true }
90
+ }
91
+
92
+ return { data: body, verbatim: false }
93
+ }
@@ -0,0 +1,125 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { provideResponse } from '@owlmeans/entrypoint'
3
+ import type { AbstractRequest } from '@owlmeans/entrypoint'
4
+ import { RouteMethod } from '@owlmeans/route'
5
+ import { createApiService } from '../src/service.js'
6
+ import { contentTypeOf, isJsonContentType, jsonScalarBody, requestBodyOf } from '../src/utils/body.js'
7
+
8
+ const STRING = { type: 'string' }
9
+
10
+ describe('what a request body becomes', () => {
11
+ test('a scalar on a POST without a content type is JSON text', () => {
12
+ expect(requestBodyOf('abc', {}, RouteMethod.POST)).toEqual({ data: '"abc"', contentType: 'application/json', verbatim: true })
13
+ expect(requestBodyOf('', {}, RouteMethod.POST).data).toBe('""')
14
+ expect(requestBodyOf(5, {}, RouteMethod.POST).data).toBe('5')
15
+ expect(requestBodyOf(0, {}, RouteMethod.POST).data).toBe('0')
16
+ expect(requestBodyOf(true, {}, RouteMethod.POST).data).toBe('true')
17
+ expect(requestBodyOf(false, {}, RouteMethod.POST).data).toBe('false')
18
+ })
19
+
20
+ test('a scalar under an explicit JSON content type is JSON text, on any method', () => {
21
+ expect(requestBodyOf('abc', { 'Content-Type': 'application/json; charset=utf-8' }, RouteMethod.PUT))
22
+ .toEqual({ data: '"abc"', verbatim: true })
23
+ expect(requestBodyOf(7, { 'content-type': 'application/merge-patch+json' }, RouteMethod.PATCH).data).toBe('7')
24
+ })
25
+
26
+ test('already-serialized JSON text is sent as it is', () => {
27
+ expect(requestBodyOf('{"a":1}', {}, RouteMethod.POST).data).toBe('{"a":1}')
28
+ expect(requestBodyOf('[1,2]', {}, RouteMethod.POST).data).toBe('[1,2]')
29
+ expect(requestBodyOf('"abc"', {}, RouteMethod.POST).data).toBe('"abc"')
30
+ expect(requestBodyOf('"abc"', {}, RouteMethod.POST, STRING).data).toBe('"abc"')
31
+ })
32
+
33
+ test('under a string body schema, JSON-looking text is the string the caller means', () => {
34
+ expect(jsonScalarBody('123', STRING)).toBe('"123"')
35
+ expect(jsonScalarBody('true', STRING)).toBe('"true"')
36
+ expect(jsonScalarBody('null', { type: ['string', 'null'] })).toBe('"null"')
37
+ expect(jsonScalarBody('{"a":1}', STRING)).toBe('"{\\"a\\":1}"')
38
+ expect(jsonScalarBody('123', { type: 'number' })).toBe('123')
39
+ })
40
+
41
+ test('objects, arrays and everything outside JSON behave as before', () => {
42
+ const object = { a: 1 }
43
+ expect(requestBodyOf(object, {}, RouteMethod.POST)).toEqual({ data: object, verbatim: false })
44
+ expect(requestBodyOf([1], {}, RouteMethod.POST)).toEqual({ data: [1], verbatim: false })
45
+ expect(requestBodyOf(undefined, {}, RouteMethod.POST)).toEqual({ data: undefined, verbatim: false })
46
+ expect(requestBodyOf('abc', { 'content-type': 'text/plain' }, RouteMethod.POST)).toEqual({ data: 'abc', verbatim: false })
47
+ expect(requestBodyOf('abc', {}, RouteMethod.PUT)).toEqual({ data: 'abc', verbatim: false })
48
+ expect(requestBodyOf({ a: '1', b: ['x'] }, { 'content-type': 'application/x-www-form-urlencoded' }, RouteMethod.POST).data)
49
+ .toBe('a=1&b%5B0%5D=x')
50
+ })
51
+
52
+ test('the content-type header is read whatever its case', () => {
53
+ expect(contentTypeOf({ 'CONTENT-TYPE': 'application/json' })).toBe('application/json')
54
+ expect(contentTypeOf({ accept: 'application/json' })).toBeUndefined()
55
+ expect(isJsonContentType('application/vnd.api+json')).toBe(true)
56
+ expect(isJsonContentType('application/jsonp')).toBe(false)
57
+ expect(isJsonContentType('text/plain')).toBe(false)
58
+ })
59
+ })
60
+
61
+ /**
62
+ * The real client over real HTTP: an echo server answers the bytes and the content type it
63
+ * received, so what a Fastify JSON parser would read is exactly what is asserted here.
64
+ */
65
+ describe('the API client on the wire', () => {
66
+ let server: ReturnType<typeof Bun.serve>
67
+
68
+ beforeAll(() => {
69
+ server = Bun.serve({
70
+ port: 0,
71
+ fetch: async request => Response.json({ type: request.headers.get('content-type'), raw: await request.text() }),
72
+ })
73
+ })
74
+
75
+ afterAll(() => { server.stop(true) })
76
+
77
+ const send = async (
78
+ body: unknown, opts: { method?: RouteMethod, headers?: Record<string, string>, schema?: object } = {}
79
+ ): Promise<{ type: string | null, raw: string }> => {
80
+ const client = createApiService('test-client')
81
+ client.ctx = {
82
+ cfg: { service: 'api-tests', security: { unsecure: true }, services: {} },
83
+ entrypoint: () => ({
84
+ alias: 'echo',
85
+ route: { route: { method: opts.method ?? RouteMethod.POST } },
86
+ filter: opts.schema != null ? { body: opts.schema } : undefined,
87
+ path: () => '/echo',
88
+ address: () => ({ host: '127.0.0.1', port: server.port, secure: false }),
89
+ }),
90
+ hasService: () => false,
91
+ } as never
92
+ const reply = provideResponse<{ type: string | null, raw: string }>()
93
+ const request = { alias: 'echo', params: {}, query: {}, headers: opts.headers ?? {}, body, path: '/echo' } as AbstractRequest
94
+ await client.handler(request, reply)
95
+ if (reply.error != null) {
96
+ throw reply.error
97
+ }
98
+ return reply.value!
99
+ }
100
+
101
+ test('a bare string, number or boolean arrives as valid JSON of the same value', async () => {
102
+ for (const value of ['abc', '', 'a "quoted" word', 42, 0, true, false]) {
103
+ const received = await send(value)
104
+ expect(received.type).toStartWith('application/json')
105
+ expect(JSON.parse(received.raw)).toEqual(value)
106
+ }
107
+ })
108
+
109
+ test('a string contract carries JSON-looking text as a string', async () => {
110
+ expect(JSON.parse((await send('123', { schema: STRING })).raw)).toBe('123')
111
+ })
112
+
113
+ test('objects, arrays and serialized text arrive as before', async () => {
114
+ expect(await send({ a: 1 })).toEqual({ type: expect.stringContaining('application/json'), raw: '{"a":1}' })
115
+ expect((await send(['x'])).raw).toBe('["x"]')
116
+ expect((await send('{"a":1}')).raw).toBe('{"a":1}')
117
+ expect(await send('plain', { headers: { 'content-type': 'text/plain' } })).toEqual({ type: 'text/plain', raw: 'plain' })
118
+ })
119
+
120
+ test('the caller\'s headers are not rewritten', async () => {
121
+ const headers: Record<string, string> = {}
122
+ await send('abc', { headers })
123
+ expect(headers).toEqual({})
124
+ })
125
+ })
@@ -4,6 +4,7 @@ import type { AxiosResponse } from 'axios'
4
4
  import type { AbstractResponse } from '@owlmeans/entrypoint'
5
5
  import { EntrypointOutcome } from '@owlmeans/entrypoint'
6
6
  import { ResilientError } from '@owlmeans/error'
7
+ import { RecordExists, UnknownRecordError } from '@owlmeans/resource'
7
8
  import {
8
9
  ApiClientError, ApiStatusError, ServerAuthError, ServerCrashedError, httpStatusOf, incidentIdOf,
9
10
  INCIDENT_ID_HEADER,
@@ -143,6 +144,41 @@ describe('a development body stays its typed class', () => {
143
144
  expect(error.status).toBe(409)
144
145
  expect(error.incidentId).toBe(INCIDENT)
145
146
  })
147
+
148
+ test('a storage refusal (404 absent record, 409 taken key) rebuilds into its own class', () => {
149
+ for (const [refusal, code] of [
150
+ [new UnknownRecordError('shelf/3'), 404], [new RecordExists('isbn'), 409],
151
+ ] as const) {
152
+ const body = ResilientError.marshal(refusal, { includeStack: true, incidentId: INCIDENT }).message
153
+ const { error } = respond(code, body, { [INCIDENT_ID_HEADER]: INCIDENT })
154
+ expect(error!.constructor).toBe(refusal.constructor)
155
+ expect(error!.message).toBe(refusal.message)
156
+ expect(httpStatusOf(error)).toBe(code)
157
+ expect(incidentIdOf(error)).toBe(INCIDENT)
158
+ }
159
+ const body = ResilientError.marshal(new UnknownRecordError('shelf/3')).message
160
+ expect((respond(404, body).error as UnknownRecordError).id).toBe('shelf/3')
161
+ })
162
+
163
+ test('a class this process never registered still answers the status the server sent', () => {
164
+ const body = `ApiSpecUnregisteredRefusal${ResilientError.separator}spec:unregistered:x`
165
+ const { error } = respond(404, body, { [INCIDENT_ID_HEADER]: INCIDENT })
166
+ expect(error).not.toBeInstanceOf(ServerCrashedError)
167
+ expect(httpStatusOf(error)).toBe(404)
168
+ expect(incidentIdOf(error)).toBe(INCIDENT)
169
+ })
170
+ })
171
+
172
+ describe('a production body of a storage refusal keeps the declared status', () => {
173
+ test('404 and 409 become status errors, not a crash', () => {
174
+ for (const code of [404, 409]) {
175
+ const { error } = respond(code, INCIDENT)
176
+ expect(error).toBeInstanceOf(ApiStatusError)
177
+ expect(error).not.toBeInstanceOf(ServerCrashedError)
178
+ expect(error!.message).toBe(`api:client:status:${code}:${INCIDENT}`)
179
+ expect(httpStatusOf(error)).toBe(code)
180
+ }
181
+ })
146
182
  })
147
183
 
148
184
  describe('marshal round trips', () => {