Skip to content

Commit ec3f159

Browse files
authored
Merge pull request #68 from StyraInc/philip/batch-query-helpers
Styra/Opa: Add evaluateBatch<T> support.
2 parents 4c39878 + dacc638 commit ec3f159

File tree

4 files changed

+366
-2
lines changed

4 files changed

+366
-2
lines changed

Styra/Opa/OpaBatchTypes.cs

+18
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ public override string ToString()
2020
}
2121
}
2222

23+
public class OpaBatchResultGeneric<T> : Dictionary<string, T>
24+
{
25+
public override string ToString()
26+
{
27+
return JsonConvert.SerializeObject(this);
28+
}
29+
}
30+
2331
public class OpaBatchErrors : Dictionary<string, OpaError>
2432
{
2533
public override string ToString()
@@ -73,4 +81,14 @@ public static OpaBatchResults ToOpaBatchResults(this Dictionary<string, Successf
7381
}
7482
return opaBatchResults;
7583
}
84+
85+
public static OpaBatchResultGeneric<T> ToOpaBatchResults<T>(this Dictionary<string, SuccessfulPolicyResponse> responses)
86+
{
87+
var output = new OpaBatchResultGeneric<T>();
88+
foreach (var kvp in responses)
89+
{
90+
output[kvp.Key] = OpaClient.convertResult<T>(kvp.Value.Result!);
91+
}
92+
return output;
93+
}
7694
}

Styra/Opa/OpaClient.cs

+132-1
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,137 @@ private async Task<T> queryMachineryDefault<T>(Input input)
489489
throw new Exception("Impossible error");
490490
}
491491

