JSON & HTTP APIs
encoding/json in place of Jackson, net/http routing in place of Spring MVC, request limits and error mapping, middleware, and calling other services safely.
Section 24 of 31 8 min read
The first thing most Java backend developers build in Go is an HTTP API. The standard library covers the essentials: encoding/json for serialisation and net/http for both servers and clients. Many production Go services use no web framework at all.
encoding/json: struct tags instead of annotations
public class User {
@JsonProperty("user_id")
private Long userId;
@JsonIgnore
private String passwordHash;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private String nickname;
private Instant createdAt; // needs JavaTimeModule
}type User struct {
UserID int64 `json:"user_id"`
PasswordHash string `json:"-"` // never serialised
Nickname string `json:"nickname,omitempty"` // omit when ""
CreatedAt time.Time `json:"created_at"` // RFC 3339
}
// Only exported (capitalised) fields are serialised.
// No ObjectMapper, no modules to register.ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(user);
User back = mapper.readValue(json, User.class);data, err := json.Marshal(user) // []byte
if err != nil { ... }
var back User
if err := json.Unmarshal(data, &back); err != nil { ... }
// Unknown JSON fields are ignored by default, like
// FAIL_ON_UNKNOWN_PROPERTIES = false.Routing with net/http (Go 1.22+)
@RestController
@RequestMapping("/api/messages")
class MessageController {
@GetMapping("/{id}")
Message get(@PathVariable String id) { ... }
@PostMapping
ResponseEntity<Message> create(
@RequestBody @Valid NewMessage in) { ... }
@DeleteMapping("/{id}")
void delete(@PathVariable String id) { ... }
}mux := http.NewServeMux()
// "METHOD /path/{wildcard}", matched most-specific first
mux.HandleFunc("GET /api/messages/{id}", h.Get)
mux.HandleFunc("POST /api/messages", h.Create)
mux.HandleFunc("DELETE /api/messages/{id}", h.Delete)
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // the {id} segment
...
}
// A request with the wrong method gets 405 with an Allow
// header automatically. GET patterns also answer HEAD.Reading requests and writing responses
// Spring does these for you; in Go they are ~20 lines you own.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) // headers must be set BEFORE this call
if err := json.NewEncoder(w).Encode(v); err != nil {
// Too late to change the status: the client already has it
slog.Error("write response", "err", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // reject bodies > 1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // typo'd fields become 400s, not silent drops
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
return nil
}func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
msg, err := h.messages.Find(r.Context(), r.PathValue("id"))
switch {
case err == nil:
writeJSON(w, http.StatusOK, msg)
case errors.Is(err, message.ErrNotFound):
writeError(w, http.StatusNotFound, "message not found")
case errors.Is(err, context.DeadlineExceeded):
writeError(w, http.StatusGatewayTimeout, "timed out")
default:
// Log the details, return a generic message: never leak internals
slog.ErrorContext(r.Context(), "get message", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
}Middleware
@Component
public class TimingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
long start = System.nanoTime();
chain.doFilter(req, res);
long ms = (System.nanoTime() - start) / 1_000_000;
log.info("{} {} {}ms",
req.getMethod(), req.getRequestURI(), ms);
}
}func withTiming(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.InfoContext(r.Context(), "request",
"method", r.Method,
"path", r.URL.Path,
"duration", time.Since(start))
})
}
// Compose by wrapping: the outermost runs first
handler := withRecovery(withTiming(withAuth(mux)))One request's path
Errors become status codes
ServeMux matches the method and path pattern and calls the handler. The handler's switch plays the role of @ControllerAdvice, turning domain errors into status codes.Server timeouts
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second, // slow-header (Slowloris) protection
ReadTimeout: 15 * time.Second, // whole request, including body
WriteTimeout: 30 * time.Second, // until the response is written
IdleTimeout: 2 * time.Minute, // keep-alive connections
}
// http.ListenAndServe(":8080", h) uses a server with NO timeouts.
// Fine for a demo, not for production.Calling other services
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(5))
.build();
HttpResponse<String> res =
client.send(req, HttpResponse.BodyHandlers.ofString());// Create once and reuse: it pools connections
var client = &http.Client{Timeout: 10 * time.Second}
func fetchUser(ctx context.Context, url string) (*User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch user: %w", err)
}
defer resp.Body.Close() // always, or the connection leaks
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch user: unexpected status %s", resp.Status)
}
var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return nil, fmt.Errorf("decode user: %w", err)
}
return &u, nil
}Three http.Client mistakes to avoid
http.DefaultClient has no timeout. http.Get(url) uses it, and a hung server hangs your goroutine forever. Always use a client with a Timeout, and pass a context with NewRequestWithContext.
Non-2xx responses are not errors. client.Do only returns an error for transport failures; a 500 is a successful round trip. Check resp.StatusCode yourself.
Always close the body, even when you don’t read it. An unclosed body keeps the connection out of the pool.