@hodeinavarro/odoo-rpc-ts 0.2.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/LICENSE +21 -0
- package/README.md +401 -0
- package/dist/client.d.ts +184 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +277 -0
- package/dist/client.js.map +1 -0
- package/dist/combinators/retryOnSessionExpired.d.ts +13 -0
- package/dist/combinators/retryOnSessionExpired.d.ts.map +1 -0
- package/dist/combinators/retryOnSessionExpired.js +15 -0
- package/dist/combinators/retryOnSessionExpired.js.map +1 -0
- package/dist/commands.d.ts +91 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +69 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +60 -0
- package/dist/config.js.map +1 -0
- package/dist/domain.d.ts +30 -0
- package/dist/domain.d.ts.map +1 -0
- package/dist/domain.js +70 -0
- package/dist/domain.js.map +1 -0
- package/dist/errors/auth.d.ts +27 -0
- package/dist/errors/auth.d.ts.map +1 -0
- package/dist/errors/auth.js +8 -0
- package/dist/errors/auth.js.map +1 -0
- package/dist/errors/index.d.ts +30 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +13 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/errors/mapFault.d.ts +31 -0
- package/dist/errors/mapFault.d.ts.map +1 -0
- package/dist/errors/mapFault.js +48 -0
- package/dist/errors/mapFault.js.map +1 -0
- package/dist/errors/protocol.d.ts +22 -0
- package/dist/errors/protocol.d.ts.map +1 -0
- package/dist/errors/protocol.js +9 -0
- package/dist/errors/protocol.js.map +1 -0
- package/dist/errors/schema.d.ts +22 -0
- package/dist/errors/schema.d.ts.map +1 -0
- package/dist/errors/schema.js +14 -0
- package/dist/errors/schema.js.map +1 -0
- package/dist/errors/server.d.ts +109 -0
- package/dist/errors/server.d.ts.map +1 -0
- package/dist/errors/server.js +49 -0
- package/dist/errors/server.js.map +1 -0
- package/dist/errors/session.d.ts +16 -0
- package/dist/errors/session.d.ts.map +1 -0
- package/dist/errors/session.js +11 -0
- package/dist/errors/session.js.map +1 -0
- package/dist/errors/transport.d.ts +42 -0
- package/dist/errors/transport.d.ts.map +1 -0
- package/dist/errors/transport.js +49 -0
- package/dist/errors/transport.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/platform.d.ts +12 -0
- package/dist/internal/platform.d.ts.map +1 -0
- package/dist/internal/platform.js +11 -0
- package/dist/internal/platform.js.map +1 -0
- package/dist/internal/singleFlight.d.ts +29 -0
- package/dist/internal/singleFlight.d.ts.map +1 -0
- package/dist/internal/singleFlight.js +41 -0
- package/dist/internal/singleFlight.js.map +1 -0
- package/dist/profiles.d.ts +179 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +191 -0
- package/dist/profiles.js.map +1 -0
- package/dist/protocol/jsonrpc.d.ts +127 -0
- package/dist/protocol/jsonrpc.d.ts.map +1 -0
- package/dist/protocol/jsonrpc.js +117 -0
- package/dist/protocol/jsonrpc.js.map +1 -0
- package/dist/records/index.d.ts +14 -0
- package/dist/records/index.d.ts.map +1 -0
- package/dist/records/index.js +14 -0
- package/dist/records/index.js.map +1 -0
- package/dist/records/recordModel.d.ts +131 -0
- package/dist/records/recordModel.d.ts.map +1 -0
- package/dist/records/recordModel.js +101 -0
- package/dist/records/recordModel.js.map +1 -0
- package/dist/records/related.d.ts +42 -0
- package/dist/records/related.d.ts.map +1 -0
- package/dist/records/related.js +51 -0
- package/dist/records/related.js.map +1 -0
- package/dist/records/relations.d.ts +66 -0
- package/dist/records/relations.d.ts.map +1 -0
- package/dist/records/relations.js +119 -0
- package/dist/records/relations.js.map +1 -0
- package/dist/records/spec.d.ts +44 -0
- package/dist/records/spec.d.ts.map +1 -0
- package/dist/records/spec.js +63 -0
- package/dist/records/spec.js.map +1 -0
- package/dist/records/typed.d.ts +71 -0
- package/dist/records/typed.d.ts.map +1 -0
- package/dist/records/typed.js +65 -0
- package/dist/records/typed.js.map +1 -0
- package/dist/rpc.d.ts +90 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +113 -0
- package/dist/rpc.js.map +1 -0
- package/dist/services/db.d.ts +128 -0
- package/dist/services/db.d.ts.map +1 -0
- package/dist/services/db.js +132 -0
- package/dist/services/db.js.map +1 -0
- package/dist/services/report.d.ts +48 -0
- package/dist/services/report.d.ts.map +1 -0
- package/dist/services/report.js +103 -0
- package/dist/services/report.js.map +1 -0
- package/dist/session/cookie.d.ts +114 -0
- package/dist/session/cookie.d.ts.map +1 -0
- package/dist/session/cookie.js +231 -0
- package/dist/session/cookie.js.map +1 -0
- package/dist/testing/fakeTransport.d.ts +40 -0
- package/dist/testing/fakeTransport.d.ts.map +1 -0
- package/dist/testing/fakeTransport.js +36 -0
- package/dist/testing/fakeTransport.js.map +1 -0
- package/dist/testing/index.d.ts +9 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/transport.d.ts +62 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +9 -0
- package/dist/transport.js.map +1 -0
- package/dist/transports/json2.d.ts +41 -0
- package/dist/transports/json2.d.ts.map +1 -0
- package/dist/transports/json2.js +196 -0
- package/dist/transports/json2.js.map +1 -0
- package/dist/transports/jsonrpc.d.ts +41 -0
- package/dist/transports/jsonrpc.d.ts.map +1 -0
- package/dist/transports/jsonrpc.js +119 -0
- package/dist/transports/jsonrpc.js.map +1 -0
- package/dist/transports/web.d.ts +15 -0
- package/dist/transports/web.d.ts.map +1 -0
- package/dist/transports/web.js +62 -0
- package/dist/transports/web.js.map +1 -0
- package/dist/version-live.d.ts +41 -0
- package/dist/version-live.d.ts.map +1 -0
- package/dist/version-live.js +26 -0
- package/dist/version-live.js.map +1 -0
- package/dist/version.d.ts +78 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +46 -0
- package/dist/version.js.map +1 -0
- package/docs/protocol-verification.md +43 -0
- package/package.json +71 -0
- package/src/client.ts +731 -0
- package/src/combinators/retryOnSessionExpired.ts +25 -0
- package/src/commands.ts +112 -0
- package/src/config.ts +110 -0
- package/src/domain.ts +91 -0
- package/src/errors/auth.ts +28 -0
- package/src/errors/index.ts +50 -0
- package/src/errors/mapFault.ts +92 -0
- package/src/errors/protocol.ts +19 -0
- package/src/errors/schema.ts +18 -0
- package/src/errors/server.ts +79 -0
- package/src/errors/session.ts +12 -0
- package/src/errors/transport.ts +74 -0
- package/src/index.ts +152 -0
- package/src/internal/platform.ts +22 -0
- package/src/internal/singleFlight.ts +80 -0
- package/src/profiles.ts +386 -0
- package/src/protocol/jsonrpc.ts +162 -0
- package/src/records/index.ts +49 -0
- package/src/records/recordModel.ts +211 -0
- package/src/records/related.ts +80 -0
- package/src/records/relations.ts +192 -0
- package/src/records/spec.ts +96 -0
- package/src/records/typed.ts +180 -0
- package/src/rpc.ts +219 -0
- package/src/services/db.ts +282 -0
- package/src/services/report.ts +165 -0
- package/src/session/cookie.ts +461 -0
- package/src/testing/fakeTransport.ts +84 -0
- package/src/testing/index.ts +9 -0
- package/src/transport.ts +72 -0
- package/src/transports/json2.ts +303 -0
- package/src/transports/jsonrpc.ts +218 -0
- package/src/transports/web.ts +96 -0
- package/src/version-live.ts +67 -0
- package/src/version.ts +102 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Effect, Result, Schema } from "effect";
|
|
2
|
+
import { OdooAccessError, OdooMissingError } from "../errors/server.js";
|
|
3
|
+
import { SchemaDriftError } from "../errors/schema.js";
|
|
4
|
+
import { OdooTransportError } from "../errors/transport.js";
|
|
5
|
+
/**
|
|
6
|
+
* Lenient decoder for an `ir.actions.report` row. Only the columns we request
|
|
7
|
+
* are decoded; extra keys are tolerated. `report_name` is the identifier that
|
|
8
|
+
* feeds the `GET /report/<converter>/<report_name>/<ids>` download route.
|
|
9
|
+
*/
|
|
10
|
+
const ReportActionSchema = Schema.Struct({
|
|
11
|
+
id: Schema.Number,
|
|
12
|
+
name: Schema.String,
|
|
13
|
+
report_name: Schema.String,
|
|
14
|
+
model: Schema.String,
|
|
15
|
+
report_type: Schema.String,
|
|
16
|
+
});
|
|
17
|
+
const toReportAction = (row) => ({
|
|
18
|
+
id: row.id,
|
|
19
|
+
name: row.name,
|
|
20
|
+
reportName: row.report_name,
|
|
21
|
+
model: row.model,
|
|
22
|
+
reportType: row.report_type,
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* List `ir.actions.report` definitions over a cookie session, optionally scoped
|
|
26
|
+
* to a single `model`. Self-contained: goes through the session's `json` hatch
|
|
27
|
+
* (`web/dataset/call_kw` → `search_read`) so it needs no `Transport`/`Rpc`
|
|
28
|
+
* wiring. Fails with the session's `CookieLoginError` union (login faults,
|
|
29
|
+
* server faults, or `SchemaDriftError` on a drifted row shape).
|
|
30
|
+
*/
|
|
31
|
+
export const list = (session, model) => Effect.gen(function* () {
|
|
32
|
+
const domain = model === undefined ? [] : [["model", "=", model]];
|
|
33
|
+
const result = yield* session.json("web/dataset/call_kw", {
|
|
34
|
+
model: "ir.actions.report",
|
|
35
|
+
method: "search_read",
|
|
36
|
+
args: [domain, ["id", "name", "report_name", "model", "report_type"]],
|
|
37
|
+
kwargs: {},
|
|
38
|
+
});
|
|
39
|
+
const rows = yield* Schema.decodeUnknownEffect(Schema.Array(ReportActionSchema))(result).pipe(Effect.mapError((cause) => new SchemaDriftError({ context: "ir.actions.report rows", payload: result, cause })));
|
|
40
|
+
return rows.map(toReportAction);
|
|
41
|
+
});
|
|
42
|
+
/** The `%PDF` signature every well-formed PDF opens with. */
|
|
43
|
+
const PDF_MAGIC = Uint8Array.of(0x25, 0x50, 0x44, 0x46);
|
|
44
|
+
const hasPdfMagic = (bytes) => bytes.length >= PDF_MAGIC.length && PDF_MAGIC.every((byte, i) => bytes[i] === byte);
|
|
45
|
+
/**
|
|
46
|
+
* Download a rendered report as raw bytes via `GET
|
|
47
|
+
* /report/<converter>/<reportName>/<ids.join(",")>` on the cookie-bound client
|
|
48
|
+
* (the route is identical and CSRF-free across Odoo 16–19).
|
|
49
|
+
*
|
|
50
|
+
* Status mapping: `404 → OdooMissingError`, `403 → OdooAccessError`, any other
|
|
51
|
+
* non-2xx (including the `500` Odoo raises for an unknown report name) →
|
|
52
|
+
* `OdooTransportError`. For `converter === "pdf"` the 2xx body is checked for
|
|
53
|
+
* the `%PDF` magic and fails as `SchemaDriftError` if the server handed back
|
|
54
|
+
* something else (e.g. an HTML error page with a 200).
|
|
55
|
+
*/
|
|
56
|
+
export const download = (session, options) => Effect.gen(function* () {
|
|
57
|
+
const converter = options.converter ?? "pdf";
|
|
58
|
+
const path = `report/${converter}/${options.reportName}/${options.ids.join(",")}`;
|
|
59
|
+
const response = yield* session.http({ path, method: "GET" });
|
|
60
|
+
const request = { method: "GET", url: response.request.url };
|
|
61
|
+
if (response.status === 404) {
|
|
62
|
+
return yield* Effect.fail(new OdooMissingError({
|
|
63
|
+
name: "werkzeug.exceptions.NotFound",
|
|
64
|
+
message: `Report ${options.reportName} not found (404) for ids ${options.ids.join(",")}.`,
|
|
65
|
+
arguments: [],
|
|
66
|
+
context: {},
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
if (response.status === 403) {
|
|
70
|
+
return yield* Effect.fail(new OdooAccessError({
|
|
71
|
+
name: "werkzeug.exceptions.Forbidden",
|
|
72
|
+
message: `Access denied (403) downloading report ${options.reportName}.`,
|
|
73
|
+
arguments: [],
|
|
74
|
+
context: {},
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
if (response.status < 200 || response.status >= 300) {
|
|
78
|
+
return yield* Effect.fail(new OdooTransportError({
|
|
79
|
+
request,
|
|
80
|
+
kind: "StatusCodeError",
|
|
81
|
+
status: response.status,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
const buffer = yield* response.arrayBuffer.pipe(Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)));
|
|
85
|
+
const bytes = new Uint8Array(buffer);
|
|
86
|
+
if (converter === "pdf" && !hasPdfMagic(bytes)) {
|
|
87
|
+
// Manufacture a real ParseError so SchemaDriftError.cause is honest: a 2xx
|
|
88
|
+
// body that is not a PDF (e.g. an HTML error page served with status 200).
|
|
89
|
+
const header = new TextDecoder().decode(bytes.subarray(0, PDF_MAGIC.length));
|
|
90
|
+
const parsed = Schema.decodeUnknownResult(Schema.Literal("%PDF"))(header);
|
|
91
|
+
if (Result.isFailure(parsed)) {
|
|
92
|
+
return yield* Effect.fail(new SchemaDriftError({
|
|
93
|
+
context: `report/pdf/${options.reportName}`,
|
|
94
|
+
payload: `non-PDF body (${bytes.length} bytes, status ${response.status})`,
|
|
95
|
+
cause: parsed.failure,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return bytes;
|
|
100
|
+
});
|
|
101
|
+
/** The report service, grouped for `ReportService.list` / `ReportService.download`. */
|
|
102
|
+
export const ReportService = { list, download };
|
|
103
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"report.js","sourceRoot":"","sources":["../../src/services/report.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type Config, Context, Effect, Layer, Option, Redacted, Ref } from "effect";
|
|
2
|
+
import { OdooConfig } from "../config.js";
|
|
3
|
+
import { OdooAuthenticationError } from "../errors/auth.js";
|
|
4
|
+
import type { OdooServerFault } from "../errors/server.js";
|
|
5
|
+
import { SchemaDriftError } from "../errors/schema.js";
|
|
6
|
+
import { SessionExpiredError } from "../errors/session.js";
|
|
7
|
+
import { OdooTransportError } from "../errors/transport.js";
|
|
8
|
+
import { Cookies, HttpClient, HttpClientResponse } from "../internal/platform.js";
|
|
9
|
+
/**
|
|
10
|
+
* Join a request path onto the configured base URL, preserving any base path
|
|
11
|
+
* (e.g. a server mounted under `/odoo`). Keeping this here means both the
|
|
12
|
+
* authenticate call and the `call_kw` transport derive endpoints identically.
|
|
13
|
+
*/
|
|
14
|
+
export declare const joinPath: (base: URL, path: string) => string;
|
|
15
|
+
/** A live, authenticated web session. `uid` is always a real user id here. */
|
|
16
|
+
export interface OdooSessionInfo {
|
|
17
|
+
readonly uid: number;
|
|
18
|
+
readonly userContext: Record<string, unknown>;
|
|
19
|
+
readonly serverVersionInfo: unknown;
|
|
20
|
+
/** The full, undecoded `session_info` result for downstream passthrough. */
|
|
21
|
+
readonly raw: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The typed failure channel of a login round trip. `SessionExpiredError` is in
|
|
25
|
+
* the union only because it flows through the shared `mapJsonRpcError`; a fresh
|
|
26
|
+
* authenticate should never actually produce it.
|
|
27
|
+
*/
|
|
28
|
+
export type CookieLoginError = OdooTransportError | SchemaDriftError | OdooAuthenticationError | OdooServerFault | SessionExpiredError;
|
|
29
|
+
/**
|
|
30
|
+
* The cookie-session service. Owns the `session_id` cookie jar and a
|
|
31
|
+
* success-only single-flight around `/web/session/authenticate`.
|
|
32
|
+
*
|
|
33
|
+
* `client` is the HttpClient decorated with the shared cookie `Ref` — the web
|
|
34
|
+
* transport MUST reuse it so the login `Set-Cookie` (and any mid-session
|
|
35
|
+
* rotation) is carried on every subsequent `call_kw`.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Options for {@link CookieSessionService.http}: a raw HTTP round trip on the
|
|
39
|
+
* cookie-bound client. `path` is joined onto the session's base url (a base
|
|
40
|
+
* mount like `/odoo` is preserved). `method` defaults to `GET`.
|
|
41
|
+
*/
|
|
42
|
+
export interface RawHttpOptions {
|
|
43
|
+
readonly path: string;
|
|
44
|
+
readonly method?: string;
|
|
45
|
+
readonly body?: Uint8Array;
|
|
46
|
+
readonly headers?: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
export interface CookieSessionService {
|
|
49
|
+
/** Ensure a login has run and return the live session. Single-flight. */
|
|
50
|
+
readonly login: Effect.Effect<OdooSessionInfo, CookieLoginError>;
|
|
51
|
+
/**
|
|
52
|
+
* Raw JSON-RPC escape hatch: ensure login, then POST the shared JSON-RPC
|
|
53
|
+
* envelope (`{jsonrpc, method:"call", params, id}`) to an arbitrary `path`
|
|
54
|
+
* under the session's base url and return the raw `result` member. Server
|
|
55
|
+
* faults flow through the shared `mapJsonRpcError` choke point (code 100 →
|
|
56
|
+
* `SessionExpiredError`, etc.); a non-envelope body fails as `SchemaDriftError`.
|
|
57
|
+
* For calling models this is `web/dataset/call_kw` with the `{model, method,
|
|
58
|
+
* args, kwargs}` params shape.
|
|
59
|
+
*/
|
|
60
|
+
readonly json: (path: string, params?: Record<string, unknown>) => Effect.Effect<unknown, CookieLoginError>;
|
|
61
|
+
/**
|
|
62
|
+
* Raw HTTP escape hatch: ensure login, then issue a raw request on the
|
|
63
|
+
* cookie-bound client and return the response UNTOUCHED. The caller owns the
|
|
64
|
+
* response fully, including any non-2xx status — this hatch never inspects it
|
|
65
|
+
* and only fails (`OdooTransportError`) on a wire-level error or a failed
|
|
66
|
+
* login. Used for binary endpoints like `GET /report/<converter>/...`.
|
|
67
|
+
*/
|
|
68
|
+
readonly http: (options: RawHttpOptions) => Effect.Effect<HttpClientResponse.HttpClientResponse, CookieLoginError>;
|
|
69
|
+
/**
|
|
70
|
+
* Drop the cached session and the (now-dead) `session_id` cookie so the next
|
|
71
|
+
* `login` re-authenticates. Does NOT auto-relogin — that is the opt-in
|
|
72
|
+
* `retryOnSessionExpired` combinator's job.
|
|
73
|
+
*/
|
|
74
|
+
readonly invalidate: Effect.Effect<void>;
|
|
75
|
+
/** The shared cookie jar. Exposed for the transport's `withCookiesRef`. */
|
|
76
|
+
readonly cookies: Ref.Ref<Cookies.Cookies>;
|
|
77
|
+
/** HttpClient bound to the shared cookie jar; the transport reuses this. */
|
|
78
|
+
readonly client: HttpClient.HttpClient;
|
|
79
|
+
/** Peek at the current session without triggering a login (observability). */
|
|
80
|
+
readonly peek: Effect.Effect<Option.Option<OdooSessionInfo>>;
|
|
81
|
+
}
|
|
82
|
+
declare const CookieSession_base: Context.ServiceClass<CookieSession, "odoo-rpc-ts/CookieSession", CookieSessionService>;
|
|
83
|
+
export declare class CookieSession extends CookieSession_base {
|
|
84
|
+
}
|
|
85
|
+
export declare const make: (config: OdooConfig) => Effect.Effect<CookieSessionService, never, HttpClient.HttpClient>;
|
|
86
|
+
/** Provide `CookieSession` from an already-resolved config. Requires `HttpClient`. */
|
|
87
|
+
export declare const layer: (config: OdooConfig) => Layer.Layer<CookieSession, never, HttpClient.HttpClient>;
|
|
88
|
+
/** Provide `CookieSession`, resolving `OdooConfig` from the environment. */
|
|
89
|
+
export declare const layerConfig: Layer.Layer<CookieSession, Config.ConfigError, HttpClient.HttpClient>;
|
|
90
|
+
/**
|
|
91
|
+
* Options for {@link fromExisting}: adopt a `session_id` cookie that was
|
|
92
|
+
* minted elsewhere (e.g. harvested from an embedded browser window after the
|
|
93
|
+
* user completed the real `/web/login` page — the only stock flow that works
|
|
94
|
+
* for TOTP/SSO accounts).
|
|
95
|
+
*/
|
|
96
|
+
export interface ExistingSessionOptions {
|
|
97
|
+
/** Base server URL (same shape as `OdooConfig.url`; TLS rules apply). */
|
|
98
|
+
readonly url: URL;
|
|
99
|
+
/** The harvested `session_id` cookie value. Redacted until the wire. */
|
|
100
|
+
readonly sessionId: Redacted.Redacted<string>;
|
|
101
|
+
/**
|
|
102
|
+
* Optional renewal hook: an Effect that yields a FRESH `session_id` (e.g.
|
|
103
|
+
* by driving a login window again). Runs inside the login single-flight
|
|
104
|
+
* after an `invalidate`, so concurrent callers trigger it at most once.
|
|
105
|
+
* Must be fully provided (`R = never`) and fail within `CookieLoginError` —
|
|
106
|
+
* map UI-level failures (window closed, …) into `OdooAuthenticationError`.
|
|
107
|
+
*/
|
|
108
|
+
readonly renew?: Effect.Effect<Redacted.Redacted<string>, CookieLoginError>;
|
|
109
|
+
}
|
|
110
|
+
export declare const fromExisting: (options: ExistingSessionOptions) => Effect.Effect<CookieSessionService, never, HttpClient.HttpClient>;
|
|
111
|
+
/** Provide `CookieSession` from a harvested cookie. See {@link fromExisting}. */
|
|
112
|
+
export declare const layerFromExisting: (options: ExistingSessionOptions) => Layer.Layer<CookieSession, never, HttpClient.HttpClient>;
|
|
113
|
+
export {};
|
|
114
|
+
//# sourceMappingURL=cookie.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookie.d.ts","sourceRoot":"","sources":["../../src/session/cookie.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,EAAU,MAAM,QAAQ,CAAC;AAC5F,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAoB,MAAM,wBAAwB,CAAC;AAC9E,OAAO,EACL,OAAO,EACP,UAAU,EAEV,kBAAkB,EACnB,MAAM,yBAAyB,CAAC;AASjC;;;;GAIG;AACH,eAAO,MAAM,QAAQ,SAAU,GAAG,QAAQ,MAAM,KAAG,MAGlD,CAAC;AAaF,8EAA8E;AAC9E,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9C,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC,4EAA4E;IAC5E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GACxB,kBAAkB,GAClB,gBAAgB,GAChB,uBAAuB,GACvB,eAAe,GACf,mBAAmB,CAAC;AAExB;;;;;;;GAOG;AACH;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,oBAAoB;IACnC,yEAAyE;IACzE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,gBAAgB,CAAC,CAAC;IACjE;;;;;;;;OAQG;IACH,QAAQ,CAAC,IAAI,EAAE,CACb,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,gBAAgB,CAAC,CAAC;IAC9C;;;;;;OAMG;IACH,QAAQ,CAAC,IAAI,EAAE,CACb,OAAO,EAAE,cAAc,KACpB,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,CAAC;IAC5E;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACzC,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3C,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,UAAU,CAAC;IACvC,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,CAAC;CAC9D;;AAED,qBAAa,aAAc,SAAQ,kBAGH;CAAG;AAgJnC,eAAO,MAAM,IAAI,WACP,UAAU,KACjB,MAAM,CAAC,MAAM,CAAC,oBAAoB,EAAE,KAAK,EAAE,UAAU,CAAC,UAAU,CA2C/D,CAAC;AAEL,sFAAsF;AACtF,eAAO,MAAM,KAAK,WACR,UAAU,KACjB,KAAK,CAAC,KAAK,CAAC,aAAa,EAAE,KAAK,EAAE,UAAU,CAAC,UAAU,CACf,CAAC;AAE5C,4EAA4E;AAC5E,eAAO,MAAM,WAAW,EAAE,KAAK,CAAC,KAAK,CACnC,aAAa,EACb,MAAM,CAAC,WAAW,EAClB,UAAU,CAAC,UAAU,CAC0C,CAAC;AAElE;;;;;GAKG;AACH,MAAM,WAAW,sBAAsB;IACrC,yEAAyE;IACzE,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,wEAAwE;IACxE,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,gBAAgB,CAAC,CAAC;CAC7E;AAsBD,eAAO,MAAM,YAAY,YACd,sBAAsB,KAC9B,MAAM,CAAC,MAAM,CAAC,oBAAoB,EAAE,KAAK,EAAE,UAAU,CAAC,UAAU,CAiF/D,CAAC;AAEL,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,YACnB,sBAAsB,KAC9B,KAAK,CAAC,KAAK,CAAC,aAAa,EAAE,KAAK,EAAE,UAAU,CAAC,UAAU,CACN,CAAC"}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option, Redacted, Ref, Schema } from "effect";
|
|
2
|
+
import { OdooConfig } from "../config.js";
|
|
3
|
+
import { OdooAuthenticationError } from "../errors/auth.js";
|
|
4
|
+
import { SchemaDriftError } from "../errors/schema.js";
|
|
5
|
+
import { SessionExpiredError } from "../errors/session.js";
|
|
6
|
+
import { OdooTransportError } from "../errors/transport.js";
|
|
7
|
+
import { Cookies, HttpClient, HttpClientRequest, HttpClientResponse, } from "../internal/platform.js";
|
|
8
|
+
import * as SingleFlight from "../internal/singleFlight.js";
|
|
9
|
+
import { buildRequest, JsonRpcResponse, mapJsonRpcError, nextRequestId, } from "../protocol/jsonrpc.js";
|
|
10
|
+
/**
|
|
11
|
+
* Join a request path onto the configured base URL, preserving any base path
|
|
12
|
+
* (e.g. a server mounted under `/odoo`). Keeping this here means both the
|
|
13
|
+
* authenticate call and the `call_kw` transport derive endpoints identically.
|
|
14
|
+
*/
|
|
15
|
+
export const joinPath = (base, path) => {
|
|
16
|
+
const href = base.href.endsWith("/") ? base.href : `${base.href}/`;
|
|
17
|
+
return new URL(path, href).href;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* `session_info` as returned by `/web/session/authenticate`. Decoded leniently:
|
|
21
|
+
* `uid` is `null` while a second factor is still pending, extra keys are
|
|
22
|
+
* tolerated, and `server_version_info` passes through undecoded.
|
|
23
|
+
*/
|
|
24
|
+
const SessionInfoSchema = Schema.Struct({
|
|
25
|
+
uid: Schema.NullOr(Schema.Number),
|
|
26
|
+
user_context: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
27
|
+
server_version_info: Schema.optional(Schema.Unknown),
|
|
28
|
+
});
|
|
29
|
+
export class CookieSession extends Context.Service()("odoo-rpc-ts/CookieSession") {
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One session-info round trip: POST a JSON-RPC envelope, decode the response,
|
|
33
|
+
* map faults through the shared choke point (code 100 → `SessionExpiredError`),
|
|
34
|
+
* and validate the returned `session_info`. Shared by the credential login and
|
|
35
|
+
* the injected-cookie path so both decode — and fail — identically.
|
|
36
|
+
*/
|
|
37
|
+
const sessionInfoRoundTrip = (client, url, params, endpoint, method) => Effect.gen(function* () {
|
|
38
|
+
const request = { method: "POST", url };
|
|
39
|
+
const envelope = buildRequest(params, nextRequestId());
|
|
40
|
+
const response = yield* client
|
|
41
|
+
.execute(HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.post(url), envelope))
|
|
42
|
+
.pipe(Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)));
|
|
43
|
+
const body = yield* response.json.pipe(Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)));
|
|
44
|
+
const decoded = yield* Schema.decodeUnknownEffect(JsonRpcResponse)(body).pipe(Effect.mapError((cause) => new SchemaDriftError({
|
|
45
|
+
context: `${endpoint} envelope`,
|
|
46
|
+
payload: body,
|
|
47
|
+
cause,
|
|
48
|
+
})));
|
|
49
|
+
if ("error" in decoded) {
|
|
50
|
+
return yield* Effect.fail(mapJsonRpcError(decoded.error, { method }));
|
|
51
|
+
}
|
|
52
|
+
const info = yield* Schema.decodeUnknownEffect(SessionInfoSchema)(decoded.result).pipe(Effect.mapError((cause) => new SchemaDriftError({
|
|
53
|
+
context: `${endpoint} session_info`,
|
|
54
|
+
payload: decoded.result,
|
|
55
|
+
cause,
|
|
56
|
+
})));
|
|
57
|
+
if (info.uid === null) {
|
|
58
|
+
return yield* Effect.fail(new OdooAuthenticationError({
|
|
59
|
+
reason: "mfa-pending",
|
|
60
|
+
message: "Login accepted but a second factor is required before a session exists.",
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
const session = {
|
|
64
|
+
uid: info.uid,
|
|
65
|
+
userContext: info.user_context ?? {},
|
|
66
|
+
serverVersionInfo: info.server_version_info,
|
|
67
|
+
raw: typeof decoded.result === "object" && decoded.result !== null
|
|
68
|
+
? decoded.result
|
|
69
|
+
: {},
|
|
70
|
+
};
|
|
71
|
+
return session;
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* Build the two raw escape hatches (`json`, `http`) shared by {@link make} and
|
|
75
|
+
* {@link fromExisting}. Both close over the SAME cookie-bound `client`, base
|
|
76
|
+
* `url`, and single-flight `login` the rest of the service uses, so a hatch
|
|
77
|
+
* request rides the live `session_id` cookie exactly like a `call_kw` does.
|
|
78
|
+
*/
|
|
79
|
+
const makeRawHatches = (client, baseUrl, login) => {
|
|
80
|
+
const json = (path, params = {}) => Effect.gen(function* () {
|
|
81
|
+
yield* login;
|
|
82
|
+
const url = joinPath(baseUrl, path);
|
|
83
|
+
const request = { method: "POST", url };
|
|
84
|
+
const envelope = buildRequest(params, nextRequestId());
|
|
85
|
+
const response = yield* client
|
|
86
|
+
.execute(HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.post(url), envelope))
|
|
87
|
+
.pipe(Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)));
|
|
88
|
+
const body = yield* response.json.pipe(Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)));
|
|
89
|
+
const decoded = yield* Schema.decodeUnknownEffect(JsonRpcResponse)(body).pipe(Effect.mapError((cause) => new SchemaDriftError({ context: `${path} envelope`, payload: body, cause })));
|
|
90
|
+
if ("error" in decoded) {
|
|
91
|
+
return yield* Effect.fail(mapJsonRpcError(decoded.error, { method: path }));
|
|
92
|
+
}
|
|
93
|
+
return decoded.result;
|
|
94
|
+
});
|
|
95
|
+
const http = (options) => Effect.gen(function* () {
|
|
96
|
+
yield* login;
|
|
97
|
+
const url = joinPath(baseUrl, options.path);
|
|
98
|
+
const method = options.method ?? "GET";
|
|
99
|
+
const request = { method, url };
|
|
100
|
+
let req = HttpClientRequest.make(method)(url);
|
|
101
|
+
if (options.headers !== undefined) {
|
|
102
|
+
req = HttpClientRequest.setHeaders(req, options.headers);
|
|
103
|
+
}
|
|
104
|
+
if (options.body !== undefined) {
|
|
105
|
+
req = HttpClientRequest.bodyUint8Array(req, options.body);
|
|
106
|
+
}
|
|
107
|
+
// Response returned untouched — the caller owns any non-2xx status.
|
|
108
|
+
return yield* client
|
|
109
|
+
.execute(req)
|
|
110
|
+
.pipe(Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)));
|
|
111
|
+
});
|
|
112
|
+
return { json, http };
|
|
113
|
+
};
|
|
114
|
+
export const make = (config) => Effect.gen(function* () {
|
|
115
|
+
const baseClient = yield* HttpClient.HttpClient;
|
|
116
|
+
const cookies = yield* Ref.make(Cookies.empty);
|
|
117
|
+
const client = HttpClient.withCookiesRef(baseClient, cookies);
|
|
118
|
+
const authUrl = joinPath(config.url, "web/session/authenticate");
|
|
119
|
+
const doLogin = Effect.suspend(() => {
|
|
120
|
+
// Redacted.value only at the wire boundary; for ApiKey creds the key is
|
|
121
|
+
// sent as the password (valid on all supported Odoo versions).
|
|
122
|
+
const password = config.credentials._tag === "ApiKey"
|
|
123
|
+
? Redacted.value(config.credentials.apiKey)
|
|
124
|
+
: Redacted.value(config.credentials.password);
|
|
125
|
+
return sessionInfoRoundTrip(client, authUrl, { db: config.db, login: config.credentials.username, password }, "web/session/authenticate", "authenticate");
|
|
126
|
+
});
|
|
127
|
+
// The flight owns both caching and invalidation. Its generation counter
|
|
128
|
+
// guarantees a login already in flight when `invalidate` runs cannot
|
|
129
|
+
// resurrect the cache with a stale session (its caller still gets it).
|
|
130
|
+
const flight = yield* SingleFlight.make(doLogin);
|
|
131
|
+
const invalidate = Effect.gen(function* () {
|
|
132
|
+
yield* Ref.set(cookies, Cookies.empty);
|
|
133
|
+
yield* flight.invalidate;
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
login: flight.get,
|
|
137
|
+
invalidate,
|
|
138
|
+
cookies,
|
|
139
|
+
client,
|
|
140
|
+
peek: flight.peek,
|
|
141
|
+
...makeRawHatches(client, config.url, flight.get),
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
/** Provide `CookieSession` from an already-resolved config. Requires `HttpClient`. */
|
|
145
|
+
export const layer = (config) => Layer.effect(CookieSession, make(config));
|
|
146
|
+
/** Provide `CookieSession`, resolving `OdooConfig` from the environment. */
|
|
147
|
+
export const layerConfig = Layer.effect(CookieSession, Effect.flatMap(OdooConfig, make));
|
|
148
|
+
/**
|
|
149
|
+
* Build a `CookieSessionService` from an already-minted `session_id` cookie —
|
|
150
|
+
* no credentials are ever held. `login` seeds the cookie jar and validates the
|
|
151
|
+
* session via `POST /web/session/get_session_info` (verified 16–19: JSON-RPC
|
|
152
|
+
* envelope, `auth="user"`), so `OdooSessionInfo` (`uid`, context, version) is
|
|
153
|
+
* always real, never fabricated. Success-only single-flight, like {@link make}.
|
|
154
|
+
*
|
|
155
|
+
* Expired-session contract: this session cannot re-login by itself. When the
|
|
156
|
+
* server kills the session, calls fail with `SessionExpiredError`; after
|
|
157
|
+
* `invalidate`, a subsequent `login` fails FAST with `SessionExpiredError`
|
|
158
|
+
* (no network) unless a `renew` hook was provided. `retryOnSessionExpired`
|
|
159
|
+
* therefore performs exactly one relogin attempt and propagates the second
|
|
160
|
+
* `SessionExpiredError` — it can never spin. The consumer's shell is expected
|
|
161
|
+
* to catch it, re-run its login UI, and construct a fresh session (or supply
|
|
162
|
+
* `renew` to do the same in place).
|
|
163
|
+
*/
|
|
164
|
+
// RFC 6265 `cookie-octet`: the exact charset a cookie value may put on the
|
|
165
|
+
// wire (excludes controls, whitespace, DQUOTE, comma, semicolon, backslash).
|
|
166
|
+
const cookieOctets = /^[\u0021\u0023-\u002b\u002d-\u003a\u003c-\u005b\u005d-\u007e]+$/;
|
|
167
|
+
export const fromExisting = (options) => Effect.gen(function* () {
|
|
168
|
+
const baseClient = yield* HttpClient.HttpClient;
|
|
169
|
+
const cookies = yield* Ref.make(Cookies.empty);
|
|
170
|
+
const client = HttpClient.withCookiesRef(baseClient, cookies);
|
|
171
|
+
const infoUrl = joinPath(options.url, "web/session/get_session_info");
|
|
172
|
+
// The cookie to seed on the next login. Starts as the harvested value;
|
|
173
|
+
// consumed once, and cleared by `invalidate` so a dead cookie is never
|
|
174
|
+
// re-seeded. After that only `renew` can produce another one.
|
|
175
|
+
const nextCookie = yield* Ref.make(Option.some(options.sessionId));
|
|
176
|
+
// Redacted.value only at the wire boundary (the cookie jar IS the wire
|
|
177
|
+
// buffer). Seeded via `fromSetCookie` so the raw value rides the wire
|
|
178
|
+
// untouched, exactly like a server-set cookie — `Cookies.makeCookie`
|
|
179
|
+
// would percent-encode it (corrupting `+`/`=`/`/`), and an unvalidated
|
|
180
|
+
// `fromSetCookie` would silently truncate at the first `;`.
|
|
181
|
+
const seed = (value) => {
|
|
182
|
+
const raw = Redacted.value(value);
|
|
183
|
+
if (raw === "" || !cookieOctets.test(raw)) {
|
|
184
|
+
return Effect.fail(new OdooAuthenticationError({
|
|
185
|
+
reason: "invalid-credentials",
|
|
186
|
+
message: "The provided session_id is not a valid cookie value (RFC 6265).",
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
return Ref.update(cookies, (jar) => Cookies.merge(jar, Cookies.fromSetCookie(`session_id=${raw}; Path=/`)));
|
|
190
|
+
};
|
|
191
|
+
const doLogin = Effect.gen(function* () {
|
|
192
|
+
const jar = yield* Ref.get(cookies);
|
|
193
|
+
if (Option.isNone(Cookies.getValue(jar, "session_id"))) {
|
|
194
|
+
const pending = yield* Ref.getAndSet(nextCookie, Option.none());
|
|
195
|
+
if (Option.isSome(pending)) {
|
|
196
|
+
yield* seed(pending.value);
|
|
197
|
+
}
|
|
198
|
+
else if (options.renew !== undefined) {
|
|
199
|
+
yield* seed(yield* options.renew);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// Fail FAST, without a round trip: this session holds no credentials
|
|
203
|
+
// and no renew hook, so re-login is structurally impossible.
|
|
204
|
+
return yield* Effect.fail(new SessionExpiredError({
|
|
205
|
+
message: "The injected web session was invalidated and no renew hook was provided; " +
|
|
206
|
+
"obtain a fresh session_id and construct a new session.",
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Validate + hydrate: a dead cookie surfaces here as code 100 →
|
|
211
|
+
// SessionExpiredError via the shared choke point.
|
|
212
|
+
return yield* sessionInfoRoundTrip(client, infoUrl, {}, "web/session/get_session_info", "get_session_info");
|
|
213
|
+
});
|
|
214
|
+
const flight = yield* SingleFlight.make(doLogin);
|
|
215
|
+
const invalidate = Effect.gen(function* () {
|
|
216
|
+
yield* Ref.set(cookies, Cookies.empty);
|
|
217
|
+
yield* Ref.set(nextCookie, Option.none());
|
|
218
|
+
yield* flight.invalidate;
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
login: flight.get,
|
|
222
|
+
invalidate,
|
|
223
|
+
cookies,
|
|
224
|
+
client,
|
|
225
|
+
peek: flight.peek,
|
|
226
|
+
...makeRawHatches(client, options.url, flight.get),
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
/** Provide `CookieSession` from a harvested cookie. See {@link fromExisting}. */
|
|
230
|
+
export const layerFromExisting = (options) => Layer.effect(CookieSession, fromExisting(options));
|
|
231
|
+
//# sourceMappingURL=cookie.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookie.js","sourceRoot":"","sources":["../../src/session/cookie.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Effect, Layer, Ref } from "effect";
|
|
2
|
+
import { type CallKwParams, Transport, type TransportCallError, type TransportDialect } from "../transport.js";
|
|
3
|
+
/**
|
|
4
|
+
* A single scripted method. Receives the full {@link CallKwParams} of the
|
|
5
|
+
* `call_kw` — `args`, `kwargs`, and the browse `ids` faithfully, because the
|
|
6
|
+
* seam is the contract, not a projection of it — and returns either a bare value
|
|
7
|
+
* (wrapped in `Effect.succeed`) or an `Effect`; the latter lets a handler script
|
|
8
|
+
* a typed transport failure.
|
|
9
|
+
*/
|
|
10
|
+
export type FakeHandler = (params: CallKwParams) => Effect.Effect<unknown, TransportCallError> | unknown;
|
|
11
|
+
/**
|
|
12
|
+
* Scripted responses keyed by `model` then `method`. Anything not scripted is a
|
|
13
|
+
* test misconfiguration — see the dispatch note in {@link make}.
|
|
14
|
+
*/
|
|
15
|
+
export type FakeHandlers = {
|
|
16
|
+
readonly [model: string]: {
|
|
17
|
+
readonly [method: string]: FakeHandler;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/** A `FakeTransport` layer paired with the log of every call it received. */
|
|
21
|
+
export interface FakeTransport {
|
|
22
|
+
readonly layer: Layer.Layer<Transport>;
|
|
23
|
+
/**
|
|
24
|
+
* Every `call_kw` this transport handled, in order, for assertions. Populated
|
|
25
|
+
* before the handler runs, so a failing handler still records its call.
|
|
26
|
+
*/
|
|
27
|
+
readonly callLog: Ref.Ref<ReadonlyArray<CallKwParams>>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a deterministic, network-free {@link Transport} from scripted handlers.
|
|
31
|
+
*
|
|
32
|
+
* GOTCHA: an unhandled model/method is a defect in the test, not a runtime
|
|
33
|
+
* condition the code under test could ever encounter — so it is an `Effect.die`
|
|
34
|
+
* (with a precise locator), never a value in the typed failure channel. Tests
|
|
35
|
+
* that mean to exercise a transport failure must script it explicitly.
|
|
36
|
+
*/
|
|
37
|
+
export declare const make: (handlers: FakeHandlers, options?: {
|
|
38
|
+
readonly dialect?: TransportDialect;
|
|
39
|
+
}) => FakeTransport;
|
|
40
|
+
//# sourceMappingURL=fakeTransport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fakeTransport.d.ts","sourceRoot":"","sources":["../../src/testing/fakeTransport.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,QAAQ,CAAC;AAC5C,OAAO,EACL,KAAK,YAAY,EACjB,SAAS,EACT,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACtB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GAAG,CACxB,MAAM,EAAE,YAAY,KACjB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,kBAAkB,CAAC,GAAG,OAAO,CAAC;AAE1D;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG;QACxB,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAG,WAAW,CAAC;KACxC,CAAC;CACH,CAAC;AAEF,6EAA6E;AAC7E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IACvC;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,GAAG,CAAC,aAAa,CAAC,YAAY,CAAC,CAAC,CAAC;CACxD;AAMD;;;;;;;GAOG;AACH,eAAO,MAAM,IAAI,aACL,YAAY,YACZ;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,gBAAgB,CAAA;CAAE,KAChD,aA6BF,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect, Layer, Ref } from "effect";
|
|
2
|
+
import { Transport, } from "../transport.js";
|
|
3
|
+
const isEffect = (value) => Effect.isEffect(value);
|
|
4
|
+
/**
|
|
5
|
+
* Build a deterministic, network-free {@link Transport} from scripted handlers.
|
|
6
|
+
*
|
|
7
|
+
* GOTCHA: an unhandled model/method is a defect in the test, not a runtime
|
|
8
|
+
* condition the code under test could ever encounter — so it is an `Effect.die`
|
|
9
|
+
* (with a precise locator), never a value in the typed failure channel. Tests
|
|
10
|
+
* that mean to exercise a transport failure must script it explicitly.
|
|
11
|
+
*/
|
|
12
|
+
export const make = (handlers, options) => {
|
|
13
|
+
const callLog = Ref.makeUnsafe([]);
|
|
14
|
+
const layer = Layer.succeed(Transport, {
|
|
15
|
+
dialect: options?.dialect ?? "execute-kw",
|
|
16
|
+
callKw: (params) => Effect.gen(function* () {
|
|
17
|
+
const handler = handlers[params.model]?.[params.method];
|
|
18
|
+
if (handler === undefined) {
|
|
19
|
+
// Every real Odoo server answers the seeded default layer's
|
|
20
|
+
// res.users.context_get, so the fake does too — WITHOUT logging it
|
|
21
|
+
// (it is ambient plumbing, and logging it would shift positional
|
|
22
|
+
// log assertions). Script res.users.context_get to observe/override.
|
|
23
|
+
if (params.model === "res.users" && params.method === "context_get") {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
yield* Ref.update(callLog, (log) => [...log, params]);
|
|
27
|
+
return yield* Effect.die(`FakeTransport: no handler for ${params.model}.${params.method}`);
|
|
28
|
+
}
|
|
29
|
+
yield* Ref.update(callLog, (log) => [...log, params]);
|
|
30
|
+
const result = handler(params);
|
|
31
|
+
return isEffect(result) ? yield* result : result;
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
return { layer, callLog };
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=fakeTransport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fakeTransport.js","sourceRoot":"","sources":["../../src/testing/fakeTransport.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,QAAQ,CAAC;AAC5C,OAAO,EAEL,SAAS,GAGV,MAAM,iBAAiB,CAAC;AAiCzB,MAAM,QAAQ,GAAG,CACf,KAA2D,EACN,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AAEjF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,CAClB,QAAsB,EACtB,OAAiD,EAClC,EAAE;IACjB,MAAM,OAAO,GAAG,GAAG,CAAC,UAAU,CAA8B,EAAE,CAAC,CAAC;IAEhE,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,SAAS,EAAE;QACrC,OAAO,EAAE,OAAO,EAAE,OAAO,IAAI,YAAY;QACzC,MAAM,EAAE,CAAC,MAAoB,EAAE,EAAE,CAC/B,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;YAClB,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YACxD,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;gBAC1B,4DAA4D;gBAC5D,mEAAmE;gBACnE,iEAAiE;gBACjE,qEAAqE;gBACrE,IAAI,MAAM,CAAC,KAAK,KAAK,WAAW,IAAI,MAAM,CAAC,MAAM,KAAK,aAAa,EAAE,CAAC;oBACpE,OAAO,EAAE,CAAC;gBACZ,CAAC;gBACD,KAAK,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC;gBACtD,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CACtB,iCAAiC,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,CACjE,CAAC;YACJ,CAAC;YACD,KAAK,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC;YAEtD,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;YAC/B,OAAO,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;QACnD,CAAC,CAAC;KACL,CAAC,CAAC;IAEH,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;AAC5B,CAAC,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hodeinavarro/odoo-rpc-ts/testing — deterministic test doubles.
|
|
3
|
+
*
|
|
4
|
+
* `FakeTransport` is a scripted, network-free {@link Transport} implementation
|
|
5
|
+
* (AGENTS.md § Testing export). Compose its `.layer` under the service you want
|
|
6
|
+
* to exercise and assert against its `.callLog`.
|
|
7
|
+
*/
|
|
8
|
+
export { make, type FakeHandler, type FakeHandlers, type FakeTransport } from "./fakeTransport.js";
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,IAAI,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hodeinavarro/odoo-rpc-ts/testing — deterministic test doubles.
|
|
3
|
+
*
|
|
4
|
+
* `FakeTransport` is a scripted, network-free {@link Transport} implementation
|
|
5
|
+
* (AGENTS.md § Testing export). Compose its `.layer` under the service you want
|
|
6
|
+
* to exercise and assert against its `.callLog`.
|
|
7
|
+
*/
|
|
8
|
+
export { make } from "./fakeTransport.js";
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,IAAI,EAA2D,MAAM,oBAAoB,CAAC"}
|