responseType) {
55 | return this.opaQueryApi.queryForDocument(queryForDocumentRequest, responseType);
56 | }
57 |
58 | /**
59 | * @see com.bisnode.opa.client.data.OpaDataApi
60 | */
61 | public void createOrOverwriteDocument(OpaDocument document) {
62 | this.opaDataApi.createOrOverwriteDocument(document);
63 | }
64 |
65 | /**
66 | * @see com.bisnode.opa.client.policy.OpaPolicyApi
67 | */
68 | public void createOrUpdatePolicy(OpaPolicy policy) {
69 | this.opaPolicyApi.createOrUpdatePolicy(policy);
70 | }
71 |
72 | /**
73 | * Builder for {@link OpaClient}
74 | */
75 | public static class Builder {
76 | private OpaConfiguration opaConfiguration;
77 | private ObjectMapper objectMapper;
78 |
79 | /**
80 | * @param url URL including protocol and port
81 | */
82 | public Builder opaConfiguration(String url) {
83 | this.opaConfiguration = new OpaConfiguration(url);
84 | return this;
85 | }
86 |
87 | public Builder objectMapper(ObjectMapper objectMapper) {
88 | this.objectMapper = objectMapper;
89 | return this;
90 | }
91 |
92 | public OpaClient build() {
93 | Objects.requireNonNull(opaConfiguration, "build() called without opaConfiguration provided");
94 | HttpClient httpClient = HttpClient.newBuilder()
95 | .version(opaConfiguration.getHttpVersion())
96 | .build();
97 | ObjectMapper objectMapper = Optional.ofNullable(this.objectMapper)
98 | .orElseGet(ObjectMapperFactory.getInstance()::create);
99 | OpaRestClient opaRestClient = new OpaRestClient(opaConfiguration, httpClient, objectMapper);
100 | return new OpaClient(new OpaQueryClient(opaRestClient), new OpaDataClient(opaRestClient), new OpaPolicyClient(opaRestClient));
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/OpaClientException.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client;
2 |
3 | /**
4 | * Exception returned by {@link OpaClient}
5 | * All exceptions that will be thrown inside {@link OpaClient} will be mapped to this one
6 | */
7 | public class OpaClientException extends RuntimeException {
8 | public OpaClientException() {
9 | super();
10 | }
11 |
12 | public OpaClientException(String message) {
13 | super(message);
14 | }
15 |
16 | public OpaClientException(String message, Throwable cause) {
17 | super(message, cause);
18 | }
19 |
20 | public OpaClientException(Throwable cause) {
21 | super(cause);
22 | }
23 |
24 | protected OpaClientException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
25 | super(message, cause, enableSuppression, writableStackTrace);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/OpaConfiguration.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client;
2 |
3 | import java.beans.ConstructorProperties;
4 | import java.net.URI;
5 | import java.net.http.HttpClient;
6 | import java.util.Objects;
7 |
8 | /**
9 | * Contains all configuration needed to set up {@link OpaClient}
10 | */
11 | public final class OpaConfiguration {
12 | private final String url;
13 | private final HttpClient.Version httpVersion;
14 |
15 | /**
16 | * @param url base URL to OPA server, containing protocol, and port (eg. http://localhost:8181)
17 | */
18 | @ConstructorProperties({"url"})
19 | public OpaConfiguration(String url) {
20 | this.url = url;
21 | this.httpVersion = "https".equals(URI.create(url).getScheme()) ?
22 | HttpClient.Version.HTTP_2 :
23 | HttpClient.Version.HTTP_1_1;
24 | }
25 |
26 | /**
27 | * @param url base URL to OPA server, containing protocol, and port (eg. http://localhost:8181)
28 | * @param httpVersion preferred HTTP version to use for the client
29 | */
30 | @ConstructorProperties({"url", "httpVersion"})
31 | public OpaConfiguration(String url, HttpClient.Version httpVersion) {
32 | this.url = url;
33 | this.httpVersion = httpVersion;
34 | }
35 |
36 | /**
37 | * @return url base URL to OPA server, containing protocol, and port
38 | */
39 | public String getUrl() {
40 | return this.url;
41 | }
42 |
43 | /**
44 | * Get HTTP version configured for the client. If not configured will use HTTP2 for "https" scheme
45 | * and HTTP1.1 for "http" scheme.
46 | *
47 | * @return httpVersion configured for use by the client
48 | */
49 | public HttpClient.Version getHttpVersion() {
50 | return this.httpVersion;
51 | }
52 |
53 | @Override
54 | public boolean equals(Object o) {
55 | if (this == o) return true;
56 | if (o == null || getClass() != o.getClass()) return false;
57 | OpaConfiguration that = (OpaConfiguration) o;
58 | return Objects.equals(url, that.url);
59 | }
60 |
61 | @Override
62 | public int hashCode() {
63 | return Objects.hash(url);
64 | }
65 |
66 | @Override
67 | public String toString() {
68 | return "OpaConfiguration{" +
69 | "url='" + url + '\'' +
70 | '}';
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/data/OpaDataApi.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.data;
2 |
3 | /**
4 | * This is the interface responsible for OPA Data Api, please see OPA Data API docs
5 | */
6 | public interface OpaDataApi {
7 | /**
8 | * Updates or creates new OPA document
9 | *
10 | *
11 | * @param document document to be created/updated
12 | * @since 0.0.1
13 | */
14 | void createOrOverwriteDocument(OpaDocument document);
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/data/OpaDataClient.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.data;
2 |
3 | import com.bisnode.opa.client.OpaClientException;
4 | import com.bisnode.opa.client.rest.ContentType;
5 | import com.bisnode.opa.client.rest.OpaRestClient;
6 |
7 | import java.net.http.HttpRequest;
8 | import java.net.http.HttpResponse;
9 |
10 | /**
11 | * @see com.bisnode.opa.client.data.OpaDataApi
12 | */
13 | public class OpaDataClient implements OpaDataApi {
14 | private static final String DATA_ENDPOINT = "/v1/data/";
15 |
16 | private final OpaRestClient opaRestClient;
17 |
18 | public OpaDataClient(OpaRestClient opaRestClient) {
19 | this.opaRestClient = opaRestClient;
20 | }
21 |
22 | @Override
23 | public void createOrOverwriteDocument(OpaDocument document) {
24 | try {
25 | HttpRequest request = opaRestClient.getBasicRequestBuilder(DATA_ENDPOINT + document.getPath())
26 | .PUT(HttpRequest.BodyPublishers.ofString(document.getContent()))
27 | .header(ContentType.HEADER_NAME, ContentType.Values.APPLICATION_JSON)
28 | .build();
29 |
30 | opaRestClient.sendRequest(request, HttpResponse.BodyHandlers.discarding());
31 | } catch (OpaClientException exception) {
32 | throw exception;
33 | } catch (Exception e) {
34 | throw new OpaClientException(e);
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/data/OpaDocument.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.data;
2 |
3 | import java.beans.ConstructorProperties;
4 | import java.util.Objects;
5 |
6 | /**
7 | * Class wrapping OPA document content and path to it
8 | */
9 | public final class OpaDocument {
10 | private final String path;
11 | private final String content;
12 |
13 | /**
14 | * @param path Path to the document (eg. "this/is/path/to/document")
15 | * @param content Content of the document (JSON format)
16 | */
17 | @ConstructorProperties({"path", "content"})
18 | public OpaDocument(String path, String content) {
19 | this.path = path;
20 | this.content = content;
21 | }
22 |
23 | /**
24 | * Path to the document (eg. "this/is/path/to/document")
25 | */
26 | public String getPath() {
27 | return this.path;
28 | }
29 |
30 | /**
31 | * Content of the document (JSON format)
32 | */
33 | public String getContent() {
34 | return this.content;
35 | }
36 |
37 | @Override
38 | public boolean equals(Object o) {
39 | if (this == o) return true;
40 | if (o == null || getClass() != o.getClass()) return false;
41 | OpaDocument that = (OpaDocument) o;
42 | return Objects.equals(path, that.path) &&
43 | Objects.equals(content, that.content);
44 | }
45 |
46 | @Override
47 | public int hashCode() {
48 | return Objects.hash(path, content);
49 | }
50 |
51 | @Override
52 | public String toString() {
53 | return "OpaDocument{" +
54 | "path='" + path + '\'' +
55 | ", content='" + content + '\'' +
56 | '}';
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/policy/OpaPolicy.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.policy;
2 |
3 | import java.beans.ConstructorProperties;
4 | import java.util.Objects;
5 |
6 | /**
7 | * Class wrapping OPA document content and path to it
8 | */
9 | public final class OpaPolicy {
10 | private final String id;
11 | private final String content;
12 |
13 | /**
14 | * @param id Id of the policy to update, or id of newly created policy
15 | * @param content Content of the policy (written in Rego)
16 | */
17 | @ConstructorProperties({"id", "content"})
18 | public OpaPolicy(String id, String content) {
19 | this.id = id;
20 | this.content = content;
21 | }
22 |
23 | public String getId() {
24 | return this.id;
25 | }
26 |
27 | public String getContent() {
28 | return this.content;
29 | }
30 |
31 | @Override
32 | public boolean equals(Object o) {
33 | if (this == o) return true;
34 | if (o == null || getClass() != o.getClass()) return false;
35 | OpaPolicy opaPolicy = (OpaPolicy) o;
36 | return Objects.equals(id, opaPolicy.id) &&
37 | Objects.equals(content, opaPolicy.content);
38 | }
39 |
40 | @Override
41 | public int hashCode() {
42 | return Objects.hash(id, content);
43 | }
44 |
45 | @Override
46 | public String toString() {
47 | return "OpaPolicy{" +
48 | "id='" + id + '\'' +
49 | ", content='" + content + '\'' +
50 | '}';
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/policy/OpaPolicyApi.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.policy;
2 |
3 | /**
4 | * This is the interface responsible for OPA Policy Api @see OPA Policy API docs
5 | */
6 | public interface OpaPolicyApi {
7 | /**
8 | * Updates or creates new OPA policy
9 | *
10 | *
11 | * @param policy document to be created/updated
12 | * @since 0.0.1
13 | */
14 | void createOrUpdatePolicy(OpaPolicy policy);
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/policy/OpaPolicyClient.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.policy;
2 |
3 | import com.bisnode.opa.client.OpaClientException;
4 | import com.bisnode.opa.client.rest.ContentType;
5 | import com.bisnode.opa.client.rest.OpaRestClient;
6 |
7 | import java.net.http.HttpRequest;
8 | import java.net.http.HttpResponse;
9 |
10 | /**
11 | * @see com.bisnode.opa.client.policy.OpaPolicyApi
12 | */
13 | public class OpaPolicyClient implements OpaPolicyApi {
14 | public static final String POLICY_ENDPOINT = "/v1/policies/";
15 |
16 | private final OpaRestClient opaRestClient;
17 |
18 | public OpaPolicyClient(OpaRestClient opaRestClient) {
19 | this.opaRestClient = opaRestClient;
20 | }
21 |
22 | @Override
23 | public void createOrUpdatePolicy(OpaPolicy policy) {
24 | try {
25 | HttpRequest request = opaRestClient.getBasicRequestBuilder(POLICY_ENDPOINT + policy.getId())
26 | .header(ContentType.HEADER_NAME, ContentType.Values.TEXT_PLAIN)
27 | .PUT(HttpRequest.BodyPublishers.ofString(policy.getContent()))
28 | .build();
29 |
30 | opaRestClient.sendRequest(request, HttpResponse.BodyHandlers.discarding());
31 |
32 | } catch (OpaClientException exception) {
33 | throw exception;
34 | } catch (Exception e) {
35 | throw new OpaClientException(e);
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/query/OpaQueryApi.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.query;
2 |
3 |
4 | import java.lang.reflect.ParameterizedType;
5 |
6 | /**
7 | * This is the interface responsible for OPA Query API @see OPA Query API docs
8 | */
9 | public interface OpaQueryApi {
10 | /**
11 | * Executes simple query for document
12 | *
13 | *
14 | * @param queryForDocumentRequest request containing information needed for querying
15 | * @param responseType class of response to be returned
16 | * @return response from OPA mapped to specified class
17 | * @since 0.3.0
18 | */
19 | R queryForDocument(QueryForDocumentRequest queryForDocumentRequest, ParameterizedType responseType);
20 |
21 | /**
22 | * Executes simple query for document
23 | *
24 | *
25 | * @param queryForDocumentRequest request containing information needed for querying
26 | * @param responseType class of response to be returned
27 | * @return response from OPA mapped to specified class
28 | * @since 0.0.1
29 | */
30 | R queryForDocument(QueryForDocumentRequest queryForDocumentRequest, Class responseType);
31 | }
32 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/query/OpaQueryClient.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.query;
2 |
3 | import com.bisnode.opa.client.OpaClientException;
4 | import com.bisnode.opa.client.rest.ContentType;
5 | import com.bisnode.opa.client.rest.OpaRestClient;
6 | import com.fasterxml.jackson.databind.JavaType;
7 | import com.fasterxml.jackson.databind.type.TypeFactory;
8 |
9 | import java.lang.reflect.ParameterizedType;
10 | import java.lang.reflect.Type;
11 | import java.net.http.HttpRequest;
12 | import java.util.Arrays;
13 | import java.util.Objects;
14 |
15 | /**
16 | * @see com.bisnode.opa.client.query.OpaQueryApi
17 | */
18 | public class OpaQueryClient implements OpaQueryApi {
19 | private static final String EVALUATE_POLICY_ENDPOINT = "/v1/data/";
20 | private static final String EMPTY_RESULT_ERROR_MESSAGE = "Result is empty, it may indicate that document under path [%s] does not exist";
21 |
22 | private final OpaRestClient opaRestClient;
23 |
24 | public OpaQueryClient(OpaRestClient opaRestClient) {
25 | this.opaRestClient = opaRestClient;
26 | }
27 |
28 | /**
29 | * Executes simple query for document
30 | *
31 | *
32 | * @param queryForDocumentRequest request containing information needed for querying
33 | * @param responseType class of response to be returned
34 | * @return response from OPA mapped to specified class
35 | * @since 0.0.1
36 | */
37 | public R queryForDocument(QueryForDocumentRequest queryForDocumentRequest, Class responseType) {
38 | return internalQueryForDocument(queryForDocumentRequest, responseType);
39 | }
40 |
41 | @Override
42 | public R queryForDocument(QueryForDocumentRequest queryForDocumentRequest, ParameterizedType responseType) {
43 | return internalQueryForDocument(queryForDocumentRequest, responseType);
44 | }
45 |
46 | private R internalQueryForDocument(QueryForDocumentRequest queryForDocumentRequest, Type responseType) {
47 | try {
48 | OpaQueryForDocumentRequest opaQueryForDocumentRequest = new OpaQueryForDocumentRequest(queryForDocumentRequest.getInput());
49 |
50 | HttpRequest request = opaRestClient.getBasicRequestBuilder(EVALUATE_POLICY_ENDPOINT + queryForDocumentRequest.getPath())
51 | .header(ContentType.HEADER_NAME, ContentType.Values.APPLICATION_JSON)
52 | .POST(opaRestClient.getJsonBodyPublisher(opaQueryForDocumentRequest))
53 | .build();
54 |
55 | JavaType opaResponseType = getResponseJavaType(responseType);
56 |
57 | R result = opaRestClient.sendRequest(request, opaRestClient.>getJsonBodyHandler(opaResponseType))
58 | .body()
59 | .get()
60 | .getResult();
61 | if (Objects.isNull(result)) {
62 | throw new OpaClientException(String.format(EMPTY_RESULT_ERROR_MESSAGE, queryForDocumentRequest.getPath()));
63 | }
64 | return result;
65 | } catch (OpaClientException exception) {
66 | throw exception;
67 | } catch (Exception e) {
68 | throw new OpaClientException(e);
69 | }
70 | }
71 |
72 | private JavaType getResponseJavaType(Type responseType)
73 | throws ClassNotFoundException {
74 | JavaType opaResponseType;
75 | if (responseType instanceof ParameterizedType) {
76 | ParameterizedType parameterizedType = (ParameterizedType) responseType;
77 | Class>[] classes = Arrays.stream(parameterizedType.getActualTypeArguments()).map(type -> {
78 | try {
79 | return Class.forName(type.getTypeName());
80 | } catch (ClassNotFoundException e) {
81 | throw new IllegalArgumentException("Cannot find class configured in the responseType ".concat(type.getTypeName()), e);
82 | }
83 | }).toArray(Class[]::new);
84 |
85 | JavaType opaType = TypeFactory.defaultInstance().constructParametricType(Class.forName(parameterizedType.getRawType().getTypeName()), classes);
86 | opaResponseType = TypeFactory.defaultInstance().constructParametricType(OpaQueryForDocumentResponse.class, opaType);
87 | } else {
88 | opaResponseType = TypeFactory.defaultInstance().constructParametricType(OpaQueryForDocumentResponse.class, Class.forName(responseType.getTypeName()));
89 | }
90 | return opaResponseType;
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/query/OpaQueryForDocumentRequest.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.query;
2 |
3 | import java.beans.ConstructorProperties;
4 | import java.util.Objects;
5 |
6 | final class OpaQueryForDocumentRequest {
7 | private final Object input;
8 |
9 | @ConstructorProperties({"input"})
10 | public OpaQueryForDocumentRequest(Object input) {
11 | this.input = input;
12 | }
13 |
14 | public Object getInput() {
15 | return this.input;
16 | }
17 |
18 | @Override
19 | public boolean equals(Object o) {
20 | if (this == o) return true;
21 | if (o == null || getClass() != o.getClass()) return false;
22 | OpaQueryForDocumentRequest that = (OpaQueryForDocumentRequest) o;
23 | return Objects.equals(input, that.input);
24 | }
25 |
26 | @Override
27 | public int hashCode() {
28 | return Objects.hash(input);
29 | }
30 |
31 | @Override
32 | public String toString() {
33 | return "OpaQueryForDocumentRequest{" +
34 | "input=" + input +
35 | '}';
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/query/OpaQueryForDocumentResponse.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.query;
2 |
3 | import java.beans.ConstructorProperties;
4 | import java.util.Objects;
5 |
6 | final class OpaQueryForDocumentResponse {
7 | private final T result;
8 |
9 | @ConstructorProperties({"result"})
10 | public OpaQueryForDocumentResponse(T result) {
11 | this.result = result;
12 | }
13 |
14 | public T getResult() {
15 | return this.result;
16 | }
17 |
18 | @Override
19 | public boolean equals(Object o) {
20 | if (this == o) return true;
21 | if (o == null || getClass() != o.getClass()) return false;
22 | OpaQueryForDocumentResponse> that = (OpaQueryForDocumentResponse>) o;
23 | return Objects.equals(result, that.result);
24 | }
25 |
26 | @Override
27 | public int hashCode() {
28 | return Objects.hash(result);
29 | }
30 |
31 | @Override
32 | public String toString() {
33 | return "OpaQueryForDocumentResponse{" +
34 | "result=" + result +
35 | '}';
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/query/QueryForDocumentRequest.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.query;
2 |
3 | import java.beans.ConstructorProperties;
4 | import java.util.Objects;
5 |
6 | /**
7 | * Class wrapping OPA query response
8 | */
9 | public final class QueryForDocumentRequest {
10 | private final Object input;
11 | private final String path;
12 |
13 | /**
14 | * @param input Query input
15 | * @param path Path to the document (eg. "this/is/path/to/document")
16 | */
17 | @ConstructorProperties({"input", "path"})
18 | public QueryForDocumentRequest(Object input, String path) {
19 | this.input = input;
20 | this.path = path;
21 | }
22 |
23 | public Object getInput() {
24 | return this.input;
25 | }
26 |
27 | public String getPath() {
28 | return this.path;
29 | }
30 |
31 | @Override
32 | public boolean equals(Object o) {
33 | if (this == o) return true;
34 | if (o == null || getClass() != o.getClass()) return false;
35 | QueryForDocumentRequest that = (QueryForDocumentRequest) o;
36 | return Objects.equals(input, that.input) &&
37 | Objects.equals(path, that.path);
38 | }
39 |
40 | @Override
41 | public int hashCode() {
42 | return Objects.hash(input, path);
43 | }
44 |
45 | @Override
46 | public String toString() {
47 | return "QueryForDocumentRequest{" +
48 | "input=" + input +
49 | ", path='" + path + '\'' +
50 | '}';
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/rest/ContentType.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest;
2 |
3 | /**
4 | * Content-Type header related information
5 | */
6 | public interface ContentType {
7 | interface Values {
8 | String APPLICATION_JSON = "application/json";
9 | String TEXT_PLAIN = "text/plain";
10 | }
11 |
12 | String HEADER_NAME = "Content-Type";
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/rest/JsonBodyHandler.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest;
2 |
3 | import com.fasterxml.jackson.databind.JavaType;
4 | import com.fasterxml.jackson.databind.ObjectMapper;
5 |
6 | import java.io.IOException;
7 | import java.io.InputStream;
8 | import java.io.UncheckedIOException;
9 | import java.net.http.HttpResponse;
10 | import java.util.function.Function;
11 | import java.util.function.Supplier;
12 |
13 | class JsonBodyHandler implements HttpResponse.BodyHandler> {
14 |
15 | private final JavaType responseType;
16 | private final ObjectMapper objectMapper;
17 |
18 | public JsonBodyHandler(JavaType responseType, ObjectMapper objectMapper) {
19 | this.responseType = responseType;
20 | this.objectMapper = objectMapper;
21 | }
22 |
23 | public static HttpResponse.BodySubscriber> asJSON(JavaType responseType, ObjectMapper objectMapper) {
24 | HttpResponse.BodySubscriber upstream = HttpResponse.BodySubscribers.ofInputStream();
25 |
26 | return HttpResponse.BodySubscribers.mapping(upstream, createMapper(responseType, objectMapper));
27 | }
28 |
29 | private static Function> createMapper(JavaType responseType, ObjectMapper objectMapper) {
30 | return is -> () -> mapToJson(responseType, is, objectMapper);
31 | }
32 |
33 | private static W mapToJson(JavaType responseType, InputStream is, ObjectMapper objectMapper) {
34 | try (InputStream inputStream = is) {
35 | return objectMapper.readValue(inputStream, responseType);
36 | } catch (IOException e) {
37 | throw new UncheckedIOException(e);
38 | }
39 | }
40 |
41 | @Override
42 | public HttpResponse.BodySubscriber> apply(HttpResponse.ResponseInfo responseInfo) {
43 | return asJSON(responseType, objectMapper);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/rest/JsonBodyPublisher.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest;
2 |
3 |
4 | import com.fasterxml.jackson.core.JsonProcessingException;
5 | import com.fasterxml.jackson.databind.ObjectMapper;
6 |
7 | import java.net.http.HttpRequest;
8 | import java.nio.ByteBuffer;
9 | import java.util.concurrent.Flow;
10 |
11 | class JsonBodyPublisher implements HttpRequest.BodyPublisher {
12 |
13 | private final HttpRequest.BodyPublisher stringBodyPublisher;
14 |
15 | public static HttpRequest.BodyPublisher of(Object body, ObjectMapper objectMapper) throws JsonProcessingException {
16 | return new JsonBodyPublisher(body, objectMapper);
17 | }
18 |
19 | public JsonBodyPublisher(Object body, ObjectMapper objectMapper) throws JsonProcessingException {
20 | this.stringBodyPublisher = HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body));
21 | }
22 |
23 | @Override
24 | public long contentLength() {
25 | return stringBodyPublisher.contentLength();
26 | }
27 |
28 | @Override
29 | public void subscribe(Flow.Subscriber super ByteBuffer> subscriber) {
30 | stringBodyPublisher.subscribe(subscriber);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/rest/ObjectMapperFactory.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest;
2 |
3 | import com.fasterxml.jackson.databind.DeserializationFeature;
4 | import com.fasterxml.jackson.databind.ObjectMapper;
5 |
6 | /**
7 | * Creates and configures {@link ObjectMapper}
8 | */
9 | public class ObjectMapperFactory {
10 |
11 | private static final ObjectMapperFactory instance = new ObjectMapperFactory();
12 |
13 | private ObjectMapper objectMapper = createConfiguredObjectMapper();
14 |
15 | public ObjectMapper create() {
16 | return objectMapper;
17 | }
18 |
19 | private static ObjectMapper createConfiguredObjectMapper() {
20 | return new ObjectMapper()
21 | .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
22 | }
23 |
24 | public static ObjectMapperFactory getInstance() {
25 | return instance;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/rest/OpaRestClient.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest;
2 |
3 | import com.bisnode.opa.client.OpaClientException;
4 | import com.bisnode.opa.client.OpaConfiguration;
5 | import com.bisnode.opa.client.rest.url.OpaUrl;
6 | import com.fasterxml.jackson.core.JsonProcessingException;
7 | import com.fasterxml.jackson.databind.JavaType;
8 | import com.fasterxml.jackson.databind.ObjectMapper;
9 |
10 | import java.io.IOException;
11 | import java.net.SocketException;
12 | import java.net.http.HttpClient;
13 | import java.net.http.HttpRequest;
14 | import java.net.http.HttpResponse;
15 |
16 | /**
17 | * Class wrapping Java's HttpClient in OPA and JSON requests
18 | */
19 | public class OpaRestClient {
20 |
21 | private final OpaConfiguration opaConfiguration;
22 | private final HttpClient httpClient;
23 | private final ObjectMapper objectMapper;
24 |
25 | public OpaRestClient(OpaConfiguration opaConfiguration, HttpClient httpClient, ObjectMapper objectMapper) {
26 | this.opaConfiguration = opaConfiguration;
27 | this.httpClient = httpClient;
28 | this.objectMapper = objectMapper;
29 | }
30 |
31 | /**
32 | * Create {@link java.net.http.HttpRequest.Builder} with configured url using provided endpoint
33 | *
34 | * @param endpoint desired opa endpoint
35 | * @throws OpaClientException if URL or endpoint is invalid
36 | */
37 | public HttpRequest.Builder getBasicRequestBuilder(String endpoint) {
38 | OpaUrl url = OpaUrl.of(opaConfiguration.getUrl(), endpoint).normalized();
39 | return HttpRequest.newBuilder(url.toUri());
40 | }
41 |
42 | /**
43 | * Gets {@link java.net.http.HttpRequest.BodyPublisher} that is capable of serializing to JSON
44 | *
45 | * @param body object to be serialized
46 | */
47 | public HttpRequest.BodyPublisher getJsonBodyPublisher(Object body) throws JsonProcessingException {
48 | return JsonBodyPublisher.of(body, objectMapper);
49 | }
50 |
51 | /**
52 | * Gets {@link JsonBodyHandler} that will deserialize JSON to desired class type
53 | *
54 | * @param responseType desired response type
55 | * @param desired response type
56 | */
57 | public JsonBodyHandler getJsonBodyHandler(JavaType responseType) {
58 | return new JsonBodyHandler<>(responseType, objectMapper);
59 | }
60 |
61 | /**
62 | * Sends provided request and returns response mapped using {@link java.net.http.HttpResponse.BodyHandler}
63 | *
64 | * @param request request to be sent
65 | * @param bodyHandler handler that indicates how to transform incoming body
66 | * @param Type of returned body
67 | * @return response from HttpRequest
68 | * @throws IOException is propagated from {@link HttpClient}
69 | * @throws InterruptedException is propagated from {@link HttpClient}
70 | */
71 | public HttpResponse sendRequest(HttpRequest request, HttpResponse.BodyHandler bodyHandler) throws IOException, InterruptedException {
72 | try {
73 | HttpResponse response = httpClient.send(request, bodyHandler);
74 | if (response.statusCode() >= 300) {
75 | throw new OpaClientException("Error in communication with OPA server, status code: " + response.statusCode());
76 | }
77 | return response;
78 | } catch (SocketException exception) {
79 | throw new OpaServerConnectionException("Could not reach OPA server", exception);
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/rest/OpaServerConnectionException.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest;
2 |
3 | import com.bisnode.opa.client.OpaClient;
4 | import com.bisnode.opa.client.OpaClientException;
5 |
6 | /**
7 | * Exception returned by {@link OpaClient}
8 | * Exception which is thrown when {@link OpaClient} cannot reach Open Policy Agent server.
9 | */
10 | public class OpaServerConnectionException extends OpaClientException {
11 | public OpaServerConnectionException() {
12 | }
13 |
14 | public OpaServerConnectionException(String message) {
15 | super(message);
16 | }
17 |
18 | public OpaServerConnectionException(String message, Throwable cause) {
19 | super(message, cause);
20 | }
21 |
22 | public OpaServerConnectionException(Throwable cause) {
23 | super(cause);
24 | }
25 |
26 | public OpaServerConnectionException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
27 | super(message, cause, enableSuppression, writableStackTrace);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/rest/url/InvalidEndpointException.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest.url;
2 |
3 | import com.bisnode.opa.client.OpaClientException;
4 |
5 | public class InvalidEndpointException extends OpaClientException {
6 | public InvalidEndpointException() {
7 | super();
8 | }
9 |
10 | public InvalidEndpointException(String message) {
11 | super(message);
12 | }
13 |
14 | public InvalidEndpointException(String message, Throwable cause) {
15 | super(message, cause);
16 | }
17 |
18 | public InvalidEndpointException(Throwable cause) {
19 | super(cause);
20 | }
21 |
22 | protected InvalidEndpointException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
23 | super(message, cause, enableSuppression, writableStackTrace);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/main/java/com/bisnode/opa/client/rest/url/OpaUrl.java:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest.url;
2 |
3 | import org.slf4j.Logger;
4 | import org.slf4j.LoggerFactory;
5 |
6 | import java.net.URI;
7 | import java.util.Optional;
8 | import java.util.function.Function;
9 |
10 | /**
11 | * Contains request URL which is then used to call OPA server
12 | * Provides methods useful in URL validation/manipulation
13 | */
14 | public class OpaUrl {
15 | private static final Logger log = LoggerFactory.getLogger(OpaUrl.class);
16 | private final String value;
17 |
18 | /**
19 | * Creates OpaUrl
20 | *
21 | * @param serverUrl URL of OPA Server
22 | * @param endpoint endpoint path
23 | * @return created OpaUrl
24 | */
25 | public static OpaUrl of(String serverUrl, String endpoint) {
26 | return new OpaUrl(urlOf(serverUrl, endpoint));
27 | }
28 |
29 | private static String urlOf(String url, String endpoint) {
30 | return url + "/" + Optional.ofNullable(endpoint).orElseThrow(() -> new InvalidEndpointException("Invalid endpoint: " + endpoint));
31 | }
32 |
33 | /**
34 | * @return String value of OPA URL
35 | */
36 | public String getValue() {
37 | return value;
38 | }
39 |
40 | /**
41 | * @return Normalized version of URL, removing multiple and trailing slashes
42 | */
43 | public OpaUrl normalized() {
44 | String normalizedValue = normalize(value);
45 | return new OpaUrl(normalizedValue);
46 | }
47 |
48 | /**
49 | * @return OpaUrl transformed to URI
50 | */
51 | public URI toUri() {
52 | return URI.create(value);
53 | }
54 |
55 | private String normalize(String inputUrl) {
56 | String normalized = removeExtraSlashes()
57 | .andThen(removeTrailingSlash())
58 | .apply(inputUrl.trim());
59 | if (!inputUrl.equals(normalized)) {
60 | log.debug("Supplied URL [{}] is malformed, has to be normalized", inputUrl);
61 | }
62 | return normalized;
63 | }
64 |
65 | private Function removeTrailingSlash() {
66 | return (input) -> input.endsWith("/") ? input.substring(0, input.length() - 1) : input;
67 | }
68 |
69 | private Function removeExtraSlashes() {
70 | return (input) -> input.replaceAll("([^:])//+", "$1/");
71 | }
72 |
73 | private OpaUrl(String value) {
74 | this.value = value;
75 | }
76 |
77 | }
78 |
--------------------------------------------------------------------------------
/src/test/groovy/com/bisnode/opa/client/ManagingDocumentSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client
2 |
3 | import com.bisnode.opa.client.data.OpaDataApi
4 | import com.bisnode.opa.client.data.OpaDocument
5 | import com.bisnode.opa.client.query.OpaQueryApi
6 | import com.bisnode.opa.client.rest.ContentType
7 | import com.bisnode.opa.client.rest.OpaServerConnectionException
8 | import com.github.tomakehurst.wiremock.WireMockServer
9 | import spock.lang.Shared
10 | import spock.lang.Specification
11 | import spock.lang.Subject
12 | import spock.lang.Unroll
13 |
14 | import static com.bisnode.opa.client.rest.ContentType.Values.APPLICATION_JSON
15 | import static com.github.tomakehurst.wiremock.client.WireMock.aResponse
16 | import static com.github.tomakehurst.wiremock.client.WireMock.equalTo
17 | import static com.github.tomakehurst.wiremock.client.WireMock.put
18 | import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor
19 | import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo
20 |
21 | class ManagingDocumentSpec extends Specification {
22 |
23 | private static int PORT = 8181
24 |
25 | private String url = "http://localhost:$PORT"
26 | @Shared
27 | private WireMockServer wireMockServer = new WireMockServer(PORT)
28 | @Subject
29 | private OpaDataApi client = OpaClient.builder().opaConfiguration(url).build()
30 |
31 | def DOCUMENT = '{"example": {"flag": true}}'
32 |
33 | def setupSpec() {
34 | wireMockServer.start()
35 | }
36 |
37 | def cleanupSpec() {
38 | wireMockServer.stop()
39 | }
40 |
41 | def 'should perform successful policy create or update'() {
42 | given:
43 | def documentPath = 'somePath'
44 | def endpoint = "/v1/data/$documentPath"
45 | wireMockServer
46 | .stubFor(put(urlEqualTo(endpoint))
47 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
48 | .willReturn(aResponse()
49 | .withStatus(200)
50 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
51 | .withBody('{}')))
52 | when:
53 | client.createOrOverwriteDocument(new OpaDocument(documentPath, DOCUMENT))
54 | then:
55 | noExceptionThrown()
56 | wireMockServer.verify(putRequestedFor(urlEqualTo(endpoint))
57 | .withRequestBody(equalTo(DOCUMENT))
58 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)))
59 | }
60 |
61 | @Unroll
62 | def 'should throw OpaClientException on status code = #status'() {
63 | given:
64 | def documentPath = 'somePath'
65 | def endpoint = "/v1/data/$documentPath"
66 | wireMockServer
67 | .stubFor(put(urlEqualTo(endpoint))
68 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
69 | .willReturn(aResponse()
70 | .withStatus(status)
71 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
72 | .withBody('{}')))
73 | when:
74 | client.createOrOverwriteDocument(new OpaDocument(documentPath, DOCUMENT))
75 | then:
76 | thrown(OpaClientException)
77 | wireMockServer.verify(putRequestedFor(urlEqualTo(endpoint))
78 | .withRequestBody(equalTo(DOCUMENT))
79 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)))
80 | where:
81 | status << [400, 404, 500]
82 | }
83 |
84 |
85 | def 'should throw OpaServerConnectionException when server is down'() {
86 | given:
87 | def documentPath = 'somePath'
88 | def fakeUrl = 'http://localhost:8182'
89 | OpaQueryApi newClient = OpaClient.builder().opaConfiguration(fakeUrl).build()
90 | when:
91 | newClient.createOrOverwriteDocument(new OpaDocument(documentPath, DOCUMENT))
92 | then:
93 | thrown(OpaServerConnectionException)
94 |
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/test/groovy/com/bisnode/opa/client/ManagingPolicySpec.groovy:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client
2 |
3 | import com.bisnode.opa.client.policy.OpaPolicy
4 | import com.bisnode.opa.client.policy.OpaPolicyApi
5 | import com.bisnode.opa.client.query.OpaQueryApi
6 | import com.bisnode.opa.client.rest.ContentType
7 | import com.bisnode.opa.client.rest.OpaServerConnectionException
8 | import com.github.tomakehurst.wiremock.WireMockServer
9 | import spock.lang.Shared
10 | import spock.lang.Specification
11 | import spock.lang.Subject
12 | import spock.lang.Unroll
13 |
14 | import static com.bisnode.opa.client.rest.ContentType.Values.APPLICATION_JSON
15 | import static com.bisnode.opa.client.rest.ContentType.Values.TEXT_PLAIN
16 | import static com.github.tomakehurst.wiremock.client.WireMock.aResponse
17 | import static com.github.tomakehurst.wiremock.client.WireMock.equalTo
18 | import static com.github.tomakehurst.wiremock.client.WireMock.put
19 | import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor
20 | import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo
21 |
22 | class ManagingPolicySpec extends Specification {
23 |
24 | private static int PORT = 8181
25 | private static String url = "http://localhost:$PORT"
26 |
27 | @Shared
28 | private WireMockServer wireMockServer = new WireMockServer(PORT)
29 | @Subject
30 | private OpaPolicyApi client = OpaClient.builder().opaConfiguration(url).build()
31 |
32 | def POLICY = """package example
33 | default allow = false
34 |
35 | allow = true {
36 | input.body == "Looks good"
37 | }"""
38 |
39 | def setupSpec() {
40 | wireMockServer.start()
41 | }
42 |
43 | def cleanupSpec() {
44 | wireMockServer.stop()
45 | }
46 |
47 | def 'should perform successful policy create or update'() {
48 | given:
49 | def policyId = '12345'
50 | def endpoint = "/v1/policies/$policyId"
51 | wireMockServer
52 | .stubFor(put(urlEqualTo(endpoint))
53 | .withHeader(ContentType.HEADER_NAME, equalTo(TEXT_PLAIN))
54 | .willReturn(aResponse()
55 | .withStatus(200)
56 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
57 | .withBody('{}')))
58 | when:
59 | client.createOrUpdatePolicy(new OpaPolicy(policyId, POLICY))
60 | then:
61 | noExceptionThrown()
62 | wireMockServer.verify(putRequestedFor(urlEqualTo(endpoint))
63 | .withRequestBody(equalTo(POLICY))
64 | .withHeader(ContentType.HEADER_NAME, equalTo(TEXT_PLAIN)))
65 | }
66 |
67 | @Unroll
68 | def 'should throw OpaClientException on status code = #status'() {
69 | given:
70 | def policyId = '12345'
71 | def endpoint = "/v1/policies/$policyId"
72 | wireMockServer
73 | .stubFor(put(urlEqualTo(endpoint))
74 | .withHeader(ContentType.HEADER_NAME, equalTo(TEXT_PLAIN))
75 | .willReturn(aResponse()
76 | .withStatus(status)
77 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
78 | .withBody('{}')))
79 | when:
80 | client.createOrUpdatePolicy(new OpaPolicy(policyId, POLICY))
81 | then:
82 | thrown(OpaClientException)
83 | wireMockServer.verify(putRequestedFor(urlEqualTo(endpoint))
84 | .withRequestBody(equalTo(POLICY))
85 | .withHeader(ContentType.HEADER_NAME, equalTo(TEXT_PLAIN)))
86 | where:
87 | status << [400, 500]
88 | }
89 |
90 | def 'should throw OpaServerConnectionException when server is down'() {
91 | given:
92 | def policyId = '12345'
93 | def fakeUrl = 'http://localhost:8182'
94 | OpaQueryApi newClient = OpaClient.builder().opaConfiguration(fakeUrl).build()
95 | when:
96 | newClient.createOrUpdatePolicy(new OpaPolicy(policyId, POLICY))
97 | then:
98 | thrown(OpaServerConnectionException)
99 |
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/test/groovy/com/bisnode/opa/client/OpaClientBuilderSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client
2 |
3 |
4 | import com.bisnode.opa.client.query.QueryForDocumentRequest
5 | import com.bisnode.opa.client.rest.ContentType
6 | import com.fasterxml.jackson.databind.ObjectMapper
7 | import com.github.tomakehurst.wiremock.WireMockServer
8 | import spock.lang.Shared
9 | import spock.lang.Specification
10 |
11 | import static com.bisnode.opa.client.rest.ContentType.Values.APPLICATION_JSON
12 | import static com.github.tomakehurst.wiremock.client.WireMock.*
13 |
14 | class OpaClientBuilderSpec extends Specification {
15 |
16 | private static int PORT = 8181
17 | private static String url = "http://localhost:$PORT"
18 |
19 | @Shared
20 | private WireMockServer wireMockServer = new WireMockServer(PORT)
21 |
22 | def setupSpec() {
23 | wireMockServer.start()
24 | }
25 |
26 | def cleanupSpec() {
27 | wireMockServer.stop()
28 | }
29 |
30 | def 'should configure OpaClient with custom ObjectMapper'() {
31 |
32 | given:
33 | def objectMapper = Spy(ObjectMapper)
34 | def path = 'someDocument'
35 | def endpoint = "/v1/data/$path"
36 | wireMockServer
37 | .stubFor(post(urlEqualTo(endpoint))
38 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
39 | .willReturn(aResponse()
40 | .withStatus(200)
41 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
42 | .withBody('{"result": {"authorized": true}}')))
43 | def opaClient = OpaClient.builder()
44 | .opaConfiguration(url)
45 | .objectMapper(objectMapper)
46 | .build();
47 |
48 | when:
49 | opaClient.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), Object.class)
50 |
51 | then:
52 | 1 * objectMapper.writeValueAsString(_)
53 | }
54 |
55 | def 'should revert to default ObjectMapper if null ObjectMapper supplied'() {
56 | given:
57 | def path = 'someDocument'
58 | def endpoint = "/v1/data/$path"
59 | wireMockServer
60 | .stubFor(post(urlEqualTo(endpoint))
61 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
62 | .willReturn(aResponse()
63 | .withStatus(200)
64 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
65 | .withBody('{"result": {"authorized": true}}')))
66 | def opaClient = OpaClient.builder()
67 | .opaConfiguration(url)
68 | .objectMapper(null)
69 | .build();
70 |
71 | when:
72 | def result = opaClient.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), Map.class)
73 |
74 | then:
75 | result != null
76 | result.get("authorized") == true
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/test/groovy/com/bisnode/opa/client/QueryingForDocumentSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client
2 |
3 | import com.bisnode.opa.client.query.OpaQueryApi
4 | import com.bisnode.opa.client.query.QueryForDocumentRequest
5 | import com.bisnode.opa.client.rest.ContentType
6 | import com.bisnode.opa.client.rest.OpaServerConnectionException
7 | import com.github.tomakehurst.wiremock.WireMockServer
8 | import org.apache.commons.lang3.reflect.TypeUtils
9 | import spock.lang.Shared
10 | import spock.lang.Specification
11 | import spock.lang.Subject
12 | import spock.lang.Unroll
13 |
14 | import static com.bisnode.opa.client.rest.ContentType.Values.APPLICATION_JSON
15 | import static com.github.tomakehurst.wiremock.client.WireMock.aResponse
16 | import static com.github.tomakehurst.wiremock.client.WireMock.equalTo
17 | import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson
18 | import static com.github.tomakehurst.wiremock.client.WireMock.post
19 | import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor
20 | import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo
21 |
22 | class QueryingForDocumentSpec extends Specification {
23 |
24 | private static int PORT = 8181
25 | private static String url = "http://localhost:$PORT"
26 |
27 | @Shared
28 | private WireMockServer wireMockServer = new WireMockServer(PORT)
29 |
30 | @Subject
31 | private OpaQueryApi client = OpaClient.builder().opaConfiguration(url).build()
32 |
33 | def setupSpec() {
34 | wireMockServer.start()
35 | }
36 |
37 | def cleanupSpec() {
38 | wireMockServer.stop()
39 | }
40 |
41 | def 'should perform successful document evaluation'() {
42 | given:
43 | def path = 'someDocument'
44 | def endpoint = "/v1/data/$path"
45 | wireMockServer
46 | .stubFor(post(urlEqualTo(endpoint))
47 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
48 | .willReturn(aResponse()
49 | .withStatus(200)
50 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
51 | .withBody('{"result": {"allow":"true"}}')))
52 | when:
53 | def result = client.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), ValidationResult.class)
54 | then:
55 | noExceptionThrown()
56 | wireMockServer.verify(postRequestedFor(urlEqualTo(endpoint))
57 | .withRequestBody(equalToJson('{"input":{"shouldPass": true}}'))
58 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)))
59 | result.allow
60 | }
61 |
62 | def 'should return empty object when empty json returned from opa'() {
63 | given:
64 | def path = 'someDocument'
65 | def endpoint = "/v1/data/$path"
66 | wireMockServer
67 | .stubFor(post(urlEqualTo(endpoint))
68 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
69 | .willReturn(aResponse()
70 | .withStatus(200)
71 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
72 | .withBody('{"result": {}}')))
73 | when:
74 | def result = client.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), ValidationResult.class)
75 | then:
76 | noExceptionThrown()
77 | wireMockServer.verify(postRequestedFor(urlEqualTo(endpoint))
78 | .withRequestBody(equalToJson('{"input":{"shouldPass": true}}'))
79 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)))
80 | result.allow == null
81 | }
82 |
83 | def 'should not fail when returned more properties than mapping requires'() {
84 | given:
85 | def path = 'someDocument'
86 | def endpoint = "/v1/data/$path"
87 | wireMockServer
88 | .stubFor(post(urlEqualTo(endpoint))
89 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
90 | .willReturn(aResponse()
91 | .withStatus(200)
92 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
93 | .withBody('{"result": {"authorized": "true", "allow": true}}, "otherStuff": true')))
94 | when:
95 | def result = client.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), ValidationResult.class)
96 | then:
97 | noExceptionThrown()
98 | wireMockServer.verify(postRequestedFor(urlEqualTo(endpoint))
99 | .withRequestBody(equalToJson('{"input":{"shouldPass": true}}'))
100 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)))
101 | result.allow
102 | }
103 |
104 | @Unroll
105 | def 'should throw OpaClientException on status code = #status'() {
106 | given:
107 | def path = 'someDocument'
108 | def endpoint = "/v1/data/$path"
109 | wireMockServer
110 | .stubFor(post(urlEqualTo(endpoint))
111 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
112 | .willReturn(aResponse()
113 | .withStatus(status)
114 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)))
115 | when:
116 | client.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), ValidationResult.class)
117 | then:
118 | thrown(OpaClientException)
119 | wireMockServer.verify(postRequestedFor(urlEqualTo(endpoint))
120 | .withRequestBody(equalToJson('{"input":{"shouldPass": true}}'))
121 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON)))
122 | where:
123 | status << [400, 404, 500]
124 | }
125 |
126 |
127 |
128 | def 'should handle collection with generics in response'() {
129 | given:
130 | def path = 'someDocument'
131 | def endpoint = "/v1/data/$path"
132 |
133 |
134 | wireMockServer
135 | .stubFor(post(urlEqualTo(endpoint))
136 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
137 | .willReturn(aResponse()
138 | .withStatus(200)
139 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
140 | .withBody('{"result": [{"allow": true},{"allow": false}]}')))
141 | when:
142 |
143 | def type = TypeUtils.parameterize(List.class,ValidationResult.class);
144 | List result = client.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), type)
145 | then:
146 | ValidationResult element = result[0]
147 | element.allow
148 | }
149 |
150 | def 'should handle nested classes in response'() {
151 | given:
152 | def path = 'someDocument'
153 | def endpoint = "/v1/data/$path"
154 | wireMockServer
155 | .stubFor(post(urlEqualTo(endpoint))
156 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
157 | .willReturn(aResponse()
158 | .withStatus(200)
159 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
160 | .withBody('{"result": {"validationResult": {"allow": true}}}')))
161 | when:
162 | def result = client.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), ComplexValidationResult.class)
163 | then:
164 | result.getClass() == ComplexValidationResult.class
165 | result.validationResult.allow
166 | }
167 |
168 | def 'should map missing properties to null'() {
169 | given:
170 | def path = 'someDocument'
171 | def endpoint = "/v1/data/$path"
172 | wireMockServer
173 | .stubFor(post(urlEqualTo(endpoint))
174 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
175 | .willReturn(aResponse()
176 | .withStatus(200)
177 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
178 | .withBody('{"result": {"validationResult": {"authorized": true}}}')))
179 | when:
180 | def result = client.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), ComplexValidationResult.class)
181 | then:
182 | result.getClass() == ComplexValidationResult.class
183 | result.validationResult.allow == null
184 | }
185 |
186 | def 'should throw exception when document is empty'() {
187 | given:
188 | def path = 'someDocument'
189 | def endpoint = "/v1/data/$path"
190 | wireMockServer
191 | .stubFor(post(urlEqualTo(endpoint))
192 | .withHeader(ContentType.HEADER_NAME, equalTo(APPLICATION_JSON))
193 | .willReturn(aResponse()
194 | .withStatus(200)
195 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
196 | .withBody('{}')))
197 | when:
198 | client.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), ComplexValidationResult.class)
199 | then:
200 | thrown(OpaClientException)
201 |
202 | }
203 |
204 | def 'should throw OpaServerConnectionException when server is down'() {
205 | given:
206 | def path = 'someDocument'
207 | def fakeUrl = 'http://localhost:8182'
208 | OpaQueryApi newClient = OpaClient.builder().opaConfiguration(fakeUrl).build()
209 | when:
210 | newClient.queryForDocument(new QueryForDocumentRequest([shouldPass: true], path), ComplexValidationResult.class)
211 | then:
212 | thrown(OpaServerConnectionException)
213 |
214 | }
215 |
216 | static final class ValidationResult {
217 | Boolean allow
218 | }
219 |
220 | static final class ComplexValidationResult {
221 | ValidationResult validationResult
222 | }
223 |
224 | }
225 |
--------------------------------------------------------------------------------
/src/test/groovy/com/bisnode/opa/client/rest/ObjectMapperFactorySpec.groovy:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest
2 |
3 | import com.fasterxml.jackson.databind.DeserializationFeature
4 | import spock.lang.Specification
5 |
6 | class ObjectMapperFactorySpec extends Specification {
7 |
8 | def 'should create ObjectMapperFactory instance only once'() {
9 | when:
10 | def firstCall = ObjectMapperFactory.getInstance()
11 | def secondCall = ObjectMapperFactory.getInstance()
12 | then:
13 | firstCall == secondCall
14 | }
15 |
16 | def 'should set FAIL_ON_UNKNOWN_PROPERTIES to false in created ObjectMapper'() {
17 | when:
18 | def result = ObjectMapperFactory.getInstance().create()
19 | then:
20 | !result.deserializationConfig.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
21 | }
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/src/test/groovy/com/bisnode/opa/client/rest/OpaRestClientSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest
2 |
3 | import com.bisnode.opa.client.OpaClientException
4 | import com.bisnode.opa.client.OpaConfiguration
5 | import com.fasterxml.jackson.databind.ObjectMapper
6 | import com.github.tomakehurst.wiremock.WireMockServer
7 | import spock.lang.Shared
8 | import spock.lang.Specification
9 | import spock.lang.Subject
10 |
11 | import java.net.http.HttpClient
12 | import java.net.http.HttpResponse
13 |
14 | import static com.bisnode.opa.client.rest.ContentType.Values.APPLICATION_JSON
15 | import static com.github.tomakehurst.wiremock.client.WireMock.aResponse
16 | import static com.github.tomakehurst.wiremock.client.WireMock.any
17 | import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl
18 | import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor
19 | import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo
20 |
21 | class OpaRestClientSpec extends Specification {
22 |
23 | private static int PORT = 8181
24 |
25 | private String url = "http://localhost:$PORT"
26 | @Shared
27 | private WireMockServer wireMockServer = new WireMockServer(PORT)
28 | @Subject
29 | private OpaRestClient client = new OpaRestClient(new OpaConfiguration(url), HttpClient.newHttpClient(), new ObjectMapper())
30 |
31 | def setupSpec() {
32 | wireMockServer.start()
33 | }
34 |
35 | def cleanupSpec() {
36 | wireMockServer.stop()
37 | }
38 |
39 | def 'should remove trailing slashes from request URL to OPA'() {
40 | given:
41 | def path = '/v1/path/with/trailing/slash/'
42 | wireMockServer
43 | .stubFor(any(anyUrl())
44 | .willReturn(aResponse()
45 | .withStatus(200)
46 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
47 | .withBody('{}')))
48 | when:
49 | def request = client.getBasicRequestBuilder(path).GET().build()
50 | client.sendRequest(request, HttpResponse.BodyHandlers.discarding())
51 | then:
52 | wireMockServer.verify(getRequestedFor(urlEqualTo('/v1/path/with/trailing/slash')))
53 | }
54 |
55 | def 'should remove extra slashes from request URL to OPA'() {
56 | given:
57 | def path = '/v1/path/with///extra//slash'
58 | wireMockServer
59 | .stubFor(any(anyUrl())
60 | .willReturn(aResponse()
61 | .withStatus(200)
62 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
63 | .withBody('{}')))
64 | when:
65 | def request = client.getBasicRequestBuilder(path).GET().build()
66 | client.sendRequest(request, HttpResponse.BodyHandlers.discarding())
67 | then:
68 | wireMockServer.verify(getRequestedFor(urlEqualTo('/v1/path/with/extra/slash')))
69 | }
70 |
71 | def 'should throw OpaClientException when url is null'() {
72 | given:
73 | wireMockServer
74 | .stubFor(any(anyUrl())
75 | .willReturn(aResponse()
76 | .withStatus(200)
77 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
78 | .withBody('{}')))
79 | when:
80 | def request = client.getBasicRequestBuilder(null).GET().build()
81 | client.sendRequest(request, HttpResponse.BodyHandlers.discarding())
82 | then:
83 | thrown(OpaClientException)
84 | }
85 |
86 | def 'should prefix endpoint path with slash when missing'() {
87 | given:
88 | wireMockServer
89 | .stubFor(any(anyUrl())
90 | .willReturn(aResponse()
91 | .withStatus(200)
92 | .withHeader(ContentType.HEADER_NAME, APPLICATION_JSON)
93 | .withBody('{}')))
94 | when:
95 | def request = client.getBasicRequestBuilder('v1/path/with/missing/slash').GET().build()
96 | client.sendRequest(request, HttpResponse.BodyHandlers.discarding())
97 | then:
98 | wireMockServer.verify(getRequestedFor(urlEqualTo('/v1/path/with/missing/slash')))
99 | }
100 |
101 | def 'should remap SocketException and derivatives to OpaServerConnectionException'() {
102 | given:
103 | OpaRestClient restClient = new OpaRestClient(new OpaConfiguration("http://localhost:8182"), HttpClient.newHttpClient(), new ObjectMapper())
104 | when:
105 | def request = restClient.getBasicRequestBuilder('v1/path/with/missing/slash').GET().build()
106 | restClient.sendRequest(request, HttpResponse.BodyHandlers.discarding())
107 | then:
108 | thrown(OpaServerConnectionException)
109 | }
110 |
111 | }
112 |
--------------------------------------------------------------------------------
/src/test/groovy/com/bisnode/opa/client/rest/url/OpaUrlSpec.groovy:
--------------------------------------------------------------------------------
1 | package com.bisnode.opa.client.rest.url
2 |
3 | import spock.lang.Specification
4 |
5 | class OpaUrlSpec extends Specification {
6 | public static final String URL = 'http://localhost:8181/'
7 |
8 | def 'should change multiple extra slashes to single one when normalized'() {
9 | when:
10 | OpaUrl opaUrl = OpaUrl.of(URL, '/v1/path/to///document')
11 | def normalizationResult = opaUrl.normalized().value
12 | then:
13 | normalizationResult == 'http://localhost:8181/v1/path/to/document'
14 | }
15 |
16 | def 'should remove trailing slash when normalized'() {
17 | when:
18 | OpaUrl opaUrl = OpaUrl.of(URL, '/v1/path/to/document/')
19 | def normalizationResult = opaUrl.normalized().value
20 | then:
21 | normalizationResult == 'http://localhost:8181/v1/path/to/document'
22 | }
23 |
24 | def 'should throw InvalidEndpointException when trying to create object with null endpoint'() {
25 | when:
26 | OpaUrl.of(URL, null)
27 | then:
28 | thrown(InvalidEndpointException)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------