A controller turns HTTP into method calls. This chapter covers how requests are mapped to methods and how their parts are bound to parameters, how a return value becomes a response, how the app and the server share one declaration of their API, and how a server holds WebSocket connections open. A first server introduced the shape; this is the detail.

Mapping requests

A @RestController is a class whose methods answer requests. It’s also a bean, so its constructor receives whatever it depends on, as Backend beans and dependency injection describes. Each mapped method names a verb and a path:

@RestController
@RequestMapping("/api/products")
public class Products {
    private final Map<Long, Map<String, Object>> products =
            new LinkedHashMap<Long, Map<String, Object>>();

    @GetMapping("/{id}")                                       // GET /api/products/42
    public synchronized Map<String, Object> get(@PathVariable("id") long id) {
        return products.get(Long.valueOf(id));                 // null answers 404
    }

    @GetMapping("/search")                                     // GET /api/products/search?q=mug
    public synchronized List<Map<String, Object>> search(
            @RequestParam(value = "q", required = false) String query,
            @RequestParam(value = "limit", defaultValue = "20") int limit) {
        List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
        for (Map<String, Object> p : products.values()) {
            if (out.size() < limit
                    && (query == null || String.valueOf(p.get("name")).contains(query))) {
                out.add(p);
            }
        }
        return out;
    }

    @PostMapping                                               // POST /api/products
    @ResponseStatus(201)
    public synchronized Map<String, Object> create(
            @RequestBody Map<String, Object> body,
            @RequestHeader(value = "Idempotency-Key", required = false) String key) {
        Long id = Long.valueOf(products.size() + 1);
        Map<String, Object> product = new LinkedHashMap<String, Object>(body);
        product.put("id", id);
        products.put(id, product);
        return product;
    }

    @DeleteMapping("/{id}")                                    // void answers 204
    public synchronized void delete(@PathVariable("id") long id) {
        products.remove(Long.valueOf(id));
    }

    @GetMapping("/{id}/label")
    public HttpServer.Response label(HttpServer.Request request, @PathVariable("id") long id) {
        byte[] text = ("product " + id).getBytes();
        return request.respond(200, "text/plain; charset=utf-8", text);
    }
}

@GetMapping, @PostMapping, @PutMapping, @PatchMapping and @DeleteMapping cover the usual verbs, and @RequestMapping(value = "/x", method = "OPTIONS") covers the rest. On the class, @RequestMapping is a prefix for every mapping inside it, and a mapping with no path answers on the prefix itself, as create does above. A mapping may list several paths, which is how a route keeps an old URL working.

A {name} segment matches one path segment and is bound with @PathVariable. Within one controller a literal route is tried before a route with variables that would also match it, so /api/products/search above reaches search rather than get with an id of search.

What the build refuses is a route nothing could ever reach. Two methods answering the same verb and path shape are an error, even when their variables are named differently, because a variable’s name isn’t part of what a request carries. The same is true across controllers: a variable route in one controller that would answer a literal route of another is reported, since the routers are tried in turn and the literal one would never run.

Binding the parts of a request

Every parameter of a mapped method says where its value comes from, with exactly one annotation:

AnnotationReadsBinds to

@PathVariable("id")

a {id} segment of the path

String, or a numeric or boolean primitive

@RequestParam("q")

a query parameter

String, or a numeric or boolean primitive

@RequestHeader("Idempotency-Key")

a request header

String, or a numeric or boolean primitive

@RequestBody

the body

String, a Map or a List, or a class of your own and collections of it

none, typed HttpServer.Request

the request itself

anything the request exposes

The name is required. Java drops parameter names when it compiles unless it’s told to keep them, and a router that guessed would bind the wrong value in silence, so @RequestParam("q") names the parameter the client sends rather than the variable in the method. A parameter with no annotation, or with two, is a build error: one parameter reads from one place, which matters most for an input like a credential that could otherwise arrive from a query string when the code meant a header.

@RequestParam, @RequestHeader and @RequestBody are required unless they say otherwise. A request that leaves out a required value is answered 400, with the value’s name in the body, before the method runs. defaultValue supplies a value for one that’s missing. Two combinations are refused at build time, because each would hand the method a value nobody sent: required = false on a primitive with no defaultValue, since an int can’t hold "absent" and would arrive as 0; and a defaultValue that isn’t a value of the parameter’s type.

A body is parsed as JSON. A String, Map or List parameter gets it as parsed; a class of your own — an entity, a DTO — or a List<Order> of them is filled in by a codec the build writes for that class, as Jackson fills one in for a Spring controller. Your own classes as JSON describes the form.

A request target with a malformed escape sequence, or one that isn’t valid UTF-8, is answered 400 before any route is tried, rather than as a 404 the client couldn’t explain.

