diff --git a/frameworks/roadrunner/.gitignore b/frameworks/roadrunner/.gitignore new file mode 100644 index 000000000..841533328 --- /dev/null +++ b/frameworks/roadrunner/.gitignore @@ -0,0 +1,5 @@ +_build/ +_checkouts/ +.rebar3/ +*.beam +*.swp diff --git a/frameworks/roadrunner/Dockerfile b/frameworks/roadrunner/Dockerfile new file mode 100644 index 000000000..6d348e918 --- /dev/null +++ b/frameworks/roadrunner/Dockerfile @@ -0,0 +1,13 @@ +FROM erlang:29.0-alpine AS build +RUN apk add --no-cache ca-certificates git +WORKDIR /src +COPY rebar.config ./ +COPY config ./config +COPY src ./src +RUN rebar3 as prod release + +FROM alpine:3.23 +RUN apk add --no-cache libstdc++ ncurses-libs openssl +COPY --from=build /src/_build/prod/rel/roadrunner_httparena /app +EXPOSE 8080 8081 8082 8443 +CMD ["/app/bin/roadrunner_httparena", "foreground"] diff --git a/frameworks/roadrunner/README.md b/frameworks/roadrunner/README.md new file mode 100644 index 000000000..c1f5efbc1 --- /dev/null +++ b/frameworks/roadrunner/README.md @@ -0,0 +1,26 @@ +# roadrunner + +[Roadrunner](https://github.com/arizona-framework/roadrunner) is the pure-Erlang HTTP/1.1 + HTTP/2 + WebSocket server from the Arizona framework. This entry packages a minimal OTP release wrapping roadrunner with the HttpArena endpoint contract. + +## Profiles + +Covered (19 of 28 profiles): + +- `baseline`, `pipelined`, `limited-conn` +- `json`, `json-comp`, `json-tls` +- `upload` +- `async-db`, `api-4`, `api-16` +- `static`, `static-h2` +- `fortunes`, `crud` +- `baseline-h2`, `baseline-h2c`, `json-h2c` +- `echo-ws`, `echo-ws-pipeline` + +Deferred (tracked under [HttpArena coverage gaps](https://github.com/arizona-framework/roadrunner/blob/main/docs/roadmap.md) in the roadrunner roadmap): + +- `baseline-h3`, `static-h3`: roadrunner has no HTTP/3 stack yet. +- `unary-grpc`, `stream-grpc`, TLS variants: no gRPC stack. +- `gateway-64`, `gateway-h3`, `production-stack`: reverse-proxy multi-container scenarios; out of scope for the single-framework entry. + +## Build + +`docker build -t httparena-roadrunner frameworks/roadrunner` then run via `scripts/validate.sh roadrunner` from the repo root. diff --git a/frameworks/roadrunner/config/sys.config b/frameworks/roadrunner/config/sys.config new file mode 100644 index 000000000..b1e5e00a5 --- /dev/null +++ b/frameworks/roadrunner/config/sys.config @@ -0,0 +1,5 @@ +[ + {roadrunner_httparena, [ + {http_port, 8080} + ]} +]. diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json new file mode 100644 index 000000000..265bb108d --- /dev/null +++ b/frameworks/roadrunner/meta.json @@ -0,0 +1,31 @@ +{ + "display_name": "roadrunner", + "language": "Erlang", + "type": "production", + "engine": "roadrunner", + "description": "Roadrunner: pure-Erlang HTTP/1.1 + HTTP/2 + WebSocket server from the Arizona framework.", + "repo": "https://github.com/arizona-framework/roadrunner", + "enabled": true, + "tests": [ + "baseline", + "pipelined", + "limited-conn", + "json", + "json-comp", + "json-tls", + "upload", + "async-db", + "api-4", + "api-16", + "static", + "fortunes", + "crud", + "baseline-h2", + "static-h2", + "baseline-h2c", + "json-h2c", + "echo-ws", + "echo-ws-pipeline" + ], + "maintainers": ["williamthome"] +} diff --git a/frameworks/roadrunner/rebar.config b/frameworks/roadrunner/rebar.config new file mode 100644 index 000000000..485bb50ff --- /dev/null +++ b/frameworks/roadrunner/rebar.config @@ -0,0 +1,24 @@ +{erl_opts, [debug_info]}. + +{minimum_otp_vsn, "29"}. + +{deps, [ + {roadrunner, "0.1.0"}, + {epgsql, "4.8.0"}, + {pooler, "1.6.0"} +]}. + +{relx, [ + {release, {roadrunner_httparena, "0.1.0"}, + [roadrunner_httparena, roadrunner]}, + {sys_config, "./config/sys.config"}, + {dev_mode, false}, + {include_erts, true}, + {extended_start_script, true} +]}. + +{profiles, [ + {prod, [ + {relx, [{mode, prod}]} + ]} +]}. diff --git a/frameworks/roadrunner/rebar.lock b/frameworks/roadrunner/rebar.lock new file mode 100644 index 000000000..a77186a27 --- /dev/null +++ b/frameworks/roadrunner/rebar.lock @@ -0,0 +1,17 @@ +{"1.2.0", +[{<<"epgsql">>,{pkg,<<"epgsql">>,<<"4.8.0">>},0}, + {<<"pooler">>,{pkg,<<"pooler">>,<<"1.6.0">>},0}, + {<<"roadrunner">>,{pkg,<<"roadrunner">>,<<"0.1.0">>},0}, + {<<"telemetry">>,{pkg,<<"telemetry">>,<<"1.4.2">>},1}]}. +[ +{pkg_hash,[ + {<<"epgsql">>, <<"C491B141B8C37BCE7B67F2079F168F339BB374A7CF9A286CB3B40AD1CD3FABE5">>}, + {<<"pooler">>, <<"F4F33C94AB3AB82565A2E31CEA9EFE4149A160651F3707A0A2669BC54AAF81C8">>}, + {<<"roadrunner">>, <<"ECC4CA6CFE2ED65661D57DE8FF53986850F62F882AAF76829150F8ED00954249">>}, + {<<"telemetry">>, <<"A0CB522801DFFB1C49FE6E30561BADFFC7B6D0E180DB1300DF759FAA22062855">>}]}, +{pkg_hash_ext,[ + {<<"epgsql">>, <<"00E550006A62FB439FC7E879419443C889C34605E9B9DC406029D8BAA1AD79D8">>}, + {<<"pooler">>, <<"748C988FD2928DE9577C882A49621863CAB57809E3E1A88A14C9D3B55C6AB877">>}, + {<<"roadrunner">>, <<"1F393EBB706D61427F2C472A362030D45B0E85B59A680E6180154116C8BE4D6A">>}, + {<<"telemetry">>, <<"928F6495066506077862C0D1646609EED891A4326BEE3126BA54B60AF61FEBB1">>}]} +]. diff --git a/frameworks/roadrunner/src/roadrunner_httparena.app.src b/frameworks/roadrunner/src/roadrunner_httparena.app.src new file mode 100644 index 000000000..fcde85e21 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena.app.src @@ -0,0 +1,18 @@ +{application, roadrunner_httparena, [ + {description, "HttpArena entry for the Roadrunner HTTP server"}, + {vsn, "0.1.0"}, + {registered, [roadrunner_httparena_sup]}, + {mod, {roadrunner_httparena_app, []}}, + {applications, [ + kernel, + stdlib, + sasl, + ssl, + epgsql, + pooler, + roadrunner + ]}, + {env, []}, + {modules, []}, + {licenses, ["Apache-2.0"]} +]}. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl new file mode 100644 index 000000000..64b1c6acd --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -0,0 +1,81 @@ +-module(roadrunner_httparena_app). +-behaviour(application). + +-export([start/2, stop/1]). + +start(_StartType, _StartArgs) -> + {ok, SupPid} = roadrunner_httparena_sup:start_link(), + ok = roadrunner_httparena_dataset:load(), + _ = roadrunner_httparena_db:start_pool(), + ok = roadrunner_httparena_crud:init(), + Routes = roadrunner_httparena_handler:routes(), + HttpPort = application:get_env(roadrunner_httparena, http_port, 8080), + {ok, _} = roadrunner:start_listener(httparena_http, #{ + port => HttpPort, + routes => Routes, + middlewares => [roadrunner_compress], + %% 25 MB headroom for the upload profile (validator goes up to 20 MB). + max_content_length => 26214400, + %% Manual body buffering: handlers read the body themselves via + %% `roadrunner_req:read_body[_chunked]/1`. Lets the upload handler + %% stream chunks instead of buffering the entire 20 MB body in + %% the conn process before dispatch. Auto-mode handlers + %% (`baseline11` POST) still work transparently via `read_body/1`. + body_buffering => manual + }), + H2cPort = application:get_env(roadrunner_httparena, h2c_port, 8082), + {ok, _} = roadrunner:start_listener(httparena_h2c, #{ + port => H2cPort, + routes => Routes, + middlewares => [roadrunner_compress], + max_content_length => 26214400, + %% h2c prior-knowledge: `[http2]` on a plain-TCP listener + %% serves h2 directly (client sends the h2 preface, no + %% `Upgrade: h2c` negotiation). + protocols => [http2], + body_buffering => manual + }), + case tls_opts() of + {ok, TlsOpts} -> + TlsPort = application:get_env(roadrunner_httparena, tls_port, 8081), + {ok, _} = roadrunner:start_listener(httparena_tls, #{ + port => TlsPort, + routes => Routes, + middlewares => [roadrunner_compress], + max_content_length => 26214400, + tls => TlsOpts, + body_buffering => manual + }), + H2Port = application:get_env(roadrunner_httparena, h2_port, 8443), + {ok, _} = roadrunner:start_listener(httparena_h2, #{ + port => H2Port, + routes => Routes, + middlewares => [roadrunner_compress], + max_content_length => 26214400, + tls => TlsOpts, + %% Listener derives `alpn_preferred_protocols` from + %% this list — `h2` preferred, fall back to `http/1.1`. + protocols => [http2, http1], + body_buffering => manual + }); + skip -> + ok + end, + {ok, SupPid}. + +stop(_State) -> + ok. + +tls_opts() -> + Cert = env_path("TLS_CERT_PATH", tls_cert, "/certs/server.crt"), + Key = env_path("TLS_KEY_PATH", tls_key, "/certs/server.key"), + case filelib:is_regular(Cert) andalso filelib:is_regular(Key) of + true -> {ok, [{certfile, Cert}, {keyfile, Key}]}; + false -> skip + end. + +env_path(EnvVar, AppKey, Default) -> + case os:getenv(EnvVar) of + false -> application:get_env(roadrunner_httparena, AppKey, Default); + V -> V + end. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_crud.erl b/frameworks/roadrunner/src/roadrunner_httparena_crud.erl new file mode 100644 index 000000000..8cbaff9bd --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_crud.erl @@ -0,0 +1,147 @@ +-module(roadrunner_httparena_crud). + +-export([init/0]). +-export([list/1, get/1, create/1, update/1]). + +-define(CACHE, httparena_crud_cache). + +-spec init() -> ok. +init() -> + case ets:info(?CACHE, name) of + undefined -> + ets:new(?CACHE, [ + public, + named_table, + {read_concurrency, true}, + {write_concurrency, true} + ]); + _ -> + ok + end, + ok. + +list(Req) -> + Qs = roadrunner_req:parse_qs(Req), + Cat = qs_bin(~"category", Qs, ~"electronics"), + Page = max(1, qs_int(~"page", Qs, 1)), + Limit = clamp(qs_int(~"limit", Qs, 10), 1, 50), + Offset = (Page - 1) * Limit, + ListSql = ~""" + SELECT id, name, category, price, quantity, active, tags, + rating_score, rating_count + FROM items + WHERE category = $1 + ORDER BY id + LIMIT $2 OFFSET $3 + """, + CountSql = ~"SELECT COUNT(*) FROM items WHERE category = $1", + Items = + case roadrunner_httparena_db:query(ListSql, [Cat, Limit, Offset]) of + {ok, _, Rows} -> [roadrunner_httparena_items:row_to_json(R) || R <- Rows]; + _ -> [] + end, + Total = + case roadrunner_httparena_db:query(CountSql, [Cat]) of + {ok, _, [{N}]} -> N; + _ -> 0 + end, + Body = #{~"items" => Items, ~"total" => Total, ~"page" => Page}, + {roadrunner_resp:json(200, Body), Req}. + +get(Req) -> + Id = id_from_path(Req), + case ets:lookup(?CACHE, Id) of + [{Id, Item}] -> + Resp = roadrunner_resp:json(200, Item), + {roadrunner_resp:add_header(Resp, ~"x-cache", ~"HIT"), Req}; + [] -> + case roadrunner_httparena_db:query(read_sql(), [Id]) of + {ok, _, [Row]} -> + Item = roadrunner_httparena_items:row_to_json(Row), + ets:insert(?CACHE, {Id, Item}), + Resp = roadrunner_resp:json(200, Item), + {roadrunner_resp:add_header(Resp, ~"x-cache", ~"MISS"), Req}; + _ -> + {roadrunner_resp:not_found(), Req} + end + end. + +create(Req) -> + {ok, Body, Req2} = roadrunner_req:read_body(Req), + Input = json:decode(Body), + Id = maps:get(~"id", Input), + Sql = ~""" + INSERT INTO items (id, name, category, price, quantity, + active, tags, rating_score, rating_count) + VALUES ($1, $2, $3, $4, $5, true, '[]'::jsonb, 0, 0) + """, + case roadrunner_httparena_db:query(Sql, [ + Id, + maps:get(~"name", Input), + maps:get(~"category", Input), + maps:get(~"price", Input), + maps:get(~"quantity", Input) + ]) of + {ok, 1} -> + ets:delete(?CACHE, Id), + {roadrunner_resp:status(201), Req2}; + _ -> + {roadrunner_resp:internal_error(), Req2} + end. + +update(Req) -> + Id = id_from_path(Req), + {ok, Body, Req2} = roadrunner_req:read_body(Req), + Input = json:decode(Body), + Sql = ~""" + UPDATE items + SET name = $2, category = $3, price = $4, quantity = $5 + WHERE id = $1 + """, + case roadrunner_httparena_db:query(Sql, [ + Id, + maps:get(~"name", Input), + maps:get(~"category", Input), + maps:get(~"price", Input), + maps:get(~"quantity", Input) + ]) of + {ok, 1} -> + ets:delete(?CACHE, Id), + {roadrunner_resp:status(200), Req2}; + {ok, 0} -> + {roadrunner_resp:not_found(), Req2}; + _ -> + {roadrunner_resp:internal_error(), Req2} + end. + +read_sql() -> + ~""" + SELECT id, name, category, price, quantity, active, tags, + rating_score, rating_count + FROM items + WHERE id = $1 + """. + +id_from_path(Req) -> + binary_to_integer(maps:get(~"id", roadrunner_req:bindings(Req))). + +qs_int(Key, Qs, Default) -> + case lists:keyfind(Key, 1, Qs) of + {Key, V} when is_binary(V) -> + case string:to_integer(V) of + {N, _} when is_integer(N) -> N; + _ -> Default + end; + _ -> + Default + end. + +qs_bin(Key, Qs, Default) -> + case lists:keyfind(Key, 1, Qs) of + {Key, V} when is_binary(V) -> V; + _ -> Default + end. + +clamp(N, Lo, _Hi) when N < Lo -> Lo; +clamp(N, _Lo, Hi) when N > Hi -> Hi; +clamp(N, _Lo, _Hi) -> N. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_dataset.erl b/frameworks/roadrunner/src/roadrunner_httparena_dataset.erl new file mode 100644 index 000000000..851d5c902 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_dataset.erl @@ -0,0 +1,25 @@ +-module(roadrunner_httparena_dataset). + +-export([load/0, items/0]). + +-define(KEY, {?MODULE, items}). + +-spec load() -> ok. +load() -> + Path = path(), + {ok, Raw} = file:read_file(Path), + Items = json:decode(Raw), + persistent_term:put(?KEY, Items), + ok. + +-spec items() -> [map()]. +items() -> + persistent_term:get(?KEY). + +path() -> + case os:getenv("DATASET_PATH") of + false -> + application:get_env(roadrunner_httparena, dataset_path, "/data/dataset.json"); + Env -> + Env + end. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_db.erl b/frameworks/roadrunner/src/roadrunner_httparena_db.erl new file mode 100644 index 000000000..c9f814633 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_db.erl @@ -0,0 +1,71 @@ +-module(roadrunner_httparena_db). + +-export([start_pool/0, query/2]). + +-define(POOL, httparena_pg). + +-spec start_pool() -> ok | disabled. +start_pool() -> + case os:getenv("DATABASE_URL") of + false -> + disabled; + Url -> + ConnMap = parse_url(Url), + Size = pool_size(), + PoolConfig = [ + {name, ?POOL}, + {init_count, Size}, + {max_count, Size}, + {start_mfa, {epgsql, connect, [ConnMap]}} + ], + {ok, _Pid} = pooler:new_pool(PoolConfig), + ok + end. + +-spec query(iodata(), [term()]) -> + {ok, list(), list()} | {ok, non_neg_integer()} | {error, term()}. +query(Sql, Params) -> + case pooler:take_member(?POOL) of + Conn when is_pid(Conn) -> + try + epgsql:equery(Conn, Sql, Params) + after + pooler:return_member(?POOL, Conn, ok) + end; + error_no_members -> + {error, no_members} + end. + +parse_url(Url) -> + Parsed = uri_string:parse(Url), + {User, Pass} = split_userinfo(maps:get(userinfo, Parsed, "")), + Database = strip_slash(maps:get(path, Parsed, "/")), + #{ + host => maps:get(host, Parsed, "localhost"), + port => maps:get(port, Parsed, 5432), + username => User, + password => Pass, + database => Database + }. + +split_userinfo("") -> + {"", ""}; +split_userinfo(UserInfo) -> + case string:split(UserInfo, ":") of + [U, P] -> {U, P}; + [U] -> {U, ""} + end. + +strip_slash("/" ++ Rest) -> Rest; +strip_slash(Other) -> Other. + +pool_size() -> + case os:getenv("DATABASE_MAX_CONN") of + false -> + 32; + S -> + case string:to_integer(S) of + {N, _} when is_integer(N), N > 0 -> min(N, 256); + _ -> 32 + end + end. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl new file mode 100644 index 000000000..b650667a3 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -0,0 +1,178 @@ +-module(roadrunner_httparena_handler). +-behaviour(roadrunner_handler). + +-export([routes/0]). +-export([handle/1]). + +-spec routes() -> [roadrunner_router:route()]. +routes() -> + [ + {~"/baseline11", ?MODULE, undefined}, + {~"/baseline2", ?MODULE, undefined}, + {~"/pipeline", ?MODULE, undefined}, + {~"/json/:count", ?MODULE, undefined}, + {~"/upload", ?MODULE, undefined}, + {~"/async-db", ?MODULE, undefined}, + {~"/fortunes", ?MODULE, undefined}, + {~"/crud/items", ?MODULE, undefined}, + {~"/crud/items/:id", ?MODULE, undefined}, + {~"/ws", ?MODULE, undefined}, + {~"/static/*path", roadrunner_static, #{dir => static_dir()}} + ]. + +static_dir() -> + case os:getenv("STATIC_DIR") of + false -> ~"/data/static"; + D -> iolist_to_binary(D) + end. + +-spec handle(roadrunner_req:request()) -> roadrunner_handler:result(). +handle(Req) -> + handle_route(roadrunner_req:path(Req), Req). + +handle_route(~"/baseline11", Req) -> + baseline(Req); +handle_route(~"/baseline2", Req) -> + baseline(Req); +handle_route(~"/pipeline", Req) -> + {roadrunner_resp:text(200, ~"ok"), Req}; +handle_route(<<"/json/", _/binary>>, Req) -> + json_endpoint(Req); +handle_route(~"/upload", Req) -> + upload_endpoint(Req); +handle_route(~"/async-db", Req) -> + async_db_endpoint(Req); +handle_route(~"/fortunes", Req) -> + fortunes_endpoint(Req); +handle_route(~"/crud/items", Req) -> + case roadrunner_req:method(Req) of + ~"GET" -> roadrunner_httparena_crud:list(Req); + ~"POST" -> roadrunner_httparena_crud:create(Req); + _ -> {roadrunner_resp:status(405), Req} + end; +handle_route(<<"/crud/items/", _/binary>>, Req) -> + case roadrunner_req:method(Req) of + ~"GET" -> roadrunner_httparena_crud:get(Req); + ~"PUT" -> roadrunner_httparena_crud:update(Req); + _ -> {roadrunner_resp:status(405), Req} + end; +handle_route(~"/ws", Req) -> + {{websocket, roadrunner_httparena_ws, undefined}, Req}; +handle_route(_, Req) -> + {roadrunner_resp:not_found(), Req}. + +upload_endpoint(Req) -> + {Count, Req2} = consume_body(Req, 0), + {roadrunner_resp:text(200, integer_to_binary(Count)), Req2}. + +%% Stream the request body in 64 KB chunks, discarding each chunk +%% after counting its bytes via `iolist_size/1` (the auto-buffered body +%% is `iodata()`, not `binary()`). With `body_buffering => manual` on +%% the listener, `read_body/2 #{length => 65536}` returns one chunk at +%% a time so peak memory stays bounded even for the 20 MB upload +%% validator case. +consume_body(Req, Acc) -> + case roadrunner_req:read_body(Req, #{length => 65536}) of + {ok, Bytes, Req2} -> + {Acc + iolist_size(Bytes), Req2}; + {more, Bytes, Req2} -> + consume_body(Req2, Acc + iolist_size(Bytes)) + end. + +async_db_endpoint(Req) -> + Min = qs_int(~"min", Req, 10), + Max = qs_int(~"max", Req, 50), + Limit = clamp(qs_int(~"limit", Req, 50), 1, 50), + Sql = ~""" + SELECT id, name, category, price, quantity, active, tags, + rating_score, rating_count + FROM items + WHERE price BETWEEN $1 AND $2 LIMIT $3 + """, + Items = + case roadrunner_httparena_db:query(Sql, [Min, Max, Limit]) of + {ok, _Cols, Rows} -> [roadrunner_httparena_items:row_to_json(R) || R <- Rows]; + _ -> [] + end, + Body = #{~"count" => length(Items), ~"items" => Items}, + {roadrunner_resp:json(200, Body), Req}. + +clamp(N, Lo, _Hi) when N < Lo -> Lo; +clamp(N, _Lo, Hi) when N > Hi -> Hi; +clamp(N, _Lo, _Hi) -> N. + +fortunes_endpoint(Req) -> + Rows = + case roadrunner_httparena_db:query(~"SELECT id, message FROM fortune", []) of + {ok, _Cols, R} -> R; + _ -> [] + end, + Runtime = {0, ~"Additional fortune added at request time."}, + Sorted = lists:keysort(2, [Runtime | Rows]), + Body = render_fortunes(Sorted), + {roadrunner_resp:html(200, Body), Req}. + +render_fortunes(Rows) -> + [ + ~"Fortunes", + [render_fortune_row(Id, Msg) || {Id, Msg} <- Rows], + ~"
idmessage
" + ]. + +render_fortune_row(Id, Msg) -> + [~"", integer_to_binary(Id), ~"", html_escape(Msg), ~""]. + +html_escape(Bin) when is_binary(Bin) -> + B1 = binary:replace(Bin, ~"&", ~"&", [global]), + B2 = binary:replace(B1, ~"<", ~"<", [global]), + binary:replace(B2, ~">", ~">", [global]). + +baseline(Req) -> + A = qs_int(~"a", Req, 0), + B = qs_int(~"b", Req, 0), + {BodyN, Req2} = + case roadrunner_req:method(Req) of + ~"POST" -> + {ok, Body, ReqR} = roadrunner_req:read_body(Req), + {body_int(Body), ReqR}; + _ -> + {0, Req} + end, + {roadrunner_resp:text(200, integer_to_binary(A + B + BodyN)), Req2}. + +json_endpoint(Req) -> + Count = binding_int(~"count", Req, 0), + M = qs_int(~"m", Req, 1), + All = roadrunner_httparena_dataset:items(), + Items = lists:sublist(All, max(0, Count)), + Processed = [add_total(I, M) || I <- Items], + Body = #{~"items" => Processed, ~"count" => length(Processed)}, + {roadrunner_resp:json(200, Body), Req}. + +add_total(Item, M) -> + Price = maps:get(~"price", Item), + Qty = maps:get(~"quantity", Item), + Item#{~"total" => Price * Qty * M}. + +binding_int(Key, Req, Default) -> + case roadrunner_req:bindings(Req) of + #{Key := V} when is_binary(V) -> bin_int(V, Default); + _ -> Default + end. + +qs_int(Key, Req, Default) -> + case lists:keyfind(Key, 1, roadrunner_req:parse_qs(Req)) of + {Key, V} when is_binary(V) -> bin_int(V, Default); + _ -> Default + end. + +bin_int(<<>>, Default) -> + Default; +bin_int(Bin, Default) -> + case string:to_integer(Bin) of + {N, _} when is_integer(N) -> N; + _ -> Default + end. + +body_int(<<>>) -> 0; +body_int(Bin) -> bin_int(Bin, 0). diff --git a/frameworks/roadrunner/src/roadrunner_httparena_items.erl b/frameworks/roadrunner/src/roadrunner_httparena_items.erl new file mode 100644 index 000000000..5a2fc64c5 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_items.erl @@ -0,0 +1,16 @@ +-module(roadrunner_httparena_items). + +-export([row_to_json/1]). + +-spec row_to_json(tuple()) -> map(). +row_to_json({Id, Name, Cat, Price, Qty, Active, TagsJsonb, RScore, RCount}) -> + #{ + ~"id" => Id, + ~"name" => Name, + ~"category" => Cat, + ~"price" => Price, + ~"quantity" => Qty, + ~"active" => Active, + ~"tags" => json:decode(TagsJsonb), + ~"rating" => #{~"score" => RScore, ~"count" => RCount} + }. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_sup.erl b/frameworks/roadrunner/src/roadrunner_httparena_sup.erl new file mode 100644 index 000000000..7424f056b --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_sup.erl @@ -0,0 +1,11 @@ +-module(roadrunner_httparena_sup). +-behaviour(supervisor). + +-export([start_link/0, init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + SupFlags = #{strategy => one_for_one, intensity => 5, period => 10}, + {ok, {SupFlags, []}}. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_ws.erl b/frameworks/roadrunner/src/roadrunner_httparena_ws.erl new file mode 100644 index 000000000..fd6a95f4a --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_ws.erl @@ -0,0 +1,11 @@ +-module(roadrunner_httparena_ws). +-behaviour(roadrunner_ws_handler). + +-export([handle_frame/2]). + +handle_frame(#{opcode := text, payload := Payload}, State) -> + {reply, [{text, Payload}], State}; +handle_frame(#{opcode := binary, payload := Payload}, State) -> + {reply, [{binary, Payload}], State}; +handle_frame(_, State) -> + {ok, State}. diff --git a/site/data/frameworks.json b/site/data/frameworks.json index c4ccad95a..080d44277 100644 --- a/site/data/frameworks.json +++ b/site/data/frameworks.json @@ -441,6 +441,13 @@ "type": "engine", "engine": "io_uring" }, + "roadrunner": { + "dir": "roadrunner", + "description": "Pure-Erlang HTTP/1.1, HTTP/2, and WebSocket server. Beep beep.", + "repo": "https://github.com/arizona-framework/roadrunner", + "type": "production", + "engine": "roadrunner" + }, "robyn": { "dir": "robyn", "description": "Robyn is a High-Performance Web Framework with a Rust runtime",