diff --git a/api-data-store-server-java/src/main/java/ch/ethz/sis/afsclient/client/AfsClientV2.java b/api-data-store-server-java/src/main/java/ch/ethz/sis/afsclient/client/AfsClientV2.java
index da2f281af73304a1c12433752f680c327147a034..aa456946a960d8562a75a5d28c8700730cfb07f8 100644
--- a/api-data-store-server-java/src/main/java/ch/ethz/sis/afsclient/client/AfsClientV2.java
+++ b/api-data-store-server-java/src/main/java/ch/ethz/sis/afsclient/client/AfsClientV2.java
@@ -39,7 +39,7 @@ public final class AfsClientV2 implements PublicAPI
 
     private final URI serverUri;
 
-    private final JsonObjectMapper jsonObjectMapper;
+    private static final JsonObjectMapper jsonObjectMapper = new JacksonObjectMapper();
 
     public AfsClientV2(final URI serverUri)
     {
@@ -51,7 +51,6 @@ public final class AfsClientV2 implements PublicAPI
         this.maxReadSizeInBytes = maxReadSizeInBytes;
         this.timeout = timeout;
         this.serverUri = serverUri;
-        this.jsonObjectMapper = new JacksonObjectMapper();
     }
 
     public URI getServerUri()
@@ -147,12 +146,12 @@ public final class AfsClientV2 implements PublicAPI
 
     @Override
     public @NonNull Boolean write(@NonNull final String owner, @NonNull final String source,
-            @NonNull final Long offset, final byte @NonNull [] data,
-            final byte @NonNull [] md5Hash) throws Exception
+            @NonNull final Long offset, @NonNull final byte[] data,
+            @NonNull  final byte[] md5Hash) throws Exception
     {
         validateSessionToken();
         return request("POST", "write", Boolean.class, Map.of("owner", owner, "source", source,
-                "offset", offset.toString(), "data", Base64.getEncoder().encodeToString(data), "md5Hash", getMd5HexString(md5Hash)));
+                "offset", offset.toString(), "data", Base64.getEncoder().encodeToString(data), "md5Hash", Base64.getEncoder().encodeToString(md5Hash)));
     }
 
     @Override
@@ -304,20 +303,10 @@ public final class AfsClientV2 implements PublicAPI
                 throw new IllegalArgumentException(
                         "Server error HTTP response. Missing content-type");
             }
-            String content = httpResponse.headers().map().get("content-type").get(0);
+            String contentType = httpResponse.headers().map().get("content-type").get(0);
+            byte[] responseBody = httpResponse.body();
 
-            switch (content)
-            {
-                case "text/plain":
-                    return parseFormDataResponse(responseType, httpResponse);
-                case "application/json":
-                    return parseJsonResponse(httpResponse);
-                case "application/octet-stream":
-                    return (T) httpResponse.body();
-                default:
-                    throw new IllegalArgumentException(
-                            "Client error HTTP response. Unsupported content-type received.");
-            }
+            return getResponseResult(responseType, contentType, responseBody);
         } else if (statusCode >= 400 && statusCode < 500)
         {
             // jsonObjectMapper can't deserialize immutable lists sent in the error message.
@@ -332,23 +321,40 @@ public final class AfsClientV2 implements PublicAPI
         }
     }
 
-    private <T> T parseFormDataResponse(Class<T> responseType, HttpResponse<byte[]> httpResponse)
+    public static <T> T getResponseResult(Class<T> responseType, String contentType, byte[] responseBody)
+            throws Exception
+    {
+        switch (contentType)
+        {
+            case "text/plain":
+                return AfsClientV2.parseFormDataResponse(responseType, responseBody);
+            case "application/json":
+                return AfsClientV2.parseJsonResponse(responseBody);
+            case "application/octet-stream":
+                return (T) responseBody;
+            default:
+                throw new IllegalArgumentException(
+                        "Client error HTTP response. Unsupported content-type received.");
+        }
+    }
+
+    private static <T> T parseFormDataResponse(Class<T> responseType, byte[] responseBody)
     {
         if (responseType == null) {
             return null;
         } else if (responseType == String.class) {
-            return responseType.cast(new String(httpResponse.body()));
+            return responseType.cast(new String(responseBody, StandardCharsets.UTF_8));
         } else if (responseType == Boolean.class) {
-            return  responseType.cast(Boolean.parseBoolean(new String(httpResponse.body())));
+            return  responseType.cast(Boolean.parseBoolean(new String(responseBody, StandardCharsets.UTF_8)));
         }
 
         throw new IllegalStateException("Unreachable statement!");
     }
 
