On this page we want to implement a first optimization on our JSON-RPC server.

Warning
this page will not go through the complete details of such optimization and only consider the pure case where all requests match but a real implement could group requests to still optimize the execution when sub requests are groupable (a plan should group requests and use the optimize case when possible and not only optimize all or nothing).
Tip
for a simpler implementation you can review io.yupiik.uship.jsonrpc.core.plan.SimpleExecutionPlanCompanion which enables to write checks more easily.

The use case is the following one:

  • You have a JSON-RPC method findById which takes two parameters id and logo (a boolean to request to include the logo url or not),

  • You have a java service method called findByIds which can load multiple entities at once (WHERE id in (…​)).

The idea is to replace a bulk request of findById by an optimized execution using findByIds making the server execution optimized even if the client request is not optimal.

Here is a sample implementation:

@Specializes
@ApplicationScoped
public class EnrichedJsonRpcHandler extends JsonRpcHandler {
    @Inject
    @JsonRpc
    private SearchService searcher;

    @Inject
    private Jsonb jsonb;

    @Inject
    private JsonBuilderFactory jsonBuilderFactory;

    @Override
    public CompletionStage<?> execute(final JsonStructure request,
                                      final HttpServletRequest httpRequest,
                                      final HttpServletResponse httpResponse) {
        if (request.getValueType() != JsonValue.ValueType.ARRAY) { // (1)
            return super.execute(request, httpRequest, httpResponse);
        }

        final var req = request.asJsonArray();
        if (req.isEmpty()) { // (2)
            return super.execute(request, httpRequest, httpResponse);
        }

        final var first = req.get(0);
        if (first.getValueType() != JsonValue.ValueType.OBJECT) { // (3)
            return super.execute(request, httpRequest, httpResponse);
        }

        final var obj = first.asJsonObject();
        final var name = obj.get("method");
        if (name == null || name.getValueType() != JsonValue.ValueType.STRING) { // (4)
            return super.execute(request, httpRequest, httpResponse);
        }

        // (5)
        return executeWithOptimizations(request, httpRequest, httpResponse, req, obj, JsonString.class.cast(name).getString());
    }

    private CompletionStage<?> executeWithOptimizations( // (6)
            final JsonStructure request, final HttpServletRequest httpRequest, final HttpServletResponse httpResponse,
            final JsonArray req, final JsonObject firstItem, final String method) {
        switch (method) {
            case "findById":
                return findByIds(request, httpRequest, httpResponse, req, firstItem);
            default:
                return super.execute(request, httpRequest, httpResponse);
        }
    }