What a method returns

Return typeResponse

void

204 with no body, or the @ResponseStatus code.

String

The text, as text/plain; charset=utf-8, with 200 or the @ResponseStatus code.

A Map, List, Set, primitive or box, or a class of your own and collections of it

JSON, with 200 or the @ResponseStatus code.

HttpServer.Response

Exactly what the method built. @ResponseStatus on such a method is a build error, since the response already carries its own status.

null, from any of the above

404.

Any other return type is a build error: a JDK class with no JSON form, such as java.io.File, would otherwise come out as the JSON string of its toString(), with the build and the request both reporting success. @ResponseStatus takes a final status between 200 and 599.

Your own classes as JSON

A method can return an entity or any class of your own, or take one as its body, and the build writes the JSON codec for that class and for every class its fields reach. There is no reflection at run time: the codec is ordinary code that reads and writes each field by name, so it costs what the hand-written version would.

public class Order {
    public long id;
    public String customer;
    @JsonProperty("placed_at")
    public Date placedAt;
    public List<OrderLine> lines = new ArrayList<OrderLine>();
}
public class OrderLine {
    public String sku;
    public int quantity;
}
@RestController
@RequestMapping("/orders")
public class OrdersApi {
    private final Map<Long, Order> orders =
            Collections.synchronizedMap(new LinkedHashMap<Long, Order>());
    private final AtomicLong ids = new AtomicLong();

    @PostMapping
    public Order place(@RequestBody Order order) {
        order.id = ids.incrementAndGet();
        order.placedAt = new Date();
        orders.put(Long.valueOf(order.id), order);
        return order;
    }

    @GetMapping("/{id}")
    public Order find(@PathVariable("id") long id) {
        return orders.get(Long.valueOf(id));    // null is a 404
    }
}

A POST /orders with {"customer":"Ada","lines":[{"sku":"A-1","quantity":2}]} answers with the stored order:

{"id":1,"customer":"Ada","placed_at":1740821400000,"lines":[{"sku":"A-1","quantity":2}]}

The JSON form is the one the app’s @Mapped mapper uses, so a class shared by the app and the server reads the same on both sides:

  • The fields are the class’s own and its superclasses', except static and transient ones. A public field is used directly; any other through its getX (or isX) and setX methods, and is left out when it has neither.

  • @JsonProperty("due_at") renames a field and @JsonIgnore leaves one out, both from com.codename1.annotations.

  • A Date is written as milliseconds since the epoch, which is Jackson’s default and what the app’s mapper reads, and is read from that or from an ISO-8601 date such as 2025-03-01T09:30:00Z. A byte[] is base64, an enum is its name.

  • A subclass is written with its own fields, even where a method declares the superclass.

  • A field declared Object, or a raw Map or List, is written by what it holds when the server runs. A value of a class the build writes a codec for goes through that codec; a value of any other class of yours is answered 500 rather than written as its toString(), so declare the field with its type.

  • A body’s unknown members are ignored and absent ones leave the field as the constructor set it, as a Spring Boot application does.

A body the codec can’t read — a string where a number belongs, an enum name that doesn’t exist — is answered 400 with where it was and what was expected:

$.items[2].due: expected a number or an ISO-8601 date, got true

Two objects that refer to each other, such as an order whose lines point back at it, would be written forever, so a response that nests more than 64 objects deep is answered 500 with a message naming the class; mark the field that points back @JsonIgnore. The build refuses what it can’t write a codec for, and says why: a field typed by a type variable, such as List<T> in a generic Page<T>; a body class with no constructor without arguments; an interface; and an array other than byte[].

A method that throws is answered 500 with the body internal error, and the exception is logged. The message isn’t sent to the client, because an exception message is the most common way a server leaks its internals. To answer a failure with a status and a body of your choosing, return an HttpServer.Response, built with request.respond(status, contentType, bytes) or request.respondJson(status, value).

Taking the request itself

A parameter typed HttpServer.Request receives the request, which is the way to anything the binding annotations don’t model: every header, the raw body, the session, or a response with headers of its own. The label method in the example above takes it for that reason.

A request’s byte arrays belong to the connection and are reused by the next request on it, so a handler that keeps the body beyond its own call copies what it needs. That’s the price of a router that allocates nothing for a route without path variables.

Serving static files

cn1.static.root names a directory, and the server answers requests under cn1.static.prefix (/static by default) with its files, after every route has had its chance. A directory is answered with its cn1.static.index file, and every file carries cn1.static.cacheControl. The resolved path must be inside the root, checked by resolving it on disk rather than by inspecting the request, so ../, an encoded escape sequence and a symbolic link out of the tree are all refused. Conditional requests and ranges are honoured, and where the platform supports it the file is sent from the page cache to the socket without passing through the process.

