Skip to content
This repository was archived by the owner on Jul 1, 2024. It is now read-only.

Commit 1f26ab4

Browse files
committed
sdk: add evaluateBatch()
options for that method: - rejectMixed (default false) -- if a mixed result (ie. at least one element of the batch failed evaluation) causes a promise rejection - fallback (default false) -- if a 404 for the batch endpoint should degrade transparently into a sequence of single evaluations. This would be the case when talking to OPA (not Enterprise OPA). Signed-off-by: Stephan Renatus <stephan@styra.com>
1 parent dd1640d commit 1f26ab4

File tree

5 files changed

+749
-202
lines changed

5 files changed

+749
-202
lines changed

.github/workflows/pull_request.yaml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,24 @@ jobs:
1616
- uses: actions/setup-node@v4
1717
with:
1818
node-version: 21
19-
- name: Compile and Test
19+
- name: Compile, Lint
2020
run: |
2121
npm ci
2222
npx eslint --config=.eslintrc.styra.cjs --max-warnings=0 src README.md
2323
npx prettier . --check
2424
npx typedoc
25-
node --import tsx --test tests/**/*.ts
25+
- name: Test
26+
run: |
27+
node --import tsx \
28+
--test-reporter=spec \
29+
--test-reporter=junit \
30+
--test-reporter-destination=stdout \
31+
--test-reporter-destination=report.xml \
32+
--test tests/**/*.ts
33+
env:
34+
EOPA_LICENSE_KEY: ${{ secrets.EOPA_LICENSE_KEY }}
35+
- name: Publish Test Report
36+
uses: mikepenz/action-junit-report@v4
37+
if: always()
38+
with:
39+
report_paths: report.xml

DEVELOPMENT.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,9 @@ and with testcontainers-node's debug logging:
3636
```shell
3737
DEBUG='testcontainers*' node --import tsx --test tests/**/*.ts
3838
```
39+
40+
Single out a test case by name:
41+
42+
```shell
43+
node --import tsx --test-name-pattern="can be called with input==false" --test tests/**/*.ts
44+
```

