The vast world of APIs is constantly evolving. This can be seen with the recent rise in popularity of event-driven architecture connected through modern event brokers (Kafka, Solace, etc.) and asynchronous web APIs. This transition from traditional, REST-dominated architectures with synchronous request-response style communication to mixed architectures dependent on both synchronous and asynchronous protocols requires a modern and versatile API Management solution: enter Gravitee API Management (APIM).
As of Gravitee 4.0, APIM is an event-native API solution. This means Gravitee natively supports asynchronous web APIs and event brokers while still fully supporting synchronous request/response style APIs in a centralized control plane. Regardless of protocol, Gravitee can modify the behavior of the request or response through policies. Gravitee policies fall into several functional categories: security, transformation, restrictions, performance, routing, and monitoring & testing.
Gravitee offers both a free Community Edition and paid Enterprise Edition of our platform. To learn more about what features are available in each edition, check out our brand-new documentation site!
Graviteeβs protocol and policy support are powered through an extensible plugin system. So while there is built-in support for a number of protocols, policies (and more), the plugin system allows anyone to add new policies or even entrypoints/endpoints to APIM.
Yes⦠I know, this opens up a brave new world of seemingly endless possibilities. But how to get started?
In this article, Iβll teach you how to write your own custom policy using the plugin system, but first, a few housekeeping items.
πββοΈ Who am IΒ ?
I am Yann Tavernier and I am a developer on the amazing APIM team at Gravitee. I'm delighted to walk you through the creation of a policy and how to efficiently test it!
π Prerequisites
- Your favorite Java IDE
- JDK 17
- Maven 3.8
π¨βπ« About Gravitee
If you want more information about the Gravitee platform, head over to the Gravitee Essentials section of our documentation site.
π Key APIM components and concepts
Before we start looking at Policy creation and testing, let's provide an overview of all of the key concepts and components that we'll be using in this blog.
Key components
- APIM is the API Management solution created by Gravitee.
- APIM Gateway: A reverse proxy layer that brokers, secures, and hardens access to APIs and data streams. It is the component of APIM responsible for handling requests from customers.
- APIM Management API (mAPI): A REST API used to configure and manage APIs and various Gravitee resources.
- APIM Console: A graphical user interface to configure Gateways, create APIs, design policies, and publish documentation. Every action in the APIM Management Console is tied directly to the mAPI.
Key concepts
- API Publisher is a role type for APIM, representing the user of the company in charge of designing and publishing the APIs.
- API is the representation of your backend inside APIM, and is composed of elements such as documentation, users, design, properties, etc.
- Plan is the access and service layer on top of an API for consumer Applications. Consumer Applications need to subscribe to a plan to be able to consume an API.
- Plugin is a component that provide additional functionality to the Gravitee ecosystem.
- Policy is a service or action that can be executed on an API request or response to perform checks, transformation or other services to it. As well as out of the box policies, you can also create your own. The functionality of the policy is enabled through plugins.
If you want more information about Gravitee, read this awesome Dev Guide! Even better, this guide is directly available on our community forum, so you can ask all your questions.
What is a policy?
As mentioned earlier, a policy is essentially a service or action executed on a request, response or a message to perform things like transformations, rate limits, routing, monitoring & testing, etc.
Technically speaking, a policy is nothing more than a piece of Java code consuming an execution context and returning a Completable (see here).
Let's take a look at the interface (Javadoc removed for readability purpose):
public interface Policy {
String id();
default Completable onRequest(final HttpExecutionContext ctx) {
return Completable.complete();
}
default Completable onResponse(final HttpExecutionContext ctx) {
return Completable.complete();
}
default Completable onMessageRequest(final MessageExecutionContext ctx) {
return Completable.complete();
}
default Completable onMessageResponse(final MessageExecutionContext ctx) {
return Completable.complete();
}
}
β οΈ Note that, as we are in the reactive world, the policy implementation is used to build a reactive chain and this chain should only be executed when the Gateway subscribes to the policy. In short, this means your policy code must be written in the context of a Completable object creation.
Here is a good example πͺ
Completable onRequest(final HttpExecutionContext ctx) {
return Completable.fromRunnable(() -> request.headers().set("X-DummyHeader", "dummy"));
}
And a bad one π₯ (In the following example, the code will be executed before the subscription takes place which can have unintended side effects)
Completable onRequest(final HttpExecutionContext ctx) {
request.headers().set("X-DummyHeader", "dummy"); // π₯ Not to do
return Completable.complete();
}
π©βπ» Creating our ownΒ policy
The best way to learn is by doing. I will talk you through all the steps needed to create a fully functional custom policy. Let's create a π Pizza Factory policy.
The goal of this policy is quite simple. It will use the request headers and payload to transform the request into a JSON pizza object. This will be duplicated on the response phase to return the created pizza Object to the user.
The policy will be configurable:
- Users can configure the crust and the sauce.
- To keep consumers of the pizza safe, we will add a toggle to forbid the word "pineapple" or "π". If the API request contains pineapple, then the Gateway will return a
406 Not Acceptable
, else500 Internal Server Error
.
π‘ In a more realistic policy, we would have the policy return a 400 Bad Request
to follow standard HTTP error message semantics.
β Some acceptance criteria
- Headers must follow this format: X-pizza-topping: #value#. You can use it multiple times to add as many toppings as necessary.
- Body must be an array of strings representing the toppings to add.
- If no
X-Pizza-Topping
header is present and the body is empty, then a pizza object is not created andX-Pizza: No Pizza
header is added. - If both
X-Pizza-Topping
header and body payload are present, then both are used to compose the pizza object. - If the body is not an array of strings, then it results in a
400 Bad Request
. - The body of request or response will be replaced by the pizza object. We assume the content type will be
application/json
.
π§ͺ Initialize it!
To initialize your policy, simply fork or clone https://github.com/gravitee-io/gravitee-policy-template.
This repository contains the necessary structure and minimum files needed to create a policy. You may need to update some dependencies (in pom.xml) to be consistent with versions used by APIM.
Policies are written in Java and use Maven as the build tool.
Let's take a close look at the structure of the policy.
-
README.adoc
is here to explain the purpose of your policy, how to use it and the error cases. π‘ This file is used in the Management Console UI of Gravitee APIM as in-app documentation for the users of the platform; therefore, take care to ensure that the README is clear and comprehensive. -
pom.xml
contains information about the project and configuration details used by Maven to build the project. This base comes with the minimal set of dependencies to develop and build your policy. -
src/assembly
is used by maven-assembly-plugin to generate the zip of the policy with the right structure. β οΈ You do not need to modify this file. -
src/main/java
will contain the policy code -
src/main/resources/plugin.properties
is the manifest of the policy. It contains informations such as the policy's id, name and icon. For the Pizza Policy,plugin.properties
file will look like this:
id=pizza-factory
name=Pizza
version=${project.version}
description=${project.description}
class=io.gravitee.policy.pizza.PizzaPolicy
type=policy
category=transformation
icon=pizza.svg
-
src/main/resources/schemas/schema-form.json
is the representation of the configuration of your policy. It is used to generate the schema and validate the creation of a policy whether using the APIM Management UI or the Management API directly. -
src/test/java
andsrc/test/resources
are here for testing purposes. Here, you can find aTemplatePolicyIntegrationTest
test class that has already been implemented. We will discuss it in more detail later, but essentially, it allows for easy creation of unit tests to verify your policy behaves as expected. This is done by deploying an API using this policy on an in-memory Gateway and then calls the API to verify the response matches the expectation.
π Let's develop it
I will not detail every step, but instead focus on the steps required to develop a policy in Gravitee.
π€ The request phase
Let's go into it. As a reminder, our policy aims to transform the body into a JSON pizza object depending on incoming request headers and payload.
We will modify the onRequest
method in this way:
@Override
public Completable onRequest(HttpExecutionContext ctx) {
return ctx.request().onBody(maybeBody -> doSomethingHere());
}
The HttpExecutionContext
allows developers to access different objects tied to API transaction itself:
-
request()
: allows access to the current request which provides accessors toheaders
,pathParameters
,HTTP method
,pathInfo
,host
, etc. It also provides methods to manipulate the body (onBody
) or to interrupt the call (interruptWith
). -
response()
: the same thing asrequest()
except you don't have any request related information. -
getAttribute(String attribute)
: allows you to manipulate an attribute that has been stored in the context of the API transaction.
π‘ The onBody
method lets you manipulates a Maybe<Buffer>
. This can handle the uncertainty inherent in every API request: maybe the request has a body, maybe not.
π Creating a pizza
First, we will create the core feature of our policy: the createPizza
method. As inputs, it needs the incoming payload and headers, and will return a Buffer: our pizza object. As we extract the body and headers from the context object, we do not need to stay in the reactive programming style, we will see later how to do the link.
Our method would look like this:
private Buffer createPizza(Buffer body, HttpHeaders headers) throws IOException {
final Set<String> toppings = extractToppings(body, headers);
if (toppings.isEmpty()) {
return Buffer.buffer();
}
verifyPineapple(toppings);
final Buffer pizzaObject = createPizzaObject(toppings, headers);
return pizzaObject;
}
Some information:
-
extractToppings(Buffer body, HttpHeaders headers)
is responsible for the following:- extracting the toppings from headers (
X-Pizza-Topping
) - extracting the toppings from body. In this case, body must be a valid json array of strings, or else a
NotStringArrayException
will be thrown. - returning the set of toppings extracted from body and headers.
- extracting the toppings from headers (
- If the list of toppings is empty, the pizza is not created and an empty buffer is returned.
- Next, comes the pineapple case. If policy is configured to refuse pineapple, and toppings list contains
pineapple
orπ
, then aPineappleForbiddenException
would be thrown. - Finally, the method creates and returns a pizza object which is just the JSON representation of the pizza from the configured
crust
,sauce
and extracted toppings. APizzaProcessingException
could be thrown in case of mapping issue.
This code is fairly straightforward and does not require any knowledge of reactive programming.
π½οΈ Serving the pizza
However, the createPizza
method now needs to be transformed. We have to integrate our createPizza
method into the reactive chain.
The reactive world, and more precisely RxJava 3 in our case, does not directly manipulate Buffer
, it manipulates reactive objects. Those objects implements the Reactive Pattern for different cases:
-
Single
for a single value response -
Maybe
for a single value, no value or an exception -
Completable
for a deferred computation without any value, but only indication for completion or exception -
Flowable
to consume reactive dataflows - And many others.
Remember, our method returns a Buffer
: an empty buffer if there are no toppings, or the pizza object as a buffer.
This is a perfect use case for the Maybe
reactive object:
- If there are no toppings, return
Maybe.empty()
- Else, return
Maybe.just(pizzaObject)
The method now looks like this:
private Maybe<Buffer> createPizza(Buffer body, HttpHeaders headers) throws IOException {
final Set<String> toppings = extractToppings(body, headers);
if (toppings.isEmpty()) {
return Maybe.empty();
}
verifyPineapple(toppings);
final Buffer pizzaObject = createPizzaObject(toppings, headers);
return Maybe.just(pizzaObject);
}
Now, we are able to use our method in a reactive way:
@Override
public Completable onRequest(HttpExecutionContext ctx) {
return ctx
.request()
.onBody(maybeBody ->
maybeBody
// If no body, then use an empty buffer
.defaultIfEmpty(Buffer.buffer())
// Create a pizza from body and headers
.flatMapMaybe(body -> createPizza(body, ctx.request().headers()))
// If no pizza has been created, then handle the case
.switchIfEmpty(handleNoPizza(ctx.request().headers()))
// Manage errors
.onErrorResumeNext(handleError(ctx, true))
);
}
Let me provide some further explanation of these changes.
onBody
lets us manipulate a Maybe<Buffer>
. This body can indeed be empty if the user provides toppings only using headers.
- The first step is to have something to manipulate. Using
maybeBody.defaultIfEmpty(Buffer.buffer())
provides an empty buffer to be used in subsequent steps. If we've kept an empty maybe, then we could not continue to the next steps in the reactive chain. There must be something to compute. - Now, we have a
Single<Buffer>
. Thanks to theflatMapMaybe()
operator, we can use the Buffer and return aMaybe
object. That's exactly what we have done withMaybe<Buffer> createPizza(Buffer body, HttpHeaders header)
- With the
switchIfEmpty()
operator, we can handle the case of an empty pizza, by adding aX-Pizza: not-created
header with this method:
private static Maybe<Buffer> handleNoPizza(HttpHeaders headers) {
return Maybe.fromCallable(() -> {
headers.add(X_PIZZA_HEADER, NOT_CREATED);
return Buffer.buffer();
});
}
- Finally, we handle the errors that might have ocurred during the process with the
onErrorResumeNext()
operator. Let's look at the error handling in more detail.
π₯ Managing errors
The onErrorResumeNext()
operator allows you to resume the flow with a Maybe
object that is returned for a particular failure. In other words, it allows to properly manage the error cases that can occur in the reactive chain.
Our requirements were about returning particular HTTP status codes depending on whether we are in request or response phase, and provide details on the type of error:
- REQUEST:
- PineappleForbiddenException:
406 - Not Acceptable
- All other exceptions:
400 - Bad Request
- PineappleForbiddenException:
- RESPONSE:
- All exceptions:
500 - Internal Server Error
- All exceptions:
The HttpExecutionContext
contains an interesting method: interruptBodyWith(ExecutionFailure failure)
. It allows the developer to fail the current request/response flow with an ExecutionFailure
object.
This objects can be configured with:
- (required) the HTTP status code to return to the user
- a message to be returned as the body
- a key that can referenced by the Response Template feature of APIM
- and some other features we won't dive into here
The error management code is pretty straightforward to write:
private Function<Throwable, Maybe<Buffer>> handleError(HttpExecutionContext ctx, boolean isOnRequest) {
return err -> {
log.warn("It was not possible to create the pizza because: {}", err.getMessage());
if (isOnRequest && err instanceof PineappleForbiddenException) {
return ctx.interruptBodyWith(
new ExecutionFailure(HttpStatusCode.NOT_ACCEPTABLE_406).key(PIZZA_ERROR_KEY).message(err.getMessage())
);
}
return ctx.interruptBodyWith(
new ExecutionFailure(isOnRequest ? HttpStatusCode.BAD_REQUEST_400 : HttpStatusCode.INTERNAL_SERVER_ERROR_500)
.key(PIZZA_ERROR_KEY)
.message(err.getMessage())
);
};
}
π¦ Test your policy
Gravitee APIM comes with what we call the Gateway Testing SDK.
This library can be seen as a JUnit 5 extension which allow the developer to:
- Write tests as regular JUnit 5 tests
- Run an in-memory gateway (and configure it as needed)
- Deploy inline plugins (directly from code)
- Do real call against the gateway to verify it behaves as expected
In our present case, we will want to validate that if we call an API using the Pizza Factory Policy
, then we have the expected behavior in term of returned object or error handling.
Some cases to be tested (on both request and response):
- Should not create a pizza when no topping provided
- Should create a pizza when toppings comes from headers
- Should create a pizza when toppings comes from body
- Should create a pizza when toppings comes from headers and body
- Should not create a pizza when toppings contains pineapple and it's forbidden
- ...
Test preparation
APIs are deployed from a JSON definition. You can learn more about Gravitee's API definition here.
Here is the policy configuration portion of an API definition:
"request": [
{
"name": "Pizza Factory on request",
"description": "Create your pizza from request headers!",
"enabled": true,
"policy": "pizza-factory",
"configuration": {
"crust": "Pan",
"sauce": "TOMATO",
"pineappleForbidden": false
}
}
],
This JSON string will be used to deploy the test API on the testing Gateway, and will allow us to call this API for testing.
Next, we need to create a test class. APIM team uses the IntegrationTest
suffix to distinguish tests using the SDK from regular unit tests.
Here is the minimal code needed to do to run a test with the SDK:
@GatewayTest
@DeployApi("/apis/pizza-api.json")
class PizzaPolicyIntegrationTest extends AbstractPolicyTest<PizzaPolicy, PizzaPolicyConfiguration> {
@Override
public void configureEntrypoints(Map<String, EntrypointConnectorPlugin<?, ?>> entrypoints) {
entrypoints.putIfAbsent("http-proxy", EntrypointBuilder.build("http-proxy", HttpProxyEntrypointConnectorFactory.class));
}
@Override
public void configureEndpoints(Map<String, EndpointConnectorPlugin<?, ?>> endpoints) {
endpoints.putIfAbsent("http-proxy", EndpointBuilder.build("http-proxy", HttpProxyEndpointConnectorFactory.class));
}
@Test
void should_create_pizza_with_header_toppings_on_request(HttpClient httpClient) {
// We will focus on the test case later
}
}
-
@GatewayTest
is a meta-annotation which marks the class as testable with the SDK. It will run the necessary extensions to run the Gateway and initialize components -
@DeployApi
deploys an API based on its definition. You can also pass an array of APIs to deploy, where each API must have a distinct name and entrypoint.- Used at class level, it will deploy the APIs once for the Gateway instance, meaning all the methods annotated with
@Test
will be able to call those APIs. - Used at method level, it will deploy the APIs for the lifetime of the test method and then it will be undeployed.
- Used at class level, it will deploy the APIs once for the Gateway instance, meaning all the methods annotated with
-
AbstractPolicyTest<PizzaPolicy, PizzaPolicyConfiguration
is an abstract class allowing you to directly register your policy on the Gateway. You can also extendsAbstractGatewayTest
and register your policy thanks toAbstractGatewayTest#configurePolicies(Map<String PolicyPlugin policies)
-
configureEntrypoints(Map<String, EntrypointConnectorPlugin<?, ?>> entrypoints)
andconfigureEndpoints(Map<String, EndpointConnectorPlugin<?, ?>> endpoints)
allow the developer to register entrypoint and endpoint plugins. In this case,http-proxy
connectors are registered so that the API can be deployed as aproxy
api.
π‘ You can learn more about the difference between Proxy and Message Gateway APIs here.
Writing a test
You may have noticed in the previous example that an HttpClient
is injected as a parameter of the test method:
@Test
void should_create_pizza_with_header_toppings_on_request(HttpClient httpClient) {}
It is injected to make your life easier. An important thing to understand is that the Gateway is started on a random available port, and the same applies to the started Wiremock
used as endpoint.
When deploying an API, the SDK will update the localhost:8080
endpoints with by the right port and thereby allowing requests to reach Wiremock
.
The HttpClient
, in the same way, is configured to reach directly to the Gateway on the right port.
So, we now want to test if a pizza is created from toppings provided as a header when the Pizza Policy is configured on request phase. API is configured to be reached on /test
path and to contact a backend on /endpoint
path.
Here is the code of the test, with comments:
@Test
@DisplayName("Should create pizza when toppings provided from headers")
void should_create_pizza_with_header_toppings_on_request(HttpClient httpClient) {
// 1. Prepare wiremock endpoint to answer with a `200 - OK` and an empty body when `/endpoint` is reached
wiremock.stubFor(get("/endpoint").willReturn(ok()));
// 2. Use the HttpClient to call your deployed API, configured with `/test` as context-path
httpClient
.rxRequest(HttpMethod.GET, "/test")
// 2.1 Send the request to the gateway, with toppings header: `x-pizza-topping:peperoni,cheddar`
.flatMap(request -> request.putHeader(X_PIZZA_HEADER_TOPPING, List.<String>of("peperoni", "cheddar")).rxSend())
// 2.2 Verify the response status is the expected one and return the body
.flatMap(response -> {
assertThat(response.statusCode()).isEqualTo(HttpStatusCode.OK_200);
return response.body();
})
.test()
.awaitDone(10, TimeUnit.SECONDS)
// 2.3 Verify the call is complete, and that the body is an empty buffer
.assertComplete()
.assertValue(Buffer.buffer())
.assertNoErrors();
// 3. Verify the wiremock endpoint has been called
// - on `/endpoint`
// - with header `x-pizza:created`
// - with the expected pizza object
wiremock.verify(
1,
getRequestedFor(urlPathEqualTo("/endpoint"))
.withHeader(X_PIZZA_HEADER, equalTo(CREATED))
.withRequestBody(equalTo("{\"crust\":\"Pan\",\"sauce\":\"TOMATO\",\"toppings\":[\"peperoni\",\"cheddar\"]}"))
);
}
π That's it, you know how to test a policy! You just need to implements the other test cases.
You can find the README of the SDK here.
For more examples of tests, you can take a look here.
π Use it!
Now, it's time to use your policy.
You first need to build the ZIP file that will be deployed on the Management API and on the Gateway of APIM.
Thankfully, this is quite simple. Just run mvn clean install
. (The policy is preconfigured to apply a prettier check and to enforce code formatting. If you have any issues, you may need to manually run mvn prettier:write
).
This generates a ZIP file containing all the necessary assets for the policy to be used by APIM. This ZIP file can be found in the target
folder.
Deploying a plugin is as easy as copying the plugin archive into the dedicated directory. By default, you need to deploy the ZIP in ${GRAVITEE_HOME}/plugins
(more information here).
β οΈ You need to restart the Management API and Gateway for the plugin to be loaded.
π€ What about v2 APIs?
For existing users of Gravitee APIM, you probably have some "v2" APIs.
Those APIs were running with the legacy engine, and policies were written in a different format than what was shown in this blog.
Lucky for you, you can use policies written in 4.0.0 style with existing v2 APIs, 4.0 was designed with backward compatibility in mind. To do so, "v4 Emulation mode" must be enabled by setting the executionMode
field of your API definition to: v4-emulation-engine
.
π‘ You can learn more about the different Gravitee execution engines, API definitions, and emulation mode here.
β To wrap up
In this blog, we just saw how to write, test and deploy a simple custom policy to explore the extensible nature of Gravitee APIM. You can find the complete policy on the Gravitee Pizza Policy repository.
If this blog inspired you to build your own policy, you can get started quickly by forking the Policy Template.
All are welcome to get involved in the Gravitee Community. From everything to submitting pull requests in the APIM repo to getting involved in our Gravitee Community Discourse, we'd love to have you join our vibrant community.
Please don't hesitate to ask for support on our community discourse π€, we are happy to help!
Top comments (1)
Awesome, full of useful information, easy to follow tutorial !