492+
/// <summary>
493+
/// Evaluate a policy, using the provided map of query inputs. Results will
494+
/// be returned in an identically-structured pair of maps, one for
495+
/// successful evals, and one for errors. In the event that the OPA server
496+
/// does not support the /v1/batch/data endpoint, this method will fall back
497+
/// to performing sequential queries against the OPA server.
498+
/// </summary>
499+
/// <param name="path">The rule to evaluate. (Example: "app/rbac")</param>
500+
/// <param name="inputs">The input Dictionary OPA will use for evaluating the rule. The keys are arbitrary ID strings, the values are the input values intended for each query.</param>
501+
/// <returns>A pair of mappings, between string keys, and generic type T, or ServerErrors.</returns>
502+
public async Task<(OpaBatchResultGeneric<T>, OpaBatchErrors)> evaluateBatch<T>(string path, Dictionary<string, Dictionary<string, object>> inputs)
503+
{
504+
return await queryMachineryBatch<T>(path, inputs);
505+
}
506+
507+
/// <exclude />
508+
private async Task<(OpaBatchResultGeneric<T>, OpaBatchErrors)> queryMachineryBatch<T>(string path, Dictionary<string, Dictionary<string, object>> inputs)
509+
{
510+
OpaBatchResultGeneric<T> successResults = new();
511+
OpaBatchErrors failureResults = new();
512+
513+
// Attempt using the /v1/batch/data endpoint. If we ever receive a 404, then it's a vanilla OPA instance, and we should skip straight to fallback mode.
514+
if (opaSupportsBatchQueryAPI)
515+
{
516+
var req = new ExecuteBatchPolicyWithInputRequest()
517+
{
518+
Path = path,
519+
RequestBody = new ExecuteBatchPolicyWithInputRequestBody()
520+
{
521+
Inputs = inputs.ToOpaBatchInputRaw(),
522+
},
523+
Pretty = requestPretty,
524+
Provenance = requestProvenance,
525+
Explain = requestExplain,
526+
Metrics = requestMetrics,
527+
Instrument = requestInstrument,
528+
StrictBuiltinErrors = requestStrictBuiltinErrors,
529+
};
530+
531+
// Launch query. The all-errors case is handled in the exception handler block.
532+
ExecuteBatchPolicyWithInputResponse res;
533+
try
534+
{
535+
res = await opa.ExecuteBatchPolicyWithInputAsync(req);
536+
switch (res.StatusCode)
537+
{
538+
// All-success case.
539+
case 200:
540+
successResults = res.BatchSuccessfulPolicyEvaluation!.Responses!.ToOpaBatchResults<T>(); // Should not be null here.
541+
return (successResults, failureResults);
542+
// Mixed results case.
543+
case 207:
544+
var mixedResponses = res.BatchMixedResults?.Responses!; // Should not be null here.
545+
foreach (var (key, value) in mixedResponses)
546+
{
547+
switch (value.Type.ToString())
548+
{
549+
case "200":
550+
successResults.Add(key, convertResult<T>(value.SuccessfulPolicyResponseWithStatusCode!.Result!));
551+
break;
552+
case "500":
553+
failureResults.Add(key, (OpaError)value.ServerErrorWithStatusCode!); // Should not be null.
554+
break;
555+
}
556+
}
557+
558+
return (successResults, failureResults);
559+
default:
560+
// TODO: Throw exception if we reach the end of this block without a successful return.
561+
// This *should* never happen. It means we didn't return from the batch or fallback handler blocks earlier.
562+
throw new Exception("Impossible error");
563+
}
564+
}
565+
catch (ClientError ce)
566+
{
567+
throw ce; // Rethrow for the caller to deal with. Request was malformed.
568+
}
569+
catch (BatchServerError bse)
570+
{
571+
failureResults = bse.Responses!.ToOpaBatchErrors(); // Should not be null here.
572+
return (successResults, failureResults);
573+
}
574+
catch (SDKException se) when (se.StatusCode == 404)
575+
{
576+
// We know we've got an issue now.
577+
opaSupportsBatchQueryAPI = false;
578+
LogMessages.LogBatchQueryFallback(_logger);
579+
// Fall-through to the "unsupported" case.
580+
}
581+
}
582+
// Implicitly rethrow all other exceptions.
583+
584+
// Fall back to sequential queries against the OPA instance.
585+
if (!opaSupportsBatchQueryAPI)
586+
{
587+
foreach (var (key, value) in inputs)
588+
{
589+
try
590+
{
591+
var res = await evalPolicySingle(path, Input.CreateMapOfAny(value));
592+
successResults.Add(key, convertResult<T>(res.SuccessfulPolicyResponse!.Result!));
593+
}
594+
catch (ClientError ce)
595+
{
596+
throw ce; // Rethrow for the caller to deal with. Request was malformed.
597+
}
598+
catch (Styra.Opa.OpenApi.Models.Errors.ServerError se)
599+
{
600+
failureResults.Add(key, (OpaError)se);
601+
}
602+
// Implicitly rethrow all other exceptions.
603+
}
604+
605+
// If we have the mixed case, add the HttpStatusCode fields.
606+
if (successResults.Count > 0 && failureResults.Count > 0)
607+
{
608+
// Modifying the dictionary element while iterating is a language feature since 2020, apparently.
609+
// Ref: https://github.com/dotnet/runtime/pull/34667
610+
foreach (var key in failureResults.Keys)
611+
{
612+
failureResults[key].HttpStatusCode = "500";
613+
}
614+
}
615+
616+
return (successResults, failureResults);
617+
}
618+
619+
// This *should* never happen. It means we didn't return from the batch or fallback handler blocks earlier.
620+
throw new Exception("Impossible error");
621+
}
622+
492623
/// <exclude />
493624
private async Task<ExecutePolicyWithInputResponse> evalPolicySingle(string path, Input input)
494625
{
@@ -512,7 +643,7 @@ private async Task<ExecutePolicyWithInputResponse> evalPolicySingle(string path,
512643

513644
/// <exclude />
514645
// Designed to respect the nullability of the incoming generic type when possible.
515-
private static T convertResult<T>(Result resultValue)
646+
protected internal static T convertResult<T>(Result resultValue)
516647
{
517648
// We check to see if T maps to any of the core JSON types.
518649
// We do the type-switch here, so that high-level clients don't have to.

Styra/Opa/Styra.Opa.csproj

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<PropertyGroup>
33
<IsPackable>true</IsPackable>
44
<PackageId>Styra.Opa</PackageId>
5-
<Version>1.3.3</Version>
5+
<Version>1.3.4</Version>
66
<Authors>Styra</Authors>
77
<TargetFramework>net6.0</TargetFramework>
88
<Nullable>enable</Nullable>

0 commit comments

Comments
 (0)