src/hooks/request-path-hook.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ export class RewriteRequestPathHook implements BeforeCreateRequestHook {
77
input: RequestInput,
88
): RequestInput {
99
const url = new URL(input.url);
10-
if (url.pathname.indexOf("/v1/data/") != -1) {
10+
if (
11+
url.pathname.indexOf("/v1/data/") != -1 ||
12+
url.pathname.indexOf("/v1/batch/data/") != -1
13+
) {
1114
url.pathname = decodeURIComponent(url.pathname);
1215
return { ...input, url };
1316
}

src/opaclient.ts

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import { OpaApiClient as Opa } from "./sdk/index.js";
2-
import type { Input, Result } from "./sdk/models/components/index.js";
2+
import {
3+
type Input,
4+
type Result,
5+
type ResponsesSuccessfulPolicyResponse,
6+
type ServerError,
7+
BatchMixedResults,
8+
BatchSuccessfulPolicyEvaluation,
9+
SuccessfulPolicyResponse,
10+
} from "./sdk/models/components/index.js";
311
import {
412
ExecutePolicyWithInputResponse,
513
ExecutePolicyResponse,
614
} from "./sdk/models/operations/index.js";
15+
import { SDKError } from "./sdk/models/errors/sdkerror.js";
16+
import { ServerError as ServerError_ } from "./sdk/models/errors/servererror.js";
717
import { SDKOptions } from "./lib/config.js";
818
import { HTTPClient } from "./lib/http.js";
919
import { RequestOptions as FetchOptions } from "./lib/sdks.js";
@@ -37,12 +47,21 @@ export interface RequestOptions<Res> extends FetchOptions {
3747
fromResult?: (res?: Result) => Res;
3848
}
3949

50+
/** Extra per-request options for using the high-level SDK's
51+
* evaluateBatch method.
52+
*/
53+
export interface BatchRequestOptions<Res> extends RequestOptions<Res> {
54+
rejectMixed?: boolean; // reject promise if the batch result is "mixed", i.e. if any of the items errored
55+
fallback?: boolean; // fall back to sequential evaluate calls if server doesn't support batch API
56+
}
57+
4058
/** OPAClient is the starting point for using the high-level API.
4159
*
4260
* Use {@link Opa} if you need some low-level customization.
4361
*/
4462
export class OPAClient {
4563
private opa: Opa;
64+
private opaFallback: boolean = false;
4665

4766
/** Create a new `OPA` instance.
4867
* @param serverURL - The OPA URL, e.g. `https://opa.internal.corp:8443/`.
@@ -96,9 +115,10 @@ export class OPAClient {
96115
);
97116
}
98117
if (!result.successfulPolicyResponse) throw `no result in API response`;
118+
99119
const res = result.successfulPolicyResponse.result;
100-
const fromResult = opts?.fromResult;
101-
return fromResult ? fromResult(res) : (res as Res);
120+
const fromResult = opts?.fromResult || id<Res>;
121+
return fromResult(res);
102122
}
103123

104124
/** `evaluateDefault` is used to evaluate the server's default policy with optional input.
@@ -122,8 +142,126 @@ export class OPAClient {
122142
opts,
123143
);
124144
if (!resp.result) throw `no result in API response`;
125-
const res = resp.result;
126-
const fromResult = opts?.fromResult;
127-
return fromResult ? fromResult(res) : (res as Res);
145+
146+
const fromResult = opts?.fromResult || id<Res>;
147+
return fromResult(resp.result);
148+
}
149+
150+
/** `evaluateBatch` is used to evaluate the policy at the specified path, for a batch of many inputs.
151+
*
152+
* @param path - The path to the policy, without `/v1/batch/data`: use `authz/allow` to evaluate policy `data.authz.allow`.
153+
* @param inputs - The inputs to the policy.
154+
* @param opts - Per-request options to control how the policy evaluation result is to be transformed
155+
* into `Res` (via `fromResult`), if any failures in the batch result should reject the promose (via
156+
* `rejectMixed`), and low-level fetch options.
157+
*/
158+
async evaluateBatch<In extends Input | ToInput, Res>(
159+
path: string,
160+
inputs: { [k: string]: In },
161+
opts?: BatchRequestOptions<Res>,
162+
): Promise<{ [k: string]: Res | ServerError }> {
163+
const inps = Object.fromEntries(
164+
Object.entries(inputs).map(([k, inp]) => [
165+
k,
166+
implementsToInput(inp) ? inp.toInput() : inp,
167+
]),
168+
);
169+
let res: BatchMixedResults | BatchSuccessfulPolicyEvaluation | undefined;
170+
171+
if (this.opaFallback && opts?.fallback) {
172+
// memoized fallback: we have hit a 404 here before
173+
const responses = await this.fallbackBatch(path, inps, opts);
174+
res = { responses };
175+
} else {
176+
try {
177+
const resp = await this.opa.executeBatchPolicyWithInput(
178+
{ path, requestBody: { inputs: inps } },
179+
opts,
180+
);
181+
182+
res = resp.batchMixedResults || resp.batchSuccessfulPolicyEvaluation;
183+
} catch (err) {
184+
if (
185+
err instanceof SDKError &&
186+
err.httpMeta.response.status == 404 &&
187+
opts?.fallback
188+
) {
189+
this.opaFallback = true;
190+
const responses = await this.fallbackBatch(path, inps, opts);
191+
res = { responses };
192+
} else {
193+
throw err;
194+
}
195+
}
196+
}
197+
198+
if (!res) throw `no result in API response`;
199+
200+
const entries = [];
201+
for (const [k, v] of Object.entries(res?.responses ?? {})) {
202+
entries.push([k, await processResult(v, opts)]);
203+
}
204+
return Object.fromEntries(entries);
128205
}
206+
207+
// run a sequence of evaluatePolicyWithInput(), via Promise.all/Promise.allSettled
208+
async fallbackBatch<Res>(
209+
path: string,
210+
inputs: { [k: string]: Input },
211+
opts?: BatchRequestOptions<Res>,
212+
): Promise<{ [k: string]: ServerError | SuccessfulPolicyResponse }> {
213+
let items: [string, ServerError | SuccessfulPolicyResponse][];
214+
const keys = Object.keys(inputs);
215+
const ps = Object.values(inputs).map((input) =>
216+
this.opa
217+
.executePolicyWithInput({ path, requestBody: { input } })
218+
.then(({ successfulPolicyResponse: res }) => res),
219+
);
220+
if (opts?.rejectMixed) {
221+
items = await Promise.all(ps).then((results) =>
222+
results.map((result, i) => {
223+
if (!result) throw `no result in API response`;
224+
return [
225+
keys[i] as string, // can't be undefined
226+
result,
227+
];
228+
}),
229+
);
230+
} else {
231+
const settled = await Promise.allSettled(ps).then((results) => {
232+
return results.map((res, i) => {
233+
if (res.status === "rejected") {
234+
return [
235+
keys[i],
236+
{
237+
...(res.reason as ServerError_).data$,
238+
httpStatusCode: "500",
239+
},
240+
] as [string, ServerError];
241+
}
242+
return [keys[i], res.value] as [string, SuccessfulPolicyResponse];
243+
});
244+
});
245+
items = settled;
246+
}
247+
return Object.fromEntries(items);
248+
}
249+
}
250+
251+
function processResult<Res>(
252+
res: ResponsesSuccessfulPolicyResponse | ServerError,
253+
opts?: BatchRequestOptions<Res>,
254+
): Promise<Res | ServerError> {
255+
if (res && "code" in res) {
256+
if (opts?.rejectMixed) return Promise.reject(res as ServerError);
257+
258+
return Promise.resolve(res as ServerError);
259+
}
260+
261+
const fromResult = opts?.fromResult || id<Res>;
262+
return Promise.resolve(fromResult(res.result));
263+
}
264+
265+
function id<T>(x: any): T {
266+
return x as T;
129267
}

0 commit comments

Comments
 (0)