Sharing the contract with the app

This is where having the same language on both ends stops being a slogan. An interface annotated for the REST client generates the app’s client:

@RestClient
public interface NotesApi {
    @GET("/notes/{id}")
    void note(@Path("id") String id, OnComplete<Response<Note>> callback);

    @POST("/notes")
    void create(@Body Note note, OnComplete<Response<Note>> callback);
}

Building the backend module with -Dcn1.restServer=true generates two more types from that same interface: NotesApiServer, a synchronous interface the backend implements, and NotesApiDispatcher, which routes a method, path and body to it and binds the path and query parameters.

public class NotesEndpoint implements NotesApiServer {
    private final Map<String, Note> notes =
            Collections.synchronizedMap(new HashMap<String, Note>());

    public Note note(String id) {                // no callback: this IS the server
        return notes.get(id);
    }

    public Note create(Note note) {
        notes.put(String.valueOf(note.id), note);
        return note;
    }
}

The client’s methods are asynchronous because a UI can’t block; the server’s methods are synchronous because a handler has nothing to call back into. One declaration produces both shapes, which is what gRPC does and for the same reason.

The payoff is that changing the contract breaks the build on whichever side did not follow it, instead of producing a response the app fails to parse in the field. The data transfer objects are shared rather than transcribed, and their codecs are generated on both sides, so there is no handwritten mapping layer to drift.

The server half is off by default. Every existing project carries these interfaces for its client alone, and generating server classes into those builds would grow them for nothing.

Real-time with WebSockets

The server speaks RFC 6455, so a Codename One app can hold a live connection to a Codename One server with the same language on both ends. The client half is com.codename1.io.WebSocket, which every port has shipped for years; this is the other half of it.

An endpoint implements com.codename1.backend.WebSocket:

public static final class Echo implements WebSocket {
    public void onOpen(WebSocketSession session) throws IOException {
        session.sendText("welcome");
    }

    public void onText(WebSocketSession session, String message) throws IOException {
        session.sendText(message);
    }

    public void onBinary(WebSocketSession session, byte[] message, int offset, int length)
            throws IOException {
        session.sendBinary(message, offset, length);
    }
}

The build finds it, the way it finds a @RestController:

@WebSocketMapping("/chat")
public static final class ChatEndpoint implements WebSocket {
    public void onOpen(WebSocketSession session) { }

    public void onText(WebSocketSession session, String message) { }

    public void onBinary(WebSocketSession session, byte[] message, int offset, int length) { }
}

The annotation goes on the type rather than on a method, because a @GetMapping marks a call and a WebSocket is a connection: its contract is seven callbacks that share per-connection state, which is an object. What it keeps from @GetMapping is what a reader cares about — the path is relative to a class-level @RequestMapping, a constructor taking a DataSource or an EntityManager is injected the same way, and two endpoints claiming one path is a build error rather than something registration order decides. Write the path’s characters out: an escaped character such as %61 in the mapping is a build error too, since an upgrade is matched against the decoded path, which an escaped mapping never equals. An endpoint that injects a @RequestScope or @SessionScope bean, directly or through a singleton, builds with a warning: its callbacks run outside any HTTP request, so there’s no request or session to find the bean in, and using it there throws IllegalStateException, as in Spring. Keep per-connection state in the session’s attachment instead.

Only onOpen, onText and onBinary have to be written. onPing, onPong, onClose and onError have empty defaults, and a PING is answered with its PONG before the endpoint is told, so an endpoint that ignores them still keeps its connections alive.

One endpoint, many connections

An endpoint is created once for the route, not once per client, which is the same shape a @RestController has. Everything belonging to one client lives on the WebSocketSession the callbacks are handed, and setAttachment is where an endpoint puts its own per-connection state.

Messages arrive whole. A client that splits a two megabyte upload into thirty-two frames produces one onBinary, and a text message is validated as UTF-8 before it is decoded, so a handler never sees a replacement character standing in for bytes the client didn’t send. The array passed to onBinary is the session’s own reassembly buffer and is valid only until that call returns — the same contract HttpServer.Request carries, and for the same reason. An endpoint that keeps the bytes copies the range it wants.

Sending from somewhere else

A callback runs on the thread that owns its connection, and the server reads nothing more from that connection until it returns. An endpoint may therefore block, and a slow one slows down its own client and nobody else’s.

Sending is the other way round: a session may be written to from any thread, which is what makes a broadcast possible.

public static final class Room implements WebSocket {
    // Every open session in this room. A websocket endpoint is one object shared
    // by every connection, so anything per-room lives here and anything per-client
    // lives on the session.
    private final List sessions = Collections.synchronizedList(new ArrayList());

