@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.
Files changed (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +401 -0
  3. package/dist/client.d.ts +184 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +277 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/combinators/retryOnSessionExpired.d.ts +13 -0
  8. package/dist/combinators/retryOnSessionExpired.d.ts.map +1 -0
  9. package/dist/combinators/retryOnSessionExpired.js +15 -0
  10. package/dist/combinators/retryOnSessionExpired.js.map +1 -0
  11. package/dist/commands.d.ts +91 -0
  12. package/dist/commands.d.ts.map +1 -0
  13. package/dist/commands.js +69 -0
  14. package/dist/commands.js.map +1 -0
  15. package/dist/config.d.ts +32 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +60 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/domain.d.ts +30 -0
  20. package/dist/domain.d.ts.map +1 -0
  21. package/dist/domain.js +70 -0
  22. package/dist/domain.js.map +1 -0
  23. package/dist/errors/auth.d.ts +27 -0
  24. package/dist/errors/auth.d.ts.map +1 -0
  25. package/dist/errors/auth.js +8 -0
  26. package/dist/errors/auth.js.map +1 -0
  27. package/dist/errors/index.d.ts +30 -0
  28. package/dist/errors/index.d.ts.map +1 -0
  29. package/dist/errors/index.js +13 -0
  30. package/dist/errors/index.js.map +1 -0
  31. package/dist/errors/mapFault.d.ts +31 -0
  32. package/dist/errors/mapFault.d.ts.map +1 -0
  33. package/dist/errors/mapFault.js +48 -0
  34. package/dist/errors/mapFault.js.map +1 -0
  35. package/dist/errors/protocol.d.ts +22 -0
  36. package/dist/errors/protocol.d.ts.map +1 -0
  37. package/dist/errors/protocol.js +9 -0
  38. package/dist/errors/protocol.js.map +1 -0
  39. package/dist/errors/schema.d.ts +22 -0
  40. package/dist/errors/schema.d.ts.map +1 -0
  41. package/dist/errors/schema.js +14 -0
  42. package/dist/errors/schema.js.map +1 -0
  43. package/dist/errors/server.d.ts +109 -0
  44. package/dist/errors/server.d.ts.map +1 -0
  45. package/dist/errors/server.js +49 -0
  46. package/dist/errors/server.js.map +1 -0
  47. package/dist/errors/session.d.ts +16 -0
  48. package/dist/errors/session.d.ts.map +1 -0
  49. package/dist/errors/session.js +11 -0
  50. package/dist/errors/session.js.map +1 -0
  51. package/dist/errors/transport.d.ts +42 -0
  52. package/dist/errors/transport.d.ts.map +1 -0
  53. package/dist/errors/transport.js +49 -0
  54. package/dist/errors/transport.js.map +1 -0
  55. package/dist/index.d.ts +33 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +57 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/internal/platform.d.ts +12 -0
  60. package/dist/internal/platform.d.ts.map +1 -0
  61. package/dist/internal/platform.js +11 -0
  62. package/dist/internal/platform.js.map +1 -0
  63. package/dist/internal/singleFlight.d.ts +29 -0
  64. package/dist/internal/singleFlight.d.ts.map +1 -0
  65. package/dist/internal/singleFlight.js +41 -0
  66. package/dist/internal/singleFlight.js.map +1 -0
  67. package/dist/profiles.d.ts +179 -0
  68. package/dist/profiles.d.ts.map +1 -0
  69. package/dist/profiles.js +191 -0
  70. package/dist/profiles.js.map +1 -0
  71. package/dist/protocol/jsonrpc.d.ts +127 -0
  72. package/dist/protocol/jsonrpc.d.ts.map +1 -0
  73. package/dist/protocol/jsonrpc.js +117 -0
  74. package/dist/protocol/jsonrpc.js.map +1 -0
  75. package/dist/records/index.d.ts +14 -0
  76. package/dist/records/index.d.ts.map +1 -0
  77. package/dist/records/index.js +14 -0
  78. package/dist/records/index.js.map +1 -0
  79. package/dist/records/recordModel.d.ts +131 -0
  80. package/dist/records/recordModel.d.ts.map +1 -0
  81. package/dist/records/recordModel.js +101 -0
  82. package/dist/records/recordModel.js.map +1 -0
  83. package/dist/records/related.d.ts +42 -0
  84. package/dist/records/related.d.ts.map +1 -0
  85. package/dist/records/related.js +51 -0
  86. package/dist/records/related.js.map +1 -0
  87. package/dist/records/relations.d.ts +66 -0
  88. package/dist/records/relations.d.ts.map +1 -0
  89. package/dist/records/relations.js +119 -0
  90. package/dist/records/relations.js.map +1 -0
  91. package/dist/records/spec.d.ts +44 -0
  92. package/dist/records/spec.d.ts.map +1 -0
  93. package/dist/records/spec.js +63 -0
  94. package/dist/records/spec.js.map +1 -0
  95. package/dist/records/typed.d.ts +71 -0
  96. package/dist/records/typed.d.ts.map +1 -0
  97. package/dist/records/typed.js +65 -0
  98. package/dist/records/typed.js.map +1 -0
  99. package/dist/rpc.d.ts +90 -0
  100. package/dist/rpc.d.ts.map +1 -0
  101. package/dist/rpc.js +113 -0
  102. package/dist/rpc.js.map +1 -0
  103. package/dist/services/db.d.ts +128 -0
  104. package/dist/services/db.d.ts.map +1 -0
  105. package/dist/services/db.js +132 -0
  106. package/dist/services/db.js.map +1 -0
  107. package/dist/services/report.d.ts +48 -0
  108. package/dist/services/report.d.ts.map +1 -0
  109. package/dist/services/report.js +103 -0
  110. package/dist/services/report.js.map +1 -0
  111. package/dist/session/cookie.d.ts +114 -0
  112. package/dist/session/cookie.d.ts.map +1 -0
  113. package/dist/session/cookie.js +231 -0
  114. package/dist/session/cookie.js.map +1 -0
  115. package/dist/testing/fakeTransport.d.ts +40 -0
  116. package/dist/testing/fakeTransport.d.ts.map +1 -0
  117. package/dist/testing/fakeTransport.js +36 -0
  118. package/dist/testing/fakeTransport.js.map +1 -0
  119. package/dist/testing/index.d.ts +9 -0
  120. package/dist/testing/index.d.ts.map +1 -0
  121. package/dist/testing/index.js +9 -0
  122. package/dist/testing/index.js.map +1 -0
  123. package/dist/transport.d.ts +62 -0
  124. package/dist/transport.d.ts.map +1 -0
  125. package/dist/transport.js +9 -0
  126. package/dist/transport.js.map +1 -0
  127. package/dist/transports/json2.d.ts +41 -0
  128. package/dist/transports/json2.d.ts.map +1 -0
  129. package/dist/transports/json2.js +196 -0
  130. package/dist/transports/json2.js.map +1 -0
  131. package/dist/transports/jsonrpc.d.ts +41 -0
  132. package/dist/transports/jsonrpc.d.ts.map +1 -0
  133. package/dist/transports/jsonrpc.js +119 -0
  134. package/dist/transports/jsonrpc.js.map +1 -0
  135. package/dist/transports/web.d.ts +15 -0
  136. package/dist/transports/web.d.ts.map +1 -0
  137. package/dist/transports/web.js +62 -0
  138. package/dist/transports/web.js.map +1 -0
  139. package/dist/version-live.d.ts +41 -0
  140. package/dist/version-live.d.ts.map +1 -0
  141. package/dist/version-live.js +26 -0
  142. package/dist/version-live.js.map +1 -0
  143. package/dist/version.d.ts +78 -0
  144. package/dist/version.d.ts.map +1 -0
  145. package/dist/version.js +46 -0
  146. package/dist/version.js.map +1 -0
  147. package/docs/protocol-verification.md +43 -0
  148. package/package.json +71 -0
  149. package/src/client.ts +731 -0
  150. package/src/combinators/retryOnSessionExpired.ts +25 -0
  151. package/src/commands.ts +112 -0
  152. package/src/config.ts +110 -0
  153. package/src/domain.ts +91 -0
  154. package/src/errors/auth.ts +28 -0
  155. package/src/errors/index.ts +50 -0
  156. package/src/errors/mapFault.ts +92 -0
  157. package/src/errors/protocol.ts +19 -0
  158. package/src/errors/schema.ts +18 -0
  159. package/src/errors/server.ts +79 -0
  160. package/src/errors/session.ts +12 -0
  161. package/src/errors/transport.ts +74 -0
  162. package/src/index.ts +152 -0
  163. package/src/internal/platform.ts +22 -0
  164. package/src/internal/singleFlight.ts +80 -0
  165. package/src/profiles.ts +386 -0
  166. package/src/protocol/jsonrpc.ts +162 -0
  167. package/src/records/index.ts +49 -0
  168. package/src/records/recordModel.ts +211 -0
  169. package/src/records/related.ts +80 -0
  170. package/src/records/relations.ts +192 -0
  171. package/src/records/spec.ts +96 -0
  172. package/src/records/typed.ts +180 -0
  173. package/src/rpc.ts +219 -0
  174. package/src/services/db.ts +282 -0
  175. package/src/services/report.ts +165 -0
  176. package/src/session/cookie.ts +461 -0
  177. package/src/testing/fakeTransport.ts +84 -0
  178. package/src/testing/index.ts +9 -0
  179. package/src/transport.ts +72 -0
  180. package/src/transports/json2.ts +303 -0
  181. package/src/transports/jsonrpc.ts +218 -0
  182. package/src/transports/web.ts +96 -0
  183. package/src/version-live.ts +67 -0
  184. package/src/version.ts +102 -0
@@ -0,0 +1,84 @@
1
+ import { Effect, Layer, Ref } from "effect";
2
+ import {
3
+ type CallKwParams,
4
+ Transport,
5
+ type TransportCallError,
6
+ type TransportDialect,
7
+ } from "../transport.ts";
8
+
9
+ /**
10
+ * A single scripted method. Receives the full {@link CallKwParams} of the
11
+ * `call_kw` — `args`, `kwargs`, and the browse `ids` faithfully, because the
12
+ * seam is the contract, not a projection of it — and returns either a bare value
13
+ * (wrapped in `Effect.succeed`) or an `Effect`; the latter lets a handler script
14
+ * a typed transport failure.
15
+ */
16
+ export type FakeHandler = (
17
+ params: CallKwParams,
18
+ ) => Effect.Effect<unknown, TransportCallError> | unknown;
19
+
20
+ /**
21
+ * Scripted responses keyed by `model` then `method`. Anything not scripted is a
22
+ * test misconfiguration — see the dispatch note in {@link make}.
23
+ */
24
+ export type FakeHandlers = {
25
+ readonly [model: string]: {
26
+ readonly [method: string]: FakeHandler;
27
+ };
28
+ };
29
+
30
+ /** A `FakeTransport` layer paired with the log of every call it received. */
31
+ export interface FakeTransport {
32
+ readonly layer: Layer.Layer<Transport>;
33
+ /**
34
+ * Every `call_kw` this transport handled, in order, for assertions. Populated
35
+ * before the handler runs, so a failing handler still records its call.
36
+ */
37
+ readonly callLog: Ref.Ref<ReadonlyArray<CallKwParams>>;
38
+ }
39
+
40
+ const isEffect = (
41
+ value: Effect.Effect<unknown, TransportCallError> | unknown,
42
+ ): value is Effect.Effect<unknown, TransportCallError> => Effect.isEffect(value);
43
+
44
+ /**
45
+ * Build a deterministic, network-free {@link Transport} from scripted handlers.
46
+ *
47
+ * GOTCHA: an unhandled model/method is a defect in the test, not a runtime
48
+ * condition the code under test could ever encounter — so it is an `Effect.die`
49
+ * (with a precise locator), never a value in the typed failure channel. Tests
50
+ * that mean to exercise a transport failure must script it explicitly.
51
+ */
52
+ export const make = (
53
+ handlers: FakeHandlers,
54
+ options?: { readonly dialect?: TransportDialect },
55
+ ): FakeTransport => {
56
+ const callLog = Ref.makeUnsafe<ReadonlyArray<CallKwParams>>([]);
57
+
58
+ const layer = Layer.succeed(Transport, {
59
+ dialect: options?.dialect ?? "execute-kw",
60
+ callKw: (params: CallKwParams) =>
61
+ Effect.gen(function* () {
62
+ const handler = handlers[params.model]?.[params.method];
63
+ if (handler === undefined) {
64
+ // Every real Odoo server answers the seeded default layer's
65
+ // res.users.context_get, so the fake does too — WITHOUT logging it
66
+ // (it is ambient plumbing, and logging it would shift positional
67
+ // log assertions). Script res.users.context_get to observe/override.
68
+ if (params.model === "res.users" && params.method === "context_get") {
69
+ return {};
70
+ }
71
+ yield* Ref.update(callLog, (log) => [...log, params]);
72
+ return yield* Effect.die(
73
+ `FakeTransport: no handler for ${params.model}.${params.method}`,
74
+ );
75
+ }
76
+ yield* Ref.update(callLog, (log) => [...log, params]);
77
+
78
+ const result = handler(params);
79
+ return isEffect(result) ? yield* result : result;
80
+ }),
81
+ });
82
+
83
+ return { layer, callLog };
84
+ };
@@ -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
+
9
+ export { make, type FakeHandler, type FakeHandlers, type FakeTransport } from "./fakeTransport.ts";
@@ -0,0 +1,72 @@
1
+ import { Context, type Effect } from "effect";
2
+ import type { OdooAuthenticationError } from "./errors/auth.ts";
3
+ import type { ProtocolUnsupportedError } from "./errors/protocol.ts";
4
+ import type { SchemaDriftError } from "./errors/schema.ts";
5
+ import type { OdooServerFault } from "./errors/server.ts";
6
+ import type { SessionExpiredError } from "./errors/session.ts";
7
+ import type { OdooTransportError } from "./errors/transport.ts";
8
+
9
+ /**
10
+ * Parameters for a single `call_kw`-shaped round trip. Deliberately mirrors
11
+ * Odoo's `execute_kw(model, method, args, kwargs)`.
12
+ */
13
+ export interface CallKwParams {
14
+ readonly model: string;
15
+ readonly method: string;
16
+ readonly args: ReadonlyArray<unknown>;
17
+ readonly kwargs: Record<string, unknown>;
18
+ /**
19
+ * The browse target — the record ids the method operates on.
20
+ *
21
+ * GOTCHA: this is protocol-agnostic on purpose. JSON-2 sends it as its special
22
+ * `ids` body key; the execute_kw-family transports (web, `/jsonrpc`) prepend it
23
+ * as the first positional argument. Callers set `ids` instead of hand-placing
24
+ * it in `args`, so the same call works over every seam.
25
+ */
26
+ readonly ids?: ReadonlyArray<number>;
27
+ }
28
+
29
+ /**
30
+ * The complete typed failure channel of a transport round trip. Every seam
31
+ * (web, JSON-RPC, JSON-2) maps its wire faults into exactly this union at its
32
+ * choke point, so application code sees one shape regardless of protocol.
33
+ */
34
+ export type TransportCallError =
35
+ | OdooTransportError
36
+ | SessionExpiredError
37
+ | SchemaDriftError
38
+ | OdooAuthenticationError
39
+ | ProtocolUnsupportedError
40
+ | OdooServerFault;
41
+
42
+ /**
43
+ * How a transport encodes calls on the wire. High-level ops that know a
44
+ * method's Python semantics branch on this — a transport itself never reorders
45
+ * args. The one live case: `create` vals MUST be positional over the
46
+ * execute_kw family (Odoo's `call_kw` reads `args[0]` unconditionally, 16–19)
47
+ * but keyword-only (`vals_list`) over JSON-2.
48
+ */
49
+ export type TransportDialect = "execute-kw" | "json2";
50
+
51
+ /**
52
+ * The one seam every protocol implements. `JsonRpcTransport`, `Json2Transport`,
53
+ * and the cookie-session `WebTransport` all provide this tag; application code
54
+ * depends only on it and stays protocol-agnostic.
55
+ */
56
+ export class Transport extends Context.Service<
57
+ Transport,
58
+ {
59
+ /** The wire dialect — see {@link TransportDialect}. */
60
+ readonly dialect: TransportDialect;
61
+
62
+ /**
63
+ * Execute one `call_kw`-shaped round trip and return the raw, still-undecoded
64
+ * result (callers decode through `effect/Schema` at their boundary).
65
+ *
66
+ * GOTCHA: the JSON-2 API (`/json/2/<model>/<method>`) is keyword-only — it
67
+ * cannot take positional `args`. A `Json2Transport` will reject any call
68
+ * where `args` is non-empty; put everything in `kwargs` when targeting it.
69
+ */
70
+ readonly callKw: (params: CallKwParams) => Effect.Effect<unknown, TransportCallError>;
71
+ }
72
+ >()("odoo-rpc-ts/Transport") {}
@@ -0,0 +1,303 @@
1
+ import { type Config, Effect, Layer, Option, Redacted, Schema } from "effect";
2
+ import { OdooConfig } from "../config.ts";
3
+ import type { OdooConfig as OdooConfigType } from "../config.ts";
4
+ import { OdooAuthenticationError } from "../errors/auth.ts";
5
+ import { type FaultCallSite, mapServerFault, type RawServerFault } from "../errors/mapFault.ts";
6
+ import { ProtocolUnsupportedError } from "../errors/protocol.ts";
7
+ import { SchemaDriftError } from "../errors/schema.ts";
8
+ import {
9
+ OdooAccessError,
10
+ OdooLockError,
11
+ OdooMissingError,
12
+ OdooServerError,
13
+ type OdooServerFault,
14
+ OdooUserError,
15
+ } from "../errors/server.ts";
16
+ import { OdooTransportError, type RequestInfo } from "../errors/transport.ts";
17
+ import {
18
+ HttpClient,
19
+ type HttpClientErrorType,
20
+ HttpClientRequest,
21
+ HttpClientResponse,
22
+ } from "../internal/platform.ts";
23
+ import { type CallKwParams, Transport } from "../transport.ts";
24
+ import { ServerVersionInfo } from "../version.ts";
25
+
26
+ /**
27
+ * Odoo's `/json/2/<model>/<method>` API (Odoo 19+): a keyword-only, bearer-auth
28
+ * JSON route that returns the raw method result with no JSON-RPC envelope. This
29
+ * module is one of the three seams behind the {@link Transport} tag.
30
+ */
31
+
32
+ /** Strip any trailing slashes so path segments join cleanly onto a base URL. */
33
+ const baseUrl = (url: URL): string => url.href.replace(/\/+$/u, "");
34
+
35
+ /** Sanitized request snapshot — never carries the bearer header or body. */
36
+ const requestInfo = (url: string, method: string): RequestInfo => ({ method, url });
37
+
38
+ const toTransportError =
39
+ (request: RequestInfo) =>
40
+ (cause: HttpClientErrorType): OdooTransportError =>
41
+ OdooTransportError.fromHttpClientError(request, cause);
42
+
43
+ /**
44
+ * The `serialize_exception` payload Odoo returns in an error body. Everything is
45
+ * optional: a bare HTTP failure (proxy 502, gateway timeout) may have no JSON
46
+ * body at all, in which case we synthesize a fault from the status alone.
47
+ */
48
+ const SerializeException = Schema.Struct({
49
+ name: Schema.optional(Schema.String),
50
+ message: Schema.optional(Schema.String),
51
+ arguments: Schema.optional(Schema.Array(Schema.Unknown)),
52
+ context: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
53
+ debug: Schema.optional(Schema.String),
54
+ });
55
+ type SerializeException = typeof SerializeException.Type;
56
+
57
+ const emptyException: SerializeException = {};
58
+
59
+ /**
60
+ * Build a {@link RawServerFault} from a decoded exception body and the call
61
+ * site, honouring `exactOptionalPropertyTypes` (optional keys are attached only
62
+ * when present, never as explicit `undefined`).
63
+ */
64
+ const rawFault = (
65
+ exc: SerializeException,
66
+ status: number,
67
+ site: FaultCallSite,
68
+ ): RawServerFault & FaultCallSite => ({
69
+ name: exc.name ?? `http.${status}`,
70
+ message: exc.message ?? "",
71
+ arguments: exc.arguments ?? [],
72
+ context: exc.context ?? {},
73
+ ...(exc.debug !== undefined ? { debug: exc.debug } : {}),
74
+ ...(site.model !== undefined ? { model: site.model } : {}),
75
+ ...(site.method !== undefined ? { method: site.method } : {}),
76
+ });
77
+
78
+ /** Status → concrete server-fault constructor, for the no-`name` fallback path. */
79
+ const byStatus: Record<number, new (fields: RawServerFault & FaultCallSite) => OdooServerFault> = {
80
+ 403: OdooAccessError,
81
+ 404: OdooMissingError,
82
+ 409: OdooLockError,
83
+ 422: OdooUserError,
84
+ };
85
+
86
+ /**
87
+ * The single error-mapping choke point for JSON-2. A non-2xx response becomes
88
+ * exactly one tagged error:
89
+ *
90
+ * - `400` / `415` are transport-shaped (malformed request / wrong media type),
91
+ * so they map to {@link OdooTransportError} with sanitized response-error
92
+ * metadata.
93
+ * - Any body with a Python `name` goes through {@link mapServerFault} (shared
94
+ * with the other transports), so `AccessDenied` correctly becomes an auth
95
+ * error and known exception names get their subtype.
96
+ * - Otherwise we fall back on the HTTP status, synthesizing a fault named
97
+ * `http.<status>` so nothing is silently dropped.
98
+ */
99
+ const mapJson2Fault = (
100
+ response: HttpClientResponse.HttpClientResponse,
101
+ request: RequestInfo,
102
+ site: FaultCallSite,
103
+ ): Effect.Effect<never, OdooTransportError | OdooAuthenticationError | OdooServerFault> =>
104
+ Effect.gen(function* () {
105
+ const status = response.status;
106
+
107
+ if (status === 400 || status === 415) {
108
+ // `filterStatusOk` manufactures a response error from the non-ok status
109
+ // without reading the body; the public error retains only safe metadata.
110
+ const cause = yield* Effect.flip(HttpClientResponse.filterStatusOk(response)).pipe(
111
+ Effect.orDie,
112
+ );
113
+ return yield* Effect.fail(OdooTransportError.fromHttpClientError(request, cause));
114
+ }
115
+
116
+ // A non-JSON or shape-drifted error body falls back to an empty exception,
117
+ // which routes to status-based mapping below.
118
+ const exc = yield* response.json.pipe(
119
+ Effect.flatMap(Schema.decodeUnknownEffect(SerializeException)),
120
+ Effect.orElseSucceed(() => emptyException),
121
+ );
122
+ const raw = rawFault(exc, status, site);
123
+
124
+ // GOTCHA: 401 is checked BEFORE the body name. Odoo 19 serializes a bad
125
+ // bearer key as werkzeug.exceptions.Unauthorized — an unmapped name that
126
+ // would otherwise fall through to OdooServerError instead of auth.
127
+ if (status === 401) {
128
+ return yield* Effect.fail(
129
+ new OdooAuthenticationError({
130
+ reason: "api-key-expired-or-invalid",
131
+ message: raw.message || "JSON-2 rejected the API key (HTTP 401).",
132
+ }),
133
+ );
134
+ }
135
+
136
+ if (exc.name !== undefined) {
137
+ return yield* Effect.fail(mapServerFault(raw, site));
138
+ }
139
+
140
+ const Ctor = byStatus[status] ?? OdooServerError;
141
+ return yield* Effect.fail(new Ctor(raw));
142
+ });
143
+
144
+ /**
145
+ * Build a {@link Transport} bound to the JSON-2 route for a given config.
146
+ *
147
+ * GOTCHA: JSON-2 is bearer-only. If the config carries `Password` credentials
148
+ * there is no API key to send, so construction FAILS in the typed channel with
149
+ * {@link OdooAuthenticationError} rather than silently degrading — this is a
150
+ * `Layer` build failure, surfaced eagerly so a misconfigured client never issues
151
+ * an unauthenticated request.
152
+ */
153
+ export const make = (
154
+ config: OdooConfigType,
155
+ ): Effect.Effect<typeof Transport.Service, OdooAuthenticationError, HttpClient.HttpClient> =>
156
+ Effect.gen(function* () {
157
+ if (config.credentials._tag !== "ApiKey") {
158
+ return yield* Effect.fail(
159
+ new OdooAuthenticationError({
160
+ reason: "invalid-credentials",
161
+ message:
162
+ "JSON-2 transport is bearer-only and requires ApiKey credentials; " +
163
+ "got Password. Provide ODOO_API_KEY.",
164
+ }),
165
+ );
166
+ }
167
+
168
+ const apiKey = config.credentials.apiKey;
169
+ const client = yield* HttpClient.HttpClient;
170
+ const base = baseUrl(config.url);
171
+
172
+ const callKw = (params: CallKwParams): Effect.Effect<unknown, TransportCallErrorLocal> => {
173
+ const site: FaultCallSite = { model: params.model, method: params.method };
174
+
175
+ const run = Effect.gen(function* () {
176
+ // JSON-2 cannot carry positional args; reordering silently would corrupt
177
+ // the call, so reject loudly and name the offending method.
178
+ if (params.args.length > 0) {
179
+ return yield* Effect.fail(
180
+ new ProtocolUnsupportedError({
181
+ protocol: "json-2",
182
+ serverVersion: "19+",
183
+ message:
184
+ `JSON-2 is keyword-only: "${params.model}.${params.method}" was called with ` +
185
+ `${params.args.length} positional argument(s); move them into kwargs.`,
186
+ }),
187
+ );
188
+ }
189
+
190
+ const url = `${base}/json/2/${params.model}/${params.method}`;
191
+ const info = requestInfo(url, "POST");
192
+
193
+ // Keyword-only route: the browse target rides as the dedicated `ids` body
194
+ // key (see CallKwParams.ids), never as positional args.
195
+ const body = params.ids ? { ...params.kwargs, ids: params.ids } : params.kwargs;
196
+
197
+ // Redacted is unwrapped ONLY here, at the wire boundary.
198
+ const request = HttpClientRequest.post(url).pipe(
199
+ HttpClientRequest.setHeaders({
200
+ Authorization: `Bearer ${Redacted.value(apiKey)}`,
201
+ "X-Odoo-Database": config.db,
202
+ "Content-Type": "application/json",
203
+ }),
204
+ HttpClientRequest.bodyJsonUnsafe(body),
205
+ );
206
+
207
+ const response = yield* client
208
+ .execute(request)
209
+ .pipe(Effect.mapError(toTransportError(info)));
210
+
211
+ if (response.status >= 200 && response.status < 300) {
212
+ // Raw method result, no envelope; callers decode at their own boundary.
213
+ return yield* response.json.pipe(Effect.mapError(toTransportError(info)));
214
+ }
215
+
216
+ return yield* mapJson2Fault(response, info, site);
217
+ });
218
+
219
+ // Span carries only the coordinates — never args, kwargs, or the API key.
220
+ return run.pipe(
221
+ Effect.withSpan("odoo.json2", {
222
+ attributes: { model: params.model, method: params.method },
223
+ }),
224
+ );
225
+ };
226
+
227
+ return { dialect: "json2" as const, callKw };
228
+ });
229
+
230
+ /** The concrete subset of the transport failure channel JSON-2 produces. */
231
+ type TransportCallErrorLocal =
232
+ | OdooTransportError
233
+ | OdooAuthenticationError
234
+ | ProtocolUnsupportedError
235
+ | OdooServerFault;
236
+
237
+ /** Wire a JSON-2 {@link Transport} from an explicit config. */
238
+ export const layer = (
239
+ config: OdooConfigType,
240
+ ): Layer.Layer<Transport, OdooAuthenticationError, HttpClient.HttpClient> =>
241
+ Layer.effect(Transport, make(config));
242
+
243
+ /** Wire a JSON-2 {@link Transport}, reading {@link OdooConfig} from the environment. */
244
+ export const layerConfig: Layer.Layer<
245
+ Transport,
246
+ OdooAuthenticationError | Config.ConfigError,
247
+ HttpClient.HttpClient
248
+ > = Layer.effect(Transport, Effect.flatMap(OdooConfig, make));
249
+
250
+ /**
251
+ * The `/json/version` probe result: the server's version string and its raw
252
+ * `version_info` tuple. Decoded at the boundary; drift raises
253
+ * {@link SchemaDriftError}.
254
+ */
255
+ export const Json2Version = Schema.Struct({
256
+ version: Schema.String,
257
+ version_info: ServerVersionInfo,
258
+ });
259
+ export type Json2Version = typeof Json2Version.Type;
260
+
261
+ /**
262
+ * Probe `GET {url}/json/version` to detect JSON-2 support.
263
+ *
264
+ * Returns `Option.some(version)` on a 200, and `Option.none()` on a 404 — a 404
265
+ * is the documented "this server predates Odoo 19 (no JSON-2)" signal, not an
266
+ * error. Any other non-2xx is a real {@link OdooTransportError}; a 200 body that
267
+ * fails to decode is a {@link SchemaDriftError}.
268
+ */
269
+ export const probeJson2Version = (
270
+ config: OdooConfigType,
271
+ ): Effect.Effect<
272
+ Option.Option<Json2Version>,
273
+ OdooTransportError | SchemaDriftError,
274
+ HttpClient.HttpClient
275
+ > =>
276
+ Effect.gen(function* () {
277
+ const client = yield* HttpClient.HttpClient;
278
+ const url = `${baseUrl(config.url)}/json/version`;
279
+ const info = requestInfo(url, "GET");
280
+
281
+ const response = yield* client
282
+ .execute(HttpClientRequest.get(url))
283
+ .pipe(Effect.mapError(toTransportError(info)));
284
+
285
+ const status = response.status;
286
+ if (status === 404) {
287
+ return Option.none();
288
+ }
289
+ if (status < 200 || status >= 300) {
290
+ const cause = yield* Effect.flip(HttpClientResponse.filterStatusOk(response)).pipe(
291
+ Effect.orDie,
292
+ );
293
+ return yield* Effect.fail(OdooTransportError.fromHttpClientError(info, cause));
294
+ }
295
+
296
+ const payload = yield* response.json.pipe(Effect.mapError(toTransportError(info)));
297
+ const decoded = yield* Schema.decodeUnknownEffect(Json2Version)(payload).pipe(
298
+ Effect.mapError(
299
+ (cause) => new SchemaDriftError({ context: "odoo.json2.version", payload, cause }),
300
+ ),
301
+ );
302
+ return Option.some(decoded);
303
+ });
@@ -0,0 +1,218 @@
1
+ import { Config, Effect, Layer, Redacted, Ref, Schema } from "effect";
2
+ import { OdooConfig, type OdooCredentials } from "../config.ts";
3
+ import { OdooAuthenticationError } from "../errors/auth.ts";
4
+ import type { FaultCallSite } from "../errors/mapFault.ts";
5
+ import { SchemaDriftError } from "../errors/schema.ts";
6
+ import type { OdooServerFault } from "../errors/server.ts";
7
+ import type { SessionExpiredError } from "../errors/session.ts";
8
+ import { OdooTransportError } from "../errors/transport.ts";
9
+ import { HttpClient, HttpClientRequest } from "../internal/platform.ts";
10
+ import * as SingleFlight from "../internal/singleFlight.ts";
11
+ import { buildRequest, JsonRpcResponse, mapJsonRpcError } from "../protocol/jsonrpc.ts";
12
+ import { type CallKwParams, Transport, type TransportCallError } from "../transport.ts";
13
+ import { CommonVersionResponse } from "../version.ts";
14
+
15
+ /** The by-name params of a `/jsonrpc` call: `{service, method, args}`. */
16
+ interface JsonRpcCallParams {
17
+ readonly service: string;
18
+ readonly method: string;
19
+ readonly args: ReadonlyArray<unknown>;
20
+ }
21
+
22
+ /** The failure channel a single round trip can produce (a subset of `TransportCallError`). */
23
+ type RoundTripError =
24
+ | OdooTransportError
25
+ | SchemaDriftError
26
+ | SessionExpiredError
27
+ | OdooServerFault
28
+ | OdooAuthenticationError;
29
+
30
+ /** Join the configured base URL with the `/jsonrpc` path, tolerating a trailing slash. */
31
+ const jsonRpcEndpoint = (url: URL): string => `${url.href.replace(/\/+$/, "")}/jsonrpc`;
32
+
33
+ const extractCredentials = (
34
+ credentials: OdooCredentials,
35
+ ): {
36
+ readonly username: string;
37
+ readonly secret: Redacted.Redacted<string>;
38
+ readonly isPassword: boolean;
39
+ } =>
40
+ credentials._tag === "ApiKey"
41
+ ? { username: credentials.username, secret: credentials.apiKey, isPassword: false }
42
+ : { username: credentials.username, secret: credentials.password, isPassword: true };
43
+
44
+ /**
45
+ * One JSON-RPC round trip: build the envelope, POST it, decode the response, and
46
+ * either return the raw `result` or map the fault into our union.
47
+ *
48
+ * GOTCHA: never put `args`/`kwargs`/the secret into the span attributes — only
49
+ * the model and method (or service/method) are safe to record.
50
+ */
51
+ const roundTrip = (
52
+ client: HttpClient.HttpClient,
53
+ endpoint: string,
54
+ id: number,
55
+ params: JsonRpcCallParams,
56
+ attributes: Record<string, string>,
57
+ site: FaultCallSite,
58
+ ): Effect.Effect<unknown, RoundTripError> =>
59
+ Effect.gen(function* () {
60
+ // v4 rename: bodyUnsafeJson → bodyJsonUnsafe.
61
+ const request = HttpClientRequest.post(endpoint).pipe(
62
+ HttpClientRequest.setHeader("Content-Type", "application/json"),
63
+ HttpClientRequest.bodyJsonUnsafe(buildRequest(params, id)),
64
+ );
65
+
66
+ // HttpClientError covers both connection failures and a non-JSON body.
67
+ const payload: unknown = yield* client.execute(request).pipe(
68
+ Effect.flatMap((response) => response.json),
69
+ Effect.mapError((cause) =>
70
+ OdooTransportError.fromHttpClientError({ method: "POST", url: endpoint }, cause),
71
+ ),
72
+ );
73
+
74
+ const decoded = yield* Schema.decodeUnknownEffect(JsonRpcResponse)(payload).pipe(
75
+ Effect.mapError(
76
+ (cause) => new SchemaDriftError({ context: "odoo.jsonrpc response", payload, cause }),
77
+ ),
78
+ );
79
+
80
+ if ("error" in decoded) {
81
+ return yield* Effect.fail(mapJsonRpcError(decoded.error, site));
82
+ }
83
+ return decoded.result;
84
+ }).pipe(Effect.withSpan("odoo.jsonrpc", { attributes }));
85
+
86
+ /**
87
+ * Build a `JsonRpcTransport` service value against a fully-resolved config.
88
+ *
89
+ * `uid` is resolved lazily on the first `callKw` via `common.authenticate`, then
90
+ * success-only single-flight cached (a failed authenticate caches nothing, so
91
+ * the next call retries — never `Effect.cached`).
92
+ *
93
+ * Requires an `HttpClient` in context; the caller provides the platform layer.
94
+ */
95
+ export const make = (
96
+ config: OdooConfig,
97
+ ): Effect.Effect<typeof Transport.Service, never, HttpClient.HttpClient> =>
98
+ Effect.gen(function* () {
99
+ const client = yield* HttpClient.HttpClient;
100
+ const idRef = yield* Ref.make(0);
101
+ const endpoint = jsonRpcEndpoint(config.url);
102
+ const { username, secret, isPassword } = extractCredentials(config.credentials);
103
+ const db = config.db;
104
+
105
+ const nextId = Ref.updateAndGet(idRef, (n) => n + 1);
106
+
107
+ const authenticate: Effect.Effect<number, RoundTripError> = Effect.gen(function* () {
108
+ const id = yield* nextId;
109
+ const result = yield* roundTrip(
110
+ client,
111
+ endpoint,
112
+ id,
113
+ {
114
+ service: "common",
115
+ method: "authenticate",
116
+ args: [db, username, Redacted.value(secret), {}],
117
+ },
118
+ {},
119
+ {},
120
+ );
121
+ if (typeof result === "number" && result > 0) {
122
+ return result;
123
+ }
124
+ // authenticate returned `false` (or a null uid): credentials rejected.
125
+ return yield* Effect.fail(
126
+ new OdooAuthenticationError({
127
+ reason: "invalid-credentials",
128
+ message: isPassword
129
+ ? "Odoo rejected the login. If this account has TOTP/2FA enabled, its password cannot be used over RPC — supply an API key as the secret instead."
130
+ : "Odoo rejected the provided credentials.",
131
+ }),
132
+ );
133
+ });
134
+
135
+ const uid = yield* SingleFlight.make(authenticate);
136
+
137
+ const callKw = (params: CallKwParams): Effect.Effect<unknown, TransportCallError> =>
138
+ Effect.gen(function* () {
139
+ const resolvedUid = yield* uid.get;
140
+ const id = yield* nextId;
141
+ // execute_kw-family route: an explicit `ids` browse target rides as the
142
+ // first positional argument (see CallKwParams.ids).
143
+ const args = params.ids ? [params.ids, ...params.args] : params.args;
144
+ return yield* roundTrip(
145
+ client,
146
+ endpoint,
147
+ id,
148
+ {
149
+ service: "object",
150
+ method: "execute_kw",
151
+ args: [
152
+ db,
153
+ resolvedUid,
154
+ Redacted.value(secret),
155
+ params.model,
156
+ params.method,
157
+ args,
158
+ params.kwargs,
159
+ ],
160
+ },
161
+ { model: params.model, method: params.method },
162
+ { model: params.model, method: params.method },
163
+ );
164
+ }).pipe(
165
+ // Self-healing: an auth fault means the cached uid/credential pairing is
166
+ // no longer valid server-side (key revoked-then-restored, credentials
167
+ // out of sync, ...). Drop the cache so the next call re-authenticates
168
+ // instead of staying wedged until the layer is rebuilt — symmetric with
169
+ // CookieSession.invalidate.
170
+ Effect.tapError((error) =>
171
+ error._tag === "OdooAuthenticationError" ? uid.invalidate : Effect.void,
172
+ ),
173
+ );
174
+
175
+ return { dialect: "execute-kw" as const, callKw };
176
+ });
177
+
178
+ /**
179
+ * `JsonRpcTransport` as a `Layer`, wired against an explicit config. Never
180
+ * fails to build (`E = never`); the config is already resolved. Requires an
181
+ * `HttpClient` from the platform layer the consumer provides.
182
+ */
183
+ export const layer = (config: OdooConfig): Layer.Layer<Transport, never, HttpClient.HttpClient> =>
184
+ Layer.effect(Transport, make(config));
185
+
186
+ /**
187
+ * `JsonRpcTransport` as a `Layer` that reads {@link OdooConfig} from the ambient
188
+ * `ConfigProvider`. Fails with `ConfigError` if required values are missing.
189
+ */
190
+ export const layerConfig: Layer.Layer<Transport, Config.ConfigError, HttpClient.HttpClient> =
191
+ Layer.effect(Transport, Effect.flatMap(OdooConfig, make));
192
+
193
+ /**
194
+ * The `common.version` probe, decoded into {@link CommonVersionResponse}. Shared
195
+ * with the `VersionResolver` implementation so version resolution and the
196
+ * transport agree on one wire path. Requires an `HttpClient` in context.
197
+ */
198
+ export const makeVersion = (
199
+ config: OdooConfig,
200
+ ): Effect.Effect<CommonVersionResponse, RoundTripError, HttpClient.HttpClient> =>
201
+ Effect.gen(function* () {
202
+ const client = yield* HttpClient.HttpClient;
203
+ const endpoint = jsonRpcEndpoint(config.url);
204
+ const raw = yield* roundTrip(
205
+ client,
206
+ endpoint,
207
+ 1,
208
+ { service: "common", method: "version", args: [] },
209
+ {},
210
+ {},
211
+ );
212
+ return yield* Schema.decodeUnknownEffect(CommonVersionResponse)(raw).pipe(
213
+ Effect.mapError(
214
+ (cause) =>
215
+ new SchemaDriftError({ context: "odoo.jsonrpc common.version", payload: raw, cause }),
216
+ ),
217
+ );
218
+ });