@owlmeans/api 0.1.18-rc.41 → 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 +10 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/api/SKILL.md +22 -3
- package/build/service.d.ts.map +1 -1
- package/build/service.js +10 -15
- package/build/service.js.map +1 -1
- package/build/utils/body.d.ts +34 -0
- package/build/utils/body.d.ts.map +1 -0
- package/build/utils/body.js +71 -0
- package/build/utils/body.js.map +1 -0
- package/package.json +3 -3
- package/src/service.ts +10 -20
- package/src/utils/body.ts +93 -0
- package/tests/body.spec.ts +125 -0
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.
|
|
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.
|
|
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
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/api",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
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.
|
|
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
|
|
55
|
-
|
|
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
|
|
package/build/service.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
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.
|
package/build/service.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEhE,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAEtD,OAAO,KAAuB,MAAM,OAAO,CAAA;AAG3C,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAC/D,OAAO,EAAE,
|
|
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.
|
|
3
|
+
"version": "0.1.18-rc.42",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@owlmeans/auth-common": "^0.1.18-rc.
|
|
32
|
+
"@owlmeans/auth-common": "^0.1.18-rc.42",
|
|
33
33
|
"@owlmeans/client-config": "^0.1.18-rc.39",
|
|
34
|
-
"@owlmeans/client-route": "^0.1.18-rc.
|
|
34
|
+
"@owlmeans/client-route": "^0.1.18-rc.43",
|
|
35
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",
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
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
|
+
})
|