Why ZIO‑HTTP endpoint prefixing throws MalformedPath and how to fix it

Summary

A developer attempting to globally prefix all ZIO-HTTP endpoints with /api encountered a critical failure. By using the literal("api") / routes pattern, they expected the paths to concatenate. Instead, the application threw a MalformedPath error, stating that the request for /api/activities failed because the decoder expected /activities but found the end of the path. The issue stems from a fundamental misunderstanding of how Type-Safe Endpoints differ from raw Handlers.

Root Cause

The failure is caused by a mismatch between Path Composition and Endpoint Decoding.

  • Endpoint vs. Handler: In ZIO-HTTP, an Endpoint is a declaration of a contract (including path segments, methods, and codecs). A Handler is the actual execution logic.
  • The “Literal” Trap: When using literal("api") / routes, the developer is attempting to compose a path at the Route level. While this works for raw Method handlers (which are just functions mapping a request to a response), it fails for Endpoint declarations.
  • Decoding Logic: An Endpoint contains an internal Path Decoder. If an endpoint is defined as GET / "activities", its internal logic specifically looks for a path that matches exactly activities.
  • The Prefix Conflict: By wrapping the Routes in a literal("api"), the routing engine finds the /api segment, but then passes the remainder of the path to the endpoint. Because the endpoint’s internal decoder is hardcoded to expect activities starting from the root of its own context, it perceives the structure incorrectly when wrapped, leading to the MalformedPath error.

Why This Happens in Real Systems

This is a classic example of Abstraction Leakage.

  • Declarative vs. Imperative: In imperative routing (like Play or Akka HTTP), a prefix is often just a string manipulation step. In declarative, type-safe routing (like ZIO-HTTP or Tapir), the path is part of the Type Signature.
  • Contract Rigidity: Type-safe libraries prioritize the integrity of the contract. If the contract says “I own the path /activities“, and you try to force it to live under /api, you are effectively breaking the contract’s ability to validate the incoming request against its own definition.
  • Composition Semantics: Many developers assume that A / B always means “prepend A to B”. In type-safe systems, A / B often means “match A, then pass the remaining context to B”. If B is an Endpoint, it expects the remaining context to match its definition exactly.

Real-World Impact

  • Deployment Blockers: Developers may spend hours debugging “Malformed Path” errors that appear to be infrastructure issues but are actually logical mismatches in code.
  • API Versioning Failures: When attempting to implement versioning (e.g., /v1/, /v2/) via global prefixes, teams may inadvertently break the OpenAPI/Swagger documentation generation, as the generated docs will not reflect the actual composed paths.
  • Broken Contract Testing: Automated tests that rely on the Endpoint definition to generate requests will fail when the routing layer adds unexpected segments.

Example or Code (if necessary and relevant)

// THE BROKEN WAY: This fails because the Endpoint's internal 
// decoder is not aware of the 'api' prefix provided by the Route wrapper.
val getActivities = Endpoint(GET / "activities")
val routes = Routes(getActivities.implement(h => handler))
val badApi = literal("api") / routes 

// THE CORRECT WAY: Use Endpoint composition to ensure the 
// Path Decoder is updated to include the prefix.
val getActivities = Endpoint(GET / "activities")
val prefixedEndpoint = Endpoint(GET / "api" / "activities")

// OR BETTER: Use the Endpoint composition operator before implementation
val baseEndpoint = Endpoint(GET / "activities")
val apiEndpoint = Endpoint(GET / "api") / baseEndpoint

val correctRoutes = Routes(
  apiEndpoint.implement(h => handler)
)

How Senior Engineers Fix It

Senior engineers approach this by identifying the Source of Truth.

  • Compose the Contract, Not the Implementation: Instead of trying to prefix the Routes (the implementation), you must prefix the Endpoint (the contract). This ensures that the path decoding logic is updated alongside the implementation.
  • Higher-Order Endpoint Composition: Use the fact that Endpoint is a composable structure. If you have a group of endpoints, you should map over them or use a combinator that prepends the path segments to the Endpoint definition itself.
  • Design for Scale: If the API grows, senior engineers will create a basePath constant or a helper function that wraps Endpoint definitions, ensuring that documentation (OpenAPI) and routing logic remain perfectly in sync.

Why Juniors Miss It

  • Confusing Routing with Execution: Juniors often view routing as a simple “search and execute” mechanism (like a switch statement) rather than a type-level contract validation.
  • Over-reliance on Routes: They tend to work at the highest level of abstraction (Routes) because it feels easier, not realizing that the most powerful logic resides in the lower-level Endpoint definitions.
  • Ignoring the Error Message: A junior might see MalformedPath and assume the client is sending the wrong URL, whereas a senior reads Expected path segment "activities" but found end of path and realizes the decoder’s expectations are the problem.

Leave a Comment