    private CompletionStage<?> findByIds(final JsonStructure request,
                                         final HttpServletRequest httpRequest,
                                         final HttpServletResponse httpResponse,
                                         final JsonArray req,
                                         final JsonObject firstItem) {
        final var requests = req.stream()
                .filter(it -> it.getValueType() == JsonValue.ValueType.OBJECT)
                .map(JsonValue::asJsonObject)
                .toList();
        if (requests.size() != req.size()) { // (7)
            return super.execute(request, httpRequest, httpResponse);
        }

        final var params = firstItem.get("params");
        if (params == null || params.getValueType() != JsonValue.ValueType.OBJECT) { // (8)
            return super.execute(request, httpRequest, httpResponse);
        }

        final var icons = params.asJsonObject().get("logo");
        // (9)
        if (icons.getValueType() != JsonValue.ValueType.TRUE &&
                icons.getValueType() != JsonValue.ValueType.FALSE) {
            return super.execute(request, httpRequest, httpResponse);
        }
        // (10)
        if (requests.stream()
                        .skip(1)
                        .map(it -> it.get("params"))
                        .anyMatch(p -> p == null ||
                                p.getValueType() != JsonValue.ValueType.OBJECT ||
                                !Objects.equals(icons, p.asJsonObject().get("logo")))) {
            return super.execute(request, httpRequest, httpResponse);
        }

        // (11)
        final var ids = requests.stream()
                .map(it -> it.get("params").asJsonObject())
                .map(it -> it.get("id"))
                .filter(Objects::nonNull)
                .filter(it -> it.getValueType() == JsonValue.ValueType.STRING)
                .map(JsonString.class::cast)
                .map(JsonString::getString)
                .toList();
        if (ids.size() != requests.size()) { // wrongly formatted request, let it go
            return super.execute(request, httpRequest, httpResponse);
        }

        // (12)
        if (ids.size() > 50) { // likely not supported properly - later group by chunks of 50, for now it is ok
            return super.execute(request, httpRequest, httpResponse);
        }

        // (13)
        httpResponse.setHeader("JsonRpc-Rewritten", "true");

        // (14)
        final var byId = searcher.findByMultipleCIS(ids, JsonValue.TRUE.equals(icons));
        final var reqIterator = requests.iterator();
        final var responses = ids.stream() // (15)
                .map(it -> {
                    final var response = new Response();
                    response.setJsonrpc("2.0");

                    final var id = reqIterator.next().get("id");
                    if (id != null) { // propagate request id if the client uses it to reconcile the responses
                        response.setId(id);
                    }

                    final var value = byId.get(it);
                    if (value == null) {
                        response.setError(new Response.ErrorResponse(
                                404, "Entity '" + id + "' not found",
                                jsonBuilderFactory.createObjectBuilder()
                                        .add("id", it)
                                        .build()));
                    } else {
                        // or use johnzon more optimized round trip using a JsonValueReader or alike
                        response.setResult(jsonb.fromJson(jsonb.toJson(value), JsonObject.class));
                    }
                    return response;
                })
                .toArray(Response[]::new);
        return completedFuture(responses); // (16)
    }
}
  1. If not a bulk request we don’t optimize it, so we early quit,

  2. If not an empty bulk request we can’t optimize it, so we early quit,

  3. If the first bulk request is not an object we can’t evaluate it so use the default runtime to fail,

  4. If the first bulk item does not have a method attribute we can’t evaluate it so use the default runtime to fail,

  5. If previous conditions are met, try to optimize the execution,

  6. This method enables us to route the optimizations specifically for a method (simpler to maintain),

  7. If any request of the bulk is not a request then we can’t evaluate it so use the default runtime to fail,

  8. If any request of the bulk is missing some parameter (keep in mind id and logo are required there) then we use the default runtime to fail,

  9. If logo value is invalid use the default runtime to fail,

  10. If multiple logo values, keep the default runtime execution (one by one instead of at once) - note that here we could group by logo value to do 2 optimizations or a more advanced query to optimize the runtime (out of scope of this post),

  11. Extract all identifiers for the bulk request,

  12. If we have too many requests then fail - note that we could group there too but bulk request max size is 50 so we just aligned the value there (and luckily it is also aligned on the most common SQL limitations),

  13. Totally optional but we enrich the response to notify the caller we rewrote the execution (can be useful for debug purposes),

  14. We do the optimized execution,

  15. we map the result of the optimized execution to atomic Response (for each incoming request of the bulk request),

  16. Since our optimized execution was synchronous we wrap the responses in a CompletionStage - not needed if you already have one.

Tip

With java streams, you can write all these checks more fluently (note that some helper methods can ease that even more). All the trick relies on the fact to pass an enriched state between stream (of one element there) states:

public CompletionStage<?> execute(final JsonStructure request) {
    return Stream.of(request)
            // check it is a bulk request
            .filter(req -> req.getValueType() == JsonValue.ValueType.ARRAY)
            .map(JsonValue::asJsonArray)
            // check it has something to execute
            .filter(array -> !array.isEmpty())
            // create a state with the first item
            .map(array -> new Tuple2<>(array, array.get(0)))
            // check first item is well formatted (ie it is an object)
            .filter(arrayWithFirstItem -> arrayWithFirstItem.second().getValueType() == JsonValue.ValueType.OBJECT)
            // create a test to simplify first item checks
            .map(arrayWithFirstObj -> {
                final var firstItemAsObject = arrayWithFirstObj.second().asJsonObject();
                return new Tuple2<>(
                        arrayWithFirstObj.first(),
                        new Tuple2<>(
                                firstItemAsObject,
                                firstItemAsObject.get("method")));
            })
            // check method is a valid json string and then pass it in the state
            .filter(arrayWithFirstObjAndMethod -> arrayWithFirstObjAndMethod.second().second().getValueType() == JsonValue.ValueType.STRING)
            .map(arrayWithFirstObjAndMethod -> new Tuple2<>(
                    arrayWithFirstObjAndMethod.first(),
                    new Tuple2<>(
                            arrayWithFirstObjAndMethod.second().first(),
                            JsonString.class.cast(arrayWithFirstObjAndMethod.second().second()).getString())))
            // check it is the method we want to optimize
            .filter(arrayWithFirstObjAndMethod -> "myMethod".equals(arrayWithFirstObjAndMethod.second().second()))
            .findFirst() // check all previous conditions before converting the code
            // unwrap to get the array
            .map(Tuple2::first)
            .map(this::optimize) // optimization is preconditions are true
            .or(this::defaultImpl); // else default impl without optimizations
}