    public void onOpen(WebSocketSession session) {
        sessions.add(session);
    }

    public void onText(WebSocketSession session, String message) {
        // Sending from a thread other than the one that owns a connection is
        // supported, and this is why: a broadcast reaches every session but one
        // from the thread that received the message.
        Object[] open = sessions.toArray();
        for(int iter = 0 ; iter < open.length ; iter++) {
            WebSocketSession other = (WebSocketSession)open[iter];
            if(other == session) {
                continue;
            }
            try {
                other.sendText(message);
            } catch (IOException err) {
                // A send fails when that peer has gone. It is already being torn
                // down; onClose will take it out of the list.
                other.abort();
            }
        }
    }

    public void onBinary(WebSocketSession session, byte[] message, int offset, int length) {
    }

    public void onClose(WebSocketSession session, int code, String reason) {
        sessions.remove(session);
    }
}

Each session serializes its own writers, so two threads can’t interleave halves of two messages on one connection. What the server doesn’t do is queue: sendText writes to the socket and blocks if the peer has stopped reading. That’s honest back pressure rather than a buffer that grows until the machine runs out, but it means one unresponsive client can hold up a loop that broadcasts to every other one. A server with many clients and large messages should broadcast from a small pool rather than from the receiving thread.

Subprotocols

An endpoint that speaks more than one protocol lists them best first:

public static final class Graph implements WebSocket {
    public String[] getSubprotocols() {
        // The server's order decides, not the client's.
        return new String[]{"graphql-transport-ws", "graphql-ws"};
    }

    public void onOpen(WebSocketSession session) {
        if("graphql-ws".equals(session.getSubprotocol())) {
            // The legacy protocol; answer in its shape.
        }
    }

    public void onText(WebSocketSession session, String message) {
    }

    public void onBinary(WebSocketSession session, byte[] message, int offset, int length) {
    }
}

The server’s order decides, not the client’s, so a client can’t select a deprecated protocol over a current one by listing it first. When nothing matches, the handshake still succeeds and names no protocol, which is what the standard describes; getSubprotocol then answers null.

A connection, not a request

A WebSocket is a connection that stopped being HTTP, and the difference is something an author can feel.

Under virtual threads — the default on the packaged runtime — each connection owns one, and a parked virtual thread costs a stack rather than an operating system thread. Ten thousand mostly silent connections is the workload that model was built for.

On the thread pool it’s a worker per connection for as long as the connection lasts. That’s the mode the local development loop always runs in, because the JVM has no virtual threads here, and it’s also the mode any TLS server runs in. A pooled deployment serving many WebSockets has to size workers for them, because unlike a request they don’t give the worker back.

That makes workers the ceiling on concurrent connections in pool mode, and going past it fails in a way worth knowing about: the process stays healthy, the listener stays bound, and new connections are simply refused, because no worker ever comes back to accept them. An eight-worker server driven by the conformance suite reached case 9.4.4 and refused everything after it. The server says so now — it logs once when WebSockets hold half the pool — and the two ways out are more workers or a shorter CN1_WS_IDLE_TIMEOUT_MS, so abandoned connections give their worker back sooner. On virtual threads none of this applies.

The idle timeout is separate for the same reason. CN1_HTTP_TIMEOUT_MS sheds a client that began a request and stopped; a WebSocket is idle by design and would be shed within seconds by that rule. CN1_WS_IDLE_TIMEOUT_MS governs these instead, defaults to five minutes, and accepts 0 for connections that may stay silent indefinitely. CN1_WS_MAX_MESSAGE_MB bounds reassembly, because a message is as large as the peer chooses to make it.

getMetrics() reports webSocketConnections, and an open session doesn’t count towards activeRequests — it’s a connection, and counting it would make an idle server read as permanently saturated.

stop() sends every open connection a 1001 "going away" close before the drain window, so a shutdown reads as one to the client rather than as a network failure.

How the frame layer is checked

Most of RFC 6455 is about frames a conformant client never sends, which is how a server ends up wrong in ways nothing it talks to will reveal. The Autobahn|Testsuite is what finds those, and vm/backend/ws-conformance.sh runs it:

vm/backend/ws-conformance.sh --arm javase     # or --arm native

It needs Docker or Podman, starts an echo server, and drives ~300 cases through it. The gate has no per-case tolerance: every case must pass, and NON-STRICT counts as a failure, because a lenient frame parser is the thing this is looking for. The set of cases that ran also has to match a committed manifest, so the suite can’t shrink unnoticed to the ones that happen to pass.

Sections 12 and 13 are excluded while permessage-deflate is unimplemented. They’re excluded rather than tolerated: accepting their result as a pass would also accept it for a case that used to work.