Migration from Pekko/Akka HTTP
This guide covers the key differences between Pekko HTTP (or Akka HTTP) and http4s-stir, and lists the directives and features that are not yet implemented.
Key differences
| Concept | Pekko HTTP | http4s-stir |
|---|---|---|
| Effect type | Future | IO (cats-effect) |
| Streams | Akka Streams (Source / Flow / Sink) | fs2 (Stream / Pipe) |
| Server bootstrap | ActorSystem + Http().bindAndHandle | EmberServerBuilder |
| Marshalling | spray-json / akka-http-circe | http4s-circe (EntityEncoder / EntityDecoder) |
| Route type | Route = RequestContext => Future[RouteResult] | Route = RequestContext => IO[RouteResult] |
| Configuration | application.conf / ActorSystem settings | cats-effect IOApp |
| Future directives | onComplete(future) / onSuccess(future) | onComplete(io) / onSuccess(io) (same names, IO instead of Future) |
Directive mapping
Most directives have the same names and signatures as their Pekko HTTP counterparts. The primary difference is that Future is replaced by IO throughout. For example:
onComplete(io)replacesonComplete(future)onSuccess(io)replacesonSuccess(future)complete(IO(...))replacescomplete(Future(...))entity(as[T])works identically, using http4sEntityDecoderinstead of AkkaUnmarshaller
Path directives, method directives, parameter directives, header directives, and most other directive categories are
API-compatible. Code migration typically involves updating imports and replacing Future with IO. The exceptions
whose signatures differ:
- WebSocket: the directives take the server's
WebSocketBuilder2[IO]and work on http4sWebSocketFrames (handleWebSocketMessages(ws, pipe));extractWebSocketUpgradeis replaced by the builder. - Security: authenticators receive stir's
CredentialsHelper(wrapping http4s'Credentials) where Pekko passes itsCredentials. - Debugging:
logRequest,logResultandlogRequestResulttake named options (logHeaders,logBody,redactHeadersWhen,maxBodyBytes,logAction,redaction) instead of Pekko'sLoggingMagnet(a marker string and log level), sologRequest("marker")does not compile (see the debugging directives). listDirectoryContentstakes stir's implicitDirectoryRenderer, not Pekko'sDirectoryRenderermarshaller.
What is not implemented
Every Pekko HTTP directive category has a counterpart in http4s-stir. The few directives without one are tied to
the Pekko runtime and have no meaning on http4s: withExecutionContext / extractExecutionContext,
withMaterializer / extractMaterializer, extractActorSystem, withLog / extractLog, withSettings /
mapSettings / extractSettings / extractParserSettings and onCompleteWithBreaker (needs a circuit breaker).
Settings-driven behaviour (range limits, strict form timeout, size limits, the decode limit) is set per directive
instead. Other differences that remain:
Marshaller.oneOfandAccept-Charsetnegotiation are not ported: http4s encoders have one content type and a fixed charset. Chaincomplete(json) ~ complete(text)to offer several representations.- WebSocket
permessage-deflate(theshouldCompressoverloads) needs server support http4s 0.23 lacks. - The resource directives (
getFromResource,getFromResourceDirectory) only work on the JVM: on Scala Native and Scala.js an application using them fails to link (nojava.net.URL; Native's class loader has onlygetResourceAsStream). complete(status)andcomplete((status, headers))for a status that allows an entity send an empty body, and so does the default exception handler's 500; Pekko sends the status's default message astext/plain(for example "The requested resource could not be found." for 404, "There was an internal server error." for 500).HeaderMagnetandModeledCustomHeaderare not ported:headerValueByType[T]andoptionalHeaderValueByType[T]use http4s'Header[T, _]type class; define a custom header withHeader.create.StrictFormandStrictForm.FileDataare not ported: the form-field directives read the text parts of a multipart body and skip a part with afilename(Pekko returns its content); read file parts withfileUpload/storeUploadedFile.RejectionErroris not ported: a failedIOalways reaches the exception handler; to reject, recover theIOandreject(...)(e.g. withonComplete).toStrictEntity/extractStrictEntityanswer an entity overmaxByteswith 413 Payload Too Large; Pekko answers 400 "Request too large".- The default exception handler logs an
IllegalRequestExceptionas "Error during processing of request: '...'. Completing with 409 Conflict response. ..." like other errors; Pekko warns "Illegal request: '...'. ...". toStrictEntity/extractStrictEntityanswer a request body stream that fails (other than by the size limit or the timeout) with a 500 from the exception handler; Pekko answers its parser'sEntityStreamExceptionwith 400.parameter("x".repeated)returns the values in the order of the query string; Pekko returns them reversed.- A header that http4s cannot parse is ignored where Pekko may parse it leniently (
selectPreferredLanguagereadsAccept-Languageq-values as leniently as Pekko). - There is no default request-body size limit (Pekko:
max-content-length, 8 MiB):entity(as[String])reads a body of any size. Wrap the routes that read bodies, or the whole route, inwithSizeLimit(withSizeLimit(8L * 1024 * 1024)(route).toHttpApp). - The testkit carries its default host as a
Hostheader, which Pekko's testkit does not add, rather than in the URI:~>addsHost: example.comto a request that names no host and keeps its URI as given, where Pekko makes it absolute (http://example.com/...). SoextractUriis relative in stir's tests, as it is behind stir's server, andheaderValueByName("Host")finds the header.DefaultHostInfohas nosecuredConnection: the scheme comes from the request. withRequestTimeoutcancels the inner route when it fires and its default answer is an empty 503; Pekko keeps the route running and answers with a text body.- The CSV framing of
EntityStreamingSupport.csv()drops empty lines, strips a\rbefore\nand accepts a last line without a newline; Pekko's line framing does none of these. - stir has no
Option,EitherorTrymarshallers. With circe's codecs in scopecomplete(option)still compiles but sendsnullwith 200 where Pekko answers 404 forNone, andas[Option[T]]decodes a JSONnullrather than an empty body; match on the option and complete explicitly. extractUrigives the request URI as received (relative under HTTP/1); Pekko gives the effective, absolute URI.- An empty file is served with the
Content-Typeof its extension; Pekko sends an empty entity without one. - Over Ember's HTTP/2 a request carrying its host only in
:authority(prior knowledge, HTTP/2 over TLS) has no host for the host directives (extractHostgives"",host(...)rejects): Ember 0.23.36 drops:authority. - A request whose URI authority and
Hostheader disagree is answered 400 by Pekko; stir names no host for it (extractHostgives"", sohost(...)rejects). extractClientIPskips anX-Forwarded-Forwhose first entry is not an address (unknown) and goes on toX-Real-Ipand the connection; Pekko returnsRemoteAddress.Unknownfor it.- The default rejection handler lists each supported content type, encoding and method once in its 406 and 415
messages and the
Allowheader (Pekko repeats one offered by several alternatives; http4s'Allowlists the methods in no particular order), shows a request's media type without its parameters in the 415 message, and sends noContent-Rangewith a 416 for a response of unknown length (Pekko sends an invalidContent-Range: bytes */-1). - Content negotiation differs from Pekko for wildcard ranges with parameters: stir ignores
charseton every range (Pekko only ontype/subtyperanges, so itstext/*;charset=...never matches) and does not count a wildcard range's parameters towards its specificity. fileUploadAllcreates its temporary files withFile.createTempFile(the default permissions minus the umask); Pekko creates them owner-only. The spool files of the other upload directives are owner-only.Route.sealtakes no log action; usehandleExceptions(ExceptionHandler.seal(handler)(logAction))around the route.
Behaviour changes in 0.7.1
CredentialsHelper.Provided#verifycompares the secrets character by character. It compared the low 8 bits of each character, as Pekko does, so a password whose characters differ from the secret's only in their high byte was accepted (AukaszforŁukasz). Correct passwords verify as before.extractClientIPno longer reads aRemote-Addressheader (Pekko ignores it too): a client could send one and choose the address it reported. Without a usableX-Forwarded-FororX-Real-Ipit gives the connection's address. Those two headers can be sent by clients too, so useextractDirectClientIPfor access control unless a proxy sets or strips them.- Literal path segments are compared decoded, as Pekko does:
path("café")also matches/caf%c3%a9(lower-case escapes) andpath("foo")matches/fo%6F. They were compared in http4s' encoding, so only the escapes it produces itself matched. selectPreferredLanguagecompares language tags case-insensitively, as Pekko does:Accept-Language: EN-usselectsen-US. It still returns the alternative as it was given. When equally specific ranges match the same language (en;q=0.1, EN) the higher q-value counts, as in Pekko; the first one in the header used to.Segment,Segmentsand regex path matchers no longer match the empty segment of a//, as Pekko does:pathPrefix("a" / Segment)rejects/a//b(it extracted""), andSegmentsstops before an empty segment.Slashmatches each slash of a//("a" ~ Slash ~ Slash ~ "b"matches/a//b), andignoreTrailingSlashturns a trailing//into/, as in Pekko.redirect(andredirectToTrailingSlashIfMissing/redirectToNoTrailingSlashIfPresent) send Pekko's shorttext/htmlbody linking to the URI (HTML-escaped:&is&, where Pekko inserts the URI as is), and throw anIllegalArgumentExceptionfor a status that is not a redirection (Pekko'sRedirectiontype rules it out at compile time); they sent an empty body and accepted any status.- The default rejection handler answers a
MissingQueryParamRejectionorInvalidRequiredValueForQueryParamRejection(404) before aRequestEntityExpectedRejection(400), in Pekko's order, and aMissingAttributeRejectionwith Pekko's "There was an internal server error." (it sent "Internal Server Error"). - The WebSocket directives only take a request as an upgrade when Pekko's server would: a
GETwithSec-WebSocket-Version: 13and aSec-WebSocket-Keyof 16 base64-encoded bytes besides theConnectionandUpgradeheaders. Others are rejected withExpectedWebSocketRequestRejection: Ember used to upgrade aPOSTor a key of the wrong length, and refused a missing key or version only after the route accepted the upgrade. A version list (Sec-WebSocket-Version: 8, 13) is an upgrade, as in Pekko, but Ember's handshake refuses it (400). A client offering only an older version now gets the 400 for a missing upgrade, as from Pekko, where Ember answered426 Upgrade Requiredafter the route had accepted it. - Testkit:
~>addsHost: example.comto a request that names no host (host(...)used to reject andextractHostto give""; declare an implicitDefaultHostInfofor another host), so tests that check every request header or a logged request line now see it. As in Pekko,addHeaderputs the header before the request's headers instead of replacing one of the same name (a typed lookup still finds the latest, but the logged header order changes) andaddHeadersafter them; an entity header such asContent-Typestill replaces.responseno longer shows aTransfer-Encoding: chunkedthe route did not send, andevaluateTo,haveFailedWithand the marshalling helpers fail aftermarshallingTimeout(one second, as Pekko) instead of waiting forever; a suite that defines its owndef marshallingTimeoutnow needsoverride.
Behaviour changes in 0.7
EntityRejectionis gone.entity(as[T])rejects an unsupportedContent-TypewithUnsupportedRequestContentTypeRejection(415), an unparseable body withMalformedRequestContentRejection(400) and anIllegalArgumentExceptionfrom the decoder withValidationRejection(400).as[String]accepts every content type, through anEntityDecoder[IO, String]the directives bring into scope. A spec that mixes inDirectivesdecodesresponseAs[String]with it too; code that defines its ownEntityDecoder[IO, String]next to the directives must hide one of the two.getFromResourceandgetFromFilereject when nothing is found instead of completing 404 or 500.authenticateBasic*andauthenticateOAuth2*treat credentials of another scheme as missing, so.optionalandwithAnonymousUserapply.- The testkit builds request bodies with
withEntity, so requests carryContent-TypeandContent-Length. fileUploadand friends no longer require a partContent-Type; a missing one isapplication/octet-stream.getFromFile,getFromResource,getFromDirectory,getFromResourceDirectoryand the browseable directory directives take an implicitContentTypeResolver; code that passed them as function values (xs.map(getFromDirectory)) now needsxs.map(getFromDirectory(_)).entity(as[T])on a request known to have no body rejects withRequestEntityExpectedRejection(400, "Request entity expected but not supplied") instead ofMalformedRequestContentRejection, as Pekko does;as[String]still decodes it as"".parameterSeqandformFieldSeqreturn the pairs in the order of the query string or body, as Pekko does, andparameterSeqincludes a parameter without a value (?flag) as("flag", "")instead of dropping it.parameterMapkeeps the last value of a repeated parameter (?x=1&x=2givesx -> 2, was1) andparameterMultiMaplists a parameter without a value (?flag) with one empty value (was none), as Pekko does; a bare?gives empty maps (wasMap("" -> "")andMap("" -> List())). So?flag=on&flagnow givesflag -> "".- The parameter directives parse the query string themselves, as Pekko does, instead of through http4s'
Query:;no longer separates parameters (?a;b=cis the parametera;b; a query rebuilt through http4s'QueryAPI, e.g. bywithQueryParamin a middleware, was already split there),parameter("x")takes the first occurrence even when it has no value (?x&x=1gives"", was"1"), and.repeatedlists an occurrence without a value as""(was skipped). - Logged request URIs mask a sensitive query value up to the next
&, as the parameter directives now read it, and keep the query as received:?token=t;user=ulogs as?token=REDACTED(was?token=REDACTED&user=u). - The form directives parse url-encoded bodies themselves instead of through http4s'
UrlFormdecoder:;no longer separates fields (a=1;b=2is one field, as in Pekko) and characters that http4s rejected with a 400 when unencoded (spaces, quotes, brackets, non-ASCII) are accepted literally. IntNumber,LongNumber,HexIntNumber,HexLongNumberand one-group regex matchers leave an absolute remainder, soIntNumber / "x"matches/42/xas in Pekko. All number and regex matchers (includingDoubleNumberandJavaUUID) no longer skip an unconsumed leading slash:rawPathPrefix(IntNumber)rejects/23,"a" ~ IntNumberrejects/a/23, andpathSuffix(IntNumber)leaves/orders/for/orders/123and rejects/orders/123/.respondWithHeader(s)adds its headers in front of the inner route's and keeps a same-named header the route set, as Pekko does; 0.6.1 dropped the added header in that case. To replace a header, usemapResponseHeaders(_.put(header)).respondWithDefaultHeader(s)adds a header only when the route did not set it, and now puts the missing defaults first, as Pekko does (0.6.1 appended them).ExceptionHandler.seal(handler)(logAction)passes the log action to the default fallback; 0.6.1 ignored it.overrideMethodWithParameteranswers501 Not Implementedfor an unknown method name and matches names independently of the default locale.withRangeSupportand the coding directives (decodeRequest,encodeResponseand friends) are new in 0.7.withRangeSupportrejects aRangerequest for a response of unknown length withUnsatisfiableRangeRejection(ranges, -1)(416 when sealed);decodeRequestbounds the decoded body to 8 MiB (CodingDirectives.DefaultDecodeMaxBytes, Pekko'sdecode-max-size, 413 above it) and answers a corrupt compressed body with 400 (MalformedRequestContentRejectionfromentity), as Pekko does.- The disk-based upload directives no longer decode the whole multipart body in memory:
storeUploadedFile(s)andfileUploadAllspool parts above 64 KiB to disk,fileUploadSpooledabove 50 MiB, and a body with more than 1000 parts is rejected with a 400. fileUploadAlldeletes its temporary files once the response body has been consumed, or as soon as the route rejects, fails or is cancelled; a part stream kept past the request fails. 0.6.1 kept each file until its stream was read and relied ondeleteOnExit.- The testkit bounds
~>and~!>with an implicitRouteTestTimeout, 1 second by default as in Pekko: a route that does not answer in time fails the test with "Request was neither completed nor rejected within 1 second", and reading the response (response,responseAs,chunks,trailer) is bounded the same way ("Response entity was not read within ..."). Declareimplicit val routeTestTimeout: RouteTestTimeout = RouteTestTimeout(5.seconds)for slower routes. - The static file and resource directives type content like Pekko: text types carry
charset=UTF-8, an unknown extension isapplication/octet-stream, andname.ext.gz/.svgzare served with the inner type andContent-Encoding: gzip. They answer conditional requests (ETag,Last-Modified, 304 and 412) andRangerequests (206, 416) as Pekko does. getFromResourcerejects a directory entry inside a jar instead of serving it as an empty body.getFromResource/getFromResourceDirectoryreject a directory on a class-path folder whose path has a space or a non-ASCII character; they used to serve the JDK's listing of its file names.logRequestResultWith(show)is new; ashowthat fails fails the request, as in Pekko.complete(status, value)(with or without headers) sends no entity when the status allows none (1xx, 204, 205, 304), as Pekko does; the value used to be sent as the body. Without an entity such a response is not negotiated either (Pekko still answers a 204 with an unacceptable value's type with 406).headerValueByType[T]rejects withMissingHeaderRejectionnaming the header as its http4sHeaderinstance does (X-Custom-Header), as Pekko names a modeled custom header; it used the Scala class name (XCustomHeader), which only matched for http4s' own header classes. It now needs aHeader[T, _]instance instead of aClassTag.entityrejects every failure, as Pekko does: a request body stream that fails (a dropped connection, a broken upstream) is aMalformedRequestContentRejectioncarrying the error, and a decoder raising anIllegalArgumentExceptionaValidationRejection; both used to reach the exception handler as a 500. So does any other error a customEntityDecoderraises (a 400 rejection where it used to be a logged 500), and an I/O error while http4s spools a large multipart part to a temporary file.formFieldSeq,formFieldMapandformFieldMultiMaplikewise reject a body that fails while it is buffered (a body over the size limit still answers 413, a slow one 408); a corrupt compressed form body now reaches the rejection handler, whose 400 reads "The request content was malformed:" followed by the message.completenegotiates the response'sContent-Typeagainst the request'sAcceptheader, as Pekko does: a representation the client does not accept is rejected withUnacceptedResponseContentTypeRejection(406 when sealed, "Resource representation is only available with these types:" followed by the types). Clients sending*/*or noAcceptsee no difference; a client sendingAccept: application/jsontocomplete("text")now gets a 406 instead of the text. Error responses (4xx, 5xx:complete(Status.NotFound, "not found")) are sent whatever the client accepts, as Pekko sends them through an opaque fallback, and exception and rejection handlers are exempt. To keep the old behaviour for a route, wrap it inmapRequestContext(_.withAcceptAll); see "Content negotiation" in the marshalling directives.selectPreferredLanguagereadsAccept-Languageq-values as leniently as Pekko:q=.5and spaces around;and=are accepted (the header used to be ignored, picking the first language), and a q-value with more than three decimals is truncated instead of rounded (q=0.4999now ranks belowq=0.5).- Under
toHttpAppand the testkit a request body that cannot be handed out again fails a later read at once with anIllegalStateException("The request entity was read before ...") instead of waiting on the connection until the idle timeout or arriving truncated, as Pekko fails the second materialization of a streamed entity: a chunked body (even an empty one), an HTTP/2 body with bytes and an in-memory body without a length can be read once, and a body of known length cannot be read again after a read that stopped partway (it failed mid-stream, or the reader stopped before asking for the end). A body known to be empty (no body,Content-Length: 0, an HTTP/2 body found empty) is readable again, and so is a body of known length read to its end when it is in memory (the testkit) or arrived over Ember together with the request head; a later read of a larger one fails with Ember's "Body Has Been Consumed Completely Already" (a 500, or aMalformedRequestContentRejectionunderentityand the form map directives), much as Pekko re-reads only an entity that arrived strict. Alternatives that each run a form directive read the body anew each (formField(...) ~ formField(...)), so wrap them intoStrictEntityto let several of them read the body. This applies to in-memory test bodies too (a body built withwithBodyStreamhas no length, so it can be read once);toHttpRouteshands the next routes the request as received and is not covered. extractHostandhostread the authority of the request URI first, then theHostheader, as Pekko reads the effective URI: an absolute-form request is matched by its URI's host (when itsHostheader disagrees, no host is named), a host name is lowercased (Example.COMmatcheshost("example.com")), and an IPv6 host comes without brackets (::1, was[::1]).
Testkit differences
The http4s-stir testkit differs from the Pekko HTTP testkit in the following ways:
- Synchronous execution: all test assertions run synchronously; there is no async test support.
~>waits for the route, so~> runRoutedoes not separate running the route from checking it as Pekko's does. - Chunk inspection:
chunks,chunksStreamandtrailerexpose the body chunks and trailer headers; there is noChunkStreamPartmodel. - Limited request building: some minor header convenience methods available in the Pekko HTTP testkit are missing.
- WebSocket testing:
WS(uri, probe, subprotocols*)andWSProbeexchange frames with the handler in memory, as in Pekko, but the probe talks to the handler as the directive received it (no ping/pong filtering or defragmentation) and over~!>no frames are exchanged.isWebSocketUpgradeandexpectWebSocketUpgradeWithProtocolwork as in Pekko. - Timeouts:
RouteTestTimeoutbounds~>and~!>(1 second by default) and fails the test on the test thread.