-    private <T> T parseJsonResponse(final HttpResponse<byte[]> httpResponse) throws Exception
+    private static <T> T parseJsonResponse(byte[] responseBody) throws Exception
     {
         final ApiResponse response =
-                jsonObjectMapper.readValue(new ByteArrayInputStream(httpResponse.body()),
+                jsonObjectMapper.readValue(new ByteArrayInputStream(responseBody),
                         ApiResponse.class);
 
         if (response.getError() != null)
@@ -367,13 +373,4 @@ public final class AfsClientV2 implements PublicAPI
             throw new IllegalStateException("No session information detected!");
         }
     }
-
-    private String getMd5HexString(byte[] md5) {
-        BigInteger no = new BigInteger(1, md5);
-        String hashtext = no.toString(16);
-        while (hashtext.length() < 32) {
-            hashtext = "0" + hashtext;
-        }
-        return hashtext;
-    }
 }
diff --git a/server-data-store/src/main/java/ch/ethz/sis/afsserver/http/HttpResponse.java b/server-data-store/src/main/java/ch/ethz/sis/afsserver/http/HttpResponse.java
index 64484a7568a3419cb1ae7414eafcea3c112a07f6..4071cc5463353049a9a89bb172e37edb1072c473 100644
--- a/server-data-store/src/main/java/ch/ethz/sis/afsserver/http/HttpResponse.java
+++ b/server-data-store/src/main/java/ch/ethz/sis/afsserver/http/HttpResponse.java
@@ -24,6 +24,7 @@ import lombok.Value;
 @Builder(toBuilder = true)
 @AllArgsConstructor(access = AccessLevel.PUBLIC)
 public class HttpResponse {
+    public static final String CONTENT_TYPE_TEXT = "text/plain";
     public static final String CONTENT_TYPE_BINARY_DATA = "application/octet-stream";
     public static final String CONTENT_TYPE_JSON = "application/json";
 
diff --git a/server-data-store/src/main/java/ch/ethz/sis/afsserver/http/impl/NettyHttpHandlerV2.java b/server-data-store/src/main/java/ch/ethz/sis/afsserver/http/impl/NettyHttpHandlerV2.java
new file mode 100644
index 0000000000000000000000000000000000000000..01244f61ccee0f10755b69febd5139894d204359
--- /dev/null
+++ b/server-data-store/src/main/java/ch/ethz/sis/afsserver/http/impl/NettyHttpHandlerV2.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright ETH 2022 - 2023 Zürich, Scientific IT Services
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package ch.ethz.sis.afsserver.http.impl;
+
+import ch.ethz.sis.afsserver.http.HttpResponse;
+import ch.ethz.sis.afsserver.http.HttpServerHandler;
+import ch.ethz.sis.shared.log.LogManager;
+import ch.ethz.sis.shared.log.Logger;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.handler.codec.http.*;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Set;
+
+import static io.netty.handler.codec.http.HttpMethod.*;
+
+public class NettyHttpHandlerV2 extends ChannelInboundHandlerAdapter
+{
+
+    private static final Logger logger = LogManager.getLogger(NettyHttpServer.class);
+
+    private static final byte[] NOT_FOUND = "404 NOT FOUND".getBytes();
+
+    private static final ByteBuf NOT_FOUND_BUFFER = Unpooled.wrappedBuffer(NOT_FOUND);
+
+    private static final Set<HttpMethod> allowedMethods = Set.of(GET, POST, PUT, DELETE);
+
+    private final String uri;
+
+    private final HttpServerHandler httpServerHandler;
+
+    public NettyHttpHandlerV2(String uri, HttpServerHandler httpServerHandler)
+    {
+        this.uri = uri;
+        this.httpServerHandler = httpServerHandler;
+    }
+
+    @Override
+    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception
+    {
+        if (msg instanceof FullHttpRequest)
+        {
+            final FullHttpRequest request = (FullHttpRequest) msg;
+            QueryStringDecoder queryStringDecoderForPath = new QueryStringDecoder(request.uri(), true);
+
+            if (queryStringDecoderForPath.path().equals(uri) &&
+                    allowedMethods.contains(request.method()))
+            {
+                FullHttpResponse response = null;
+                ByteBuf content = request.content();
+                try
+                {
+                    QueryStringDecoder queryStringDecoderForParameters = null;
+                    byte[] array = new byte[content.readableBytes()];
+                    content.readBytes(array);
+
+                    if (GET.equals(request.method())) {
+                        queryStringDecoderForParameters = queryStringDecoderForPath;
+                    } else {
+                        queryStringDecoderForParameters = new QueryStringDecoder(new String(array, StandardCharsets.UTF_8), StandardCharsets.UTF_8, false);
+                    }
+
+                    HttpResponse apiResponse = httpServerHandler.process(request.method(),
+                            queryStringDecoderForParameters.parameters(), array);
+                    HttpResponseStatus status = (!apiResponse.isError()) ?
+                            HttpResponseStatus.OK :
+                            HttpResponseStatus.BAD_REQUEST;
+                    response = getHttpResponse(
+                            status,
+                            apiResponse.getContentType(),
+                            Unpooled.wrappedBuffer(apiResponse.getBody()),
+                            apiResponse.getBody().length);
+                    ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
+                } finally
+                {
+                    content.release();
+                }
+            } else
+            {
+                FullHttpResponse response = getHttpResponse(
+                        HttpResponseStatus.NOT_FOUND,
+                        "text/plain",
+                        NOT_FOUND_BUFFER,
+                        NOT_FOUND.length);
+                ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
+            }
+        } else
+        {
+            super.channelRead(ctx, msg);
+        }
+    }
+
+    @Override
+    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception
+    {
+        ctx.flush();
+    }
+
+    @Override
+    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception
+    {
+        logger.catching(cause);
+        byte[] causeBytes = cause.getMessage().getBytes();
+        FullHttpResponse response = getHttpResponse(
+                HttpResponseStatus.INTERNAL_SERVER_ERROR,
+                "text/plain",
+                Unpooled.wrappedBuffer(causeBytes),
+                causeBytes.length
+        );
+        ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
+    }
+
+    public FullHttpResponse getHttpResponse(
+            HttpResponseStatus status,
+            String contentType,
+            ByteBuf content,
+            int contentLength)
+    {
+        FullHttpResponse response = new DefaultFullHttpResponse(
+                HttpVersion.HTTP_1_1,
+                status,
+                content
+        );
+        response.headers().set(HttpHeaderNames.CONTENT_LENGTH, contentLength);
+        response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
+        response.headers().set(HttpHeaderNames.CONTENT_TYPE, contentType);
+        response.headers().set(HttpHeaderNames.CONNECTION, "close");
+        return response;
+    }
+}
diff --git a/server-data-store/src/test/java/ch/ethz/sis/afsserver/core/AbstractPublicAPIWrapperV2.java b/server-data-store/src/test/java/ch/ethz/sis/afsserver/core/AbstractPublicAPIWrapperV2.java
new file mode 100644
index 0000000000000000000000000000000000000000..661e8a2258ae02b7c290a9765617f992aaab4b26
--- /dev/null
+++ b/server-data-store/src/test/java/ch/ethz/sis/afsserver/core/AbstractPublicAPIWrapperV2.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright ETH 2022 - 2023 Zürich, Scientific IT Services
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package ch.ethz.sis.afsserver.core;
+
+import ch.ethz.sis.afsapi.api.PublicAPI;
+import ch.ethz.sis.afsapi.dto.File;
+import lombok.NonNull;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+public abstract class AbstractPublicAPIWrapperV2 implements PublicAPI
+{
+
+    public abstract <E> E process(Class<E> responseType, String method, Map<String, Object> params);
+
+    @Override
+    public List<File> list(@NonNull String owner, @NonNull String source,
+            @NonNull Boolean recursively) throws Exception
+    {
+        Map<String, Object> args = Map.of(
+                "owner", owner,
+                "source", source,
+                "recursively", recursively);
+        return process(List.class, "list", args);
+    }
+
+    @Override
+    public byte[] read(@NonNull String owner, @NonNull String source, @NonNull Long offset,
+            @NonNull Integer limit) throws Exception
+    {
+        Map<String, Object> args = Map.of(
+                "owner", owner,
+                "source", source,
+                "offset", offset,
+                "limit", limit);
+        return process(byte[].class,"read", args);
+    }
+
+    @Override
+    public Boolean write(@NonNull String owner, @NonNull String source, @NonNull Long offset,
+            @NonNull byte[] data, @NonNull byte[] md5Hash) throws Exception
+    {
+        Map<String, Object> args = Map.of(
+                "owner", owner,
+                "source", source,
+                "offset", offset,
+                "data", data,
+                "md5Hash", md5Hash);
+        return process(Boolean.class, "write", args);
+    }
+
+    @Override
+    public Boolean delete(@NonNull String owner, @NonNull String source) throws Exception
+    {
+        Map<String, Object> args = Map.of(
+                "owner", owner,
+                "source", source);
+        return process(Boolean.class, "delete", args);
+    }
+
+    @Override
+    public Boolean copy(@NonNull String sourceOwner, @NonNull String source,
+            @NonNull String targetOwner, @NonNull String target) throws Exception
+    {
+        Map<String, Object> args = Map.of(
+                "sourceOwner", sourceOwner,
+                "source", source,
+                "targetOwner", targetOwner,
+                "target", target);
+        return process(Boolean.class,"copy", args);
+    }
+
+    @Override
+    public Boolean move(@NonNull String sourceOwner, @NonNull String source,
+            @NonNull String targetOwner, @NonNull String target) throws Exception
+    {
+        Map<String, Object> args = Map.of(
+                "sourceOwner", sourceOwner,
+                "source", source,
+                "targetOwner", targetOwner,
+                "target", target);
+        return process(Boolean.class, "move", args);
+    }
+
+    @Override
+    public void begin(UUID transactionId) throws Exception
+    {
+        //TODO: Unused
+    }
+
+    @Override
+    public Boolean prepare() throws Exception
+    {
+        //TODO: Unused
+        return true;
+    }
+
+    @Override
+    public void commit() throws Exception
+    {
+        //TODO: Unused
+    }
+
+    @Override
+    public void rollback() throws Exception
+    {
+        //TODO: Unused
+    }
+
+    @Override
+    public List<UUID> recover() throws Exception
+    {
+        //TODO: Unused
+        return null;
+    }
+
+    @Override
+    public String login(String userId, String password) throws Exception
+    {
+        //TODO: Unused
+        return null;
+    }
+
+    @Override
+    public Boolean isSessionValid() throws Exception
+    {
+        //TODO: Unused
+        return null;
+    }
+
+    @Override
+    public Boolean logout() throws Exception
+    {
+        //TODO: Unused
+        return null;
+    }
+}
diff --git a/server-data-store/src/test/java/ch/ethz/sis/afsserver/impl/APIServerAdapterWrapperV2.java b/server-data-store/src/test/java/ch/ethz/sis/afsserver/impl/APIServerAdapterWrapperV2.java
new file mode 100644
index 0000000000000000000000000000000000000000..810d8460872b687d1ddf45997e3d369c2c7e9f03
--- /dev/null
+++ b/server-data-store/src/test/java/ch/ethz/sis/afsserver/impl/APIServerAdapterWrapperV2.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright ETH 2022 - 2023 Zürich, Scientific IT Services
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package ch.ethz.sis.afsserver.impl;
+
+import ch.ethz.sis.afsclient.client.AfsClientV2;
+import ch.ethz.sis.afsserver.core.AbstractPublicAPIWrapperV2;
+import ch.ethz.sis.afsserver.http.HttpResponse;
+import ch.ethz.sis.afsserver.server.impl.ApiServerAdapter;
+import ch.ethz.sis.shared.io.IOUtils;
+import ch.ethz.sis.shared.log.LogManager;
+import ch.ethz.sis.shared.log.Logger;
+import io.netty.handler.codec.http.HttpMethod;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+public class APIServerAdapterWrapperV2 extends AbstractPublicAPIWrapperV2
+{
+
+    private static final Logger logger = LogManager.getLogger(APIServerAdapterWrapperV2.class);
+
+    private ApiServerAdapter apiServerAdapter;
+
+
+    public APIServerAdapterWrapperV2(ApiServerAdapter apiServerAdapter)
+    {
+        this.apiServerAdapter = apiServerAdapter;
+    }
+
+    public Map<String, List<String>> getURIParameters(Map<String, Object> args)
+    {
+        Map<String, List<String>> result = new HashMap<>(args.size());
+        for (Map.Entry<String, Object> entry : args.entrySet())
+        {
+            if (entry.getValue() instanceof byte[])
+            {
+                result.put(entry.getKey(), List.of(IOUtils.encodeBase64((byte[]) entry.getValue())));
+            } else
+            {
+                result.put(entry.getKey(), List.of(String.valueOf(entry.getValue())));
+            }
+        }
+        return result;
+    }
+
+    public <E> E process(Class<E> responseType, String apiMethod, Map<String, Object> params)
+    {
+        try
+        {
+            HttpMethod httpMethod = ApiServerAdapter.getHttpMethod(apiMethod);
+            Map<String, List<String>> requestParameters = getURIParameters(params);
+            requestParameters.put("sessionToken", List.of(UUID.randomUUID().toString()));
+            requestParameters.put("method", List.of(apiMethod));
+
+            byte[] requestBody = null;
+
+            if (HttpMethod.GET.equals(httpMethod))
+            {
+                // Do nothing
+            } else if (HttpMethod.POST.equals(httpMethod) || HttpMethod.DELETE.equals(httpMethod))
+            {
+                // Do nothing
+            } else
+            {
+                throw new IllegalArgumentException("Not supported HTTP method type!");
+            }
+
+
+
+            HttpResponse response = apiServerAdapter.process(httpMethod, requestParameters, null);
+            String contentType = response.getContentType();
+            byte[] body = response.getBody();
+
+            return AfsClientV2.getResponseResult(responseType, contentType, body);
+        } catch (Throwable throwable)
+        {
+            throw new RuntimeException(throwable);
+        }
+    }
+
+}
diff --git a/server-data-store/src/test/java/ch/ethz/sis/afsserver/impl/APIServerWrapperV2.java b/server-data-store/src/test/java/ch/ethz/sis/afsserver/impl/APIServerWrapperV2.java
new file mode 100644
index 0000000000000000000000000000000000000000..6384b1ed15c5df46478c54964aebc0d37534c411
--- /dev/null
+++ b/server-data-store/src/test/java/ch/ethz/sis/afsserver/impl/APIServerWrapperV2.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright ETH 2022 - 2023 Zürich, Scientific IT Services
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package ch.ethz.sis.afsserver.impl;
+
+
+import ch.ethz.sis.afsserver.core.AbstractPublicAPIWrapperV2;
+import ch.ethz.sis.afsserver.server.APIServer;
+import ch.ethz.sis.afsserver.server.Response;
+import ch.ethz.sis.afsserver.server.impl.ApiRequest;
+import ch.ethz.sis.afsserver.server.impl.ApiResponseBuilder;
+import ch.ethz.sis.afsserver.server.performance.PerformanceAuditor;
+import ch.ethz.sis.shared.log.LogManager;
+import ch.ethz.sis.shared.log.Logger;
+
+import java.util.Map;
+import java.util.UUID;
+
+public class APIServerWrapperV2 extends AbstractPublicAPIWrapperV2
+{
+
+    private static final Logger logger = LogManager.getLogger(APIServerWrapperV2.class);
+
+    private APIServer apiServer;
+    private final ApiResponseBuilder apiResponseBuilder;
+
+    public APIServerWrapperV2(APIServer apiServer) {
+        this.apiServer = apiServer;
+        this.apiResponseBuilder = new ApiResponseBuilder();
+    }
+
+    public <E> E process(Class<E> responseType, String method, Map<String, Object> params) {
+        PerformanceAuditor performanceAuditor = new PerformanceAuditor();
+        // Random Session token just works for tests with dummy authentication
+        ApiRequest request = new ApiRequest("test", method, params, UUID.randomUUID().toString(), null, null);
+
+        try {
+            Response response = apiServer.processOperation(request, apiResponseBuilder, performanceAuditor);
+            return (E) response.getResult();
+        } catch (Throwable throwable) {
+            throw new RuntimeException(throwable);
+        }
+    }
+
+}