diff --git a/packages/opa-react/src/authz-provider.tsx b/packages/opa-react/src/authz-provider.tsx
index 9e453d20..bbbacfc3 100644
--- a/packages/opa-react/src/authz-provider.tsx
+++ b/packages/opa-react/src/authz-provider.tsx
@@ -22,8 +22,21 @@ type EvalQuery = {
fromResult: ((_?: Result) => boolean) | undefined;
};
-function key({ path, input }: EvalQuery): string {
- return stringify({ path, input }); // Note: omit fromResult
+const table = new WeakMap();
+
+// key is used to index the individual Batch API batches when splitting
+// an incoming batch of EvalQuery. The same `x` is later passed to the
+// batch item resolver in the results object. We use the WeakMap to
+// ensure that the same `x` gets the same number.
+function key(x: EvalQuery): string {
+ const r = table.get(x);
+ if (r) return r;
+
+ const num = new Uint32Array(1);
+ crypto.getRandomValues(num);
+ const rand = num.toString();
+ table.set(x, rand);
+ return rand;
}
const evals = (sdk: OPAClient) =>
@@ -133,7 +146,7 @@ export default function AuthzProvider({
defaultPath,
defaultInput,
defaultFromResult,
- retry = 0, // Debugging
+ retry = 0,
batch = false,
}: AuthzProviderProps) {
const batcher = useMemo(
@@ -201,53 +214,3 @@ export default function AuthzProvider({
{children}
);
}
-
-// Taken from fast-json-stable-hash, MIT-licensed:
-// https://github.com/zkldi/fast-json-stable-hash/blob/31b3081e942c1ce491f9698fd0bf527847093036/index.js
-// That module was tricky to import because it's using `crypto` for hashing.
-// We only need a stable string.
-function stringify(obj: any) {
- const type = typeof obj;
- if (obj === undefined) return "_";
-
- if (type === "string") {
- return JSON.stringify(obj);
- } else if (Array.isArray(obj)) {
- let str = "[";
-
- let al = obj.length - 1;
-
- for (let i = 0; i < obj.length; i++) {
- str += stringify(obj[i]);
-
- if (i !== al) {
- str += ",";
- }
- }
-
- return `${str}]`;
- } else if (type === "object" && obj !== null) {
- let str = "{";
- let keys = Object.keys(obj).sort();
-
- let kl = keys.length - 1;
-
- for (let i = 0; i < keys.length; i++) {
- let key = keys[i];
- str += `${JSON.stringify(key)}:${stringify(obj[key])}`;
-
- if (i !== kl) {
- str += ",";
- }
- }
-
- return `${str}}`;
- } else if (type === "number" || type === "boolean" || obj === null) {
- // bool, num, null have correct auto-coercions
- return `${obj}`;
- } else {
- throw new TypeError(
- `Invalid JSON type of ${type}, value ${obj}. Can only hash JSON objects.`,
- );
- }
-}
diff --git a/packages/opa-react/src/use-authz.ts b/packages/opa-react/src/use-authz.ts
index eeb33e89..d7077d47 100644
--- a/packages/opa-react/src/use-authz.ts
+++ b/packages/opa-react/src/use-authz.ts
@@ -1,4 +1,4 @@
-import { useContext } from "react";
+import { useContext, useMemo } from "react";
import { AuthzContext } from "./authz-provider.js";
import { type Input, type Result } from "@styra/opa";
import merge from "lodash.merge";
@@ -33,9 +33,15 @@ export default function useAuthz(
queryClient,
opaClient,
} = context;
- const p = path ?? defaultPath;
- const i = mergeInput(input, defaultInput);
- const fromR = fromResult ?? defaultFromResult;
+
+ const queryKey = useMemo(
+ () => [path ?? defaultPath, mergeInput(input, defaultInput)],
+ [path, defaultPath, input, defaultInput],
+ );
+ const meta = useMemo(
+ () => ({ fromResult: fromResult ?? defaultFromResult }),
+ [fromResult, defaultFromResult],
+ );
const {
// NOTE(sr): we're ignoring 'status'
@@ -44,8 +50,8 @@ export default function useAuthz(
isFetching: isLoading,
} = useQuery(
{
- queryKey: [p, i],
- meta: { fromResult: fromR },
+ queryKey,
+ meta,
enabled: !!opaClient,
},
queryClient,
diff --git a/packages/opa-react/tests/use-authz.test.tsx b/packages/opa-react/tests/use-authz.test.tsx
index b341b024..46b901df 100644
--- a/packages/opa-react/tests/use-authz.test.tsx
+++ b/packages/opa-react/tests/use-authz.test.tsx
@@ -313,10 +313,13 @@ describe("useAuthz Hook", () => {
const batch = true;
it("works without input, without fromResult", async () => {
- const hash = '{"input":_,"path":"path/allow"}';
+ let hash: string;
const evaluateSpy = vi
.spyOn(opa, "evaluateBatch")
- .mockResolvedValue({ [hash]: false });
+ .mockImplementationOnce((_path, inputs, _opts) => {
+ [hash] = Object.keys(inputs);
+ return Promise.resolve({ [hash]: false });
+ });
const { result } = renderHook(
() => useAuthz("path/allow"),
@@ -337,13 +340,16 @@ describe("useAuthz Hook", () => {
});
it("works without input, with fromResult", async () => {
- const hash = '{"input":_,"path":"path/allow"}';
+ let hash: string;
const evaluateSpy = vi
.spyOn(opa, "evaluateBatch")
- .mockResolvedValue({ [hash]: { foo: false } });
+ .mockImplementationOnce((_path, inputs, _opts) => {
+ [hash] = Object.keys(inputs);
+ return Promise.resolve({ [hash]: { foo: false } });
+ });
const { result } = renderHook(
- () => useAuthz("path/allow", undefined, (x) => (x as any).foo),
+ () => useAuthz("path/allow", undefined, (x?: Result) => (x as any).foo),
wrapper({ batch }),
);
await waitFor(() =>
@@ -361,7 +367,6 @@ describe("useAuthz Hook", () => {
});
it("rejects evals without path", async () => {
- const hash = '{"input":_,"path":"path/allow"}';
const evaluateSpy = vi.spyOn(opa, "evaluateBatch");
const { result } = renderHook(
@@ -379,13 +384,16 @@ describe("useAuthz Hook", () => {
});
it("works with input, with fromResult", async () => {
- const hash = '{"input":"foo","path":"path/allow"}';
+ let hash: string;
const evaluateSpy = vi
.spyOn(opa, "evaluateBatch")
- .mockResolvedValue({ [hash]: { foo: false } });
+ .mockImplementationOnce((_path, inputs, _opts) => {
+ [hash] = Object.keys(inputs);
+ return Promise.resolve({ [hash]: { foo: false } });
+ });
const { result } = renderHook(
- () => useAuthz("path/allow", "foo", (x) => (x as any).foo),
+ () => useAuthz("path/allow", "foo", (x?: Result) => (x as any).foo),
wrapper({ batch }),
);
await waitFor(() =>
@@ -403,11 +411,23 @@ describe("useAuthz Hook", () => {
});
it("batches multiple requests with different inputs", async () => {
- const hash1 = '{"input":"foo","path":"path/allow"}';
- const hash2 = '{"input":"bar","path":"path/allow"}';
+ let hash1: string;
+ let hash2: string;
const evaluateSpy = vi
.spyOn(opa, "evaluateBatch")
- .mockResolvedValue({ [hash1]: false, [hash2]: true });
+ .mockImplementationOnce((_path, inputs, _opts) => {
+ const res = Object.fromEntries(
+ Object.entries(inputs).map(([k, inp]) => {
+ if (inp === "foo") {
+ hash1 = k;
+ } else {
+ hash2 = k;
+ }
+ return [k, inp != "foo"];
+ }),
+ );
+ return Promise.resolve({ [hash1]: false, [hash2]: true });
+ });
const { result } = renderHook(() => {
return {
@@ -439,17 +459,23 @@ describe("useAuthz Hook", () => {
});
it("batches multiple requests with different paths+fromResults, too", async () => {
- const hash1 = '{"input":"foo","path":"path/foo"}';
- const hash2 = '{"input":"bar","path":"path/bar"}';
+ let hash1: string;
+ let hash2: string;
const evaluateSpy = vi
.spyOn(opa, "evaluateBatch")
- .mockResolvedValueOnce({ [hash1]: { a: false } })
- .mockResolvedValueOnce({ [hash2]: { b: true } });
+ .mockImplementationOnce((_path, inputs, _opts) => {
+ [hash1] = Object.keys(inputs);
+ return Promise.resolve({ [hash1]: { a: false } });
+ })
+ .mockImplementationOnce((_path, inputs, _opts) => {
+ [hash2] = Object.keys(inputs);
+ return Promise.resolve({ [hash2]: { b: true } });
+ });
const { result } = renderHook(() => {
return {
- first: useAuthz("path/foo", "foo", (x) => (x as any).a),
- second: useAuthz("path/bar", "bar", (x) => (x as any).b),
+ first: useAuthz("path/foo", "foo", (x?: Result) => (x as any).a),
+ second: useAuthz("path/bar", "bar", (x?: Result) => (x as any).b),
};
}, wrapper({ batch }));
@@ -481,13 +507,16 @@ describe("useAuthz Hook", () => {
});
it("coalesces multiple requests with the same path input (disregarding fromResult)", async () => {
- const hash = '{"input":"foo","path":"path/allow"}';
+ let hash: string;
// NOTE(sr): Unlike the non-batching case, we're handling the application of `fromResult`
// in the code of opa-react. So the functions that are passed are evaluated on the mocked
// result returned here.
const evaluateSpy = vi
.spyOn(opa, "evaluateBatch")
- .mockResolvedValue({ [hash]: { foo: false } });
+ .mockImplementationOnce((_path, inputs, _opts) => {
+ [hash] = Object.keys(inputs);
+ return Promise.resolve({ [hash]: { foo: false } });
+ });
const { result } = renderHook(() => {
return {