DEV Community

Yann Tavernier for Gravitee

Posted on

Write your own policy with Gravitee.io API Management 4.0.0

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.

Amazing

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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"));
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘©β€πŸ’» 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.

Pizza

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, else 500 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 and X-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
Enter fullscreen mode Exit fullscreen mode
  • 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 and src/test/resources are here for testing purposes. Here, you can find a TemplatePolicyIntegrationTest 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.
Work smarter

πŸ“€ 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());
}
Enter fullscreen mode Exit fullscreen mode

The HttpExecutionContext allows developers to access different objects tied to API transaction itself:

  • request(): allows access to the current request which provides accessors to headers, 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 as request() 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;
}
Enter fullscreen mode Exit fullscreen mode

Some information:

  1. 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.
  2. If the list of toppings is empty, the pizza is not created and an empty buffer is returned.
  3. Next, comes the pineapple case. If policy is configured to refuse pineapple, and toppings list contains pineapple or 🍍, then a PineappleForbiddenException would be thrown.
  4. 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. A PizzaProcessingException 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);
}
Enter fullscreen mode Exit fullscreen mode

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))
        );
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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.
  2. Now, we have a Single<Buffer>. Thanks to the flatMapMaybe() operator, we can use the Buffer and return a Maybe object. That's exactly what we have done with Maybe<Buffer> createPizza(Buffer body, HttpHeaders header)
  3. With the switchIfEmpty() operator, we can handle the case of an empty pizza, by adding a X-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();
    });
}
Enter fullscreen mode Exit fullscreen mode
  1. 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
  • RESPONSE:
    • All exceptions: 500 - Internal Server Error

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())
        );
    };
}
Enter fullscreen mode Exit fullscreen mode

🚦 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
          }
        }
      ],
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. @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
  2. @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.
  3. AbstractPolicyTest<PizzaPolicy, PizzaPolicyConfiguration is an abstract class allowing you to directly register your policy on the Gateway. You can also extends AbstractGatewayTest and register your policy thanks to AbstractGatewayTest#configurePolicies(Map<String PolicyPlugin policies)
  4. configureEntrypoints(Map<String, EntrypointConnectorPlugin<?, ?>> entrypoints) and configureEndpoints(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 a proxy 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) {}
Enter fullscreen mode Exit fullscreen mode

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\"]}"))
    );
}
Enter fullscreen mode Exit fullscreen mode

πŸ† That's it, you know how to test a policy! You just need to implements the other test cases.

As expected

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.

Configuration of the Policy in Policy Studio

View of the Policy in Policy Studio

πŸ€” 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)

Collapse
 
dorian profile image
Dorian

Awesome, full of useful information, easy to follow tutorial !