From 79e45787310f109c4c8dbf2d908a380448470cbb Mon Sep 17 00:00:00 2001 From: Relism Date: Sun, 15 Mar 2026 01:54:35 +0100 Subject: [PATCH] Initial --- .gitignore | 39 +++ .idea/.gitignore | 10 + .idea/copilot.data.migration.ask2agent.xml | 6 + .idea/encodings.xml | 9 + .idea/misc.xml | 24 ++ .idea/vcs.xml | 6 + CLAUDE.md | 78 ++++++ dev/relism/fpr/core/FastPathRouter.class | Bin 0 -> 392 bytes .../core/internal/runtime/FrozenRouter.class | Bin 0 -> 8624 bytes .../core/internal/runtime/RouteSearch.class | Bin 0 -> 7420 bytes .../core/internal/runtime/SegmentCursor.class | Bin 0 -> 1503 bytes pom.xml | 85 +++++++ src/main/java/dev/relism/Flash.java | 8 + src/main/java/dev/relism/HttpServer.java | 227 +++++++++++++++++ .../dev/relism/HttpServerConfiguration.java | 11 + src/main/java/dev/relism/Main.java | 41 ++++ src/main/java/dev/relism/RequestParser.java | 143 +++++++++++ .../DuplicateNamespaceException.java | 7 + .../java/dev/relism/http/ContentType.java | 63 +++++ src/main/java/dev/relism/http/HttpMethod.java | 47 ++++ src/main/java/dev/relism/http/HttpStatus.java | 71 ++++++ .../java/dev/relism/models/HeaderMap.java | 96 ++++++++ src/main/java/dev/relism/models/LazyBody.java | 63 +++++ .../java/dev/relism/models/PathParams.java | 51 ++++ .../java/dev/relism/models/QueryParams.java | 100 ++++++++ src/main/java/dev/relism/models/Request.java | 89 +++++++ .../dev/relism/models/RequestHandler.java | 17 ++ .../java/dev/relism/models/RequestLine.java | 17 ++ src/main/java/dev/relism/models/Response.java | 45 ++++ .../java/dev/relism/models/SimpleHandler.java | 26 ++ .../dev/relism/routing/AbstractRouter.java | 133 ++++++++++ .../java/dev/relism/routing/GlobalRouter.java | 77 ++++++ .../java/dev/relism/routing/PathUtils.java | 47 ++++ src/main/java/dev/relism/routing/Route.java | 22 ++ .../fastpathrouter/FastPathRouterImpl.java | 114 +++++++++ .../routers/fastpathrouter/FastPathViews.java | 120 +++++++++ .../routers/radix/RadixPathRouterImpl.java | 5 + .../dev/relism/template/ByteTemplate.java | 75 ++++++ .../java/dev/relism/template/ErrorPages.java | 87 +++++++ .../resources/assets/html/default_404.html | 145 +++++++++++ .../assets/html/default_exception.html | 171 +++++++++++++ src/main/resources/assets/logo.png | Bin 0 -> 65114 bytes .../dev/relism/HttpServerConcurrencyTest.java | 230 ++++++++++++++++++ src/test/java/dev/relism/HttpServerTest.java | 147 +++++++++++ .../java/dev/relism/RequestParserTest.java | 127 ++++++++++ .../java/dev/relism/http/ContentTypeTest.java | 27 ++ .../java/dev/relism/http/HttpMethodTest.java | 69 ++++++ .../java/dev/relism/http/HttpStatusTest.java | 34 +++ .../java/dev/relism/models/HeaderMapTest.java | 105 ++++++++ .../dev/relism/models/PathParamsTest.java | 67 +++++ .../dev/relism/models/QueryParamsTest.java | 72 ++++++ .../dev/relism/models/RequestLineTest.java | 42 ++++ .../java/dev/relism/models/RequestTest.java | 88 +++++++ .../java/dev/relism/models/ResponseTest.java | 71 ++++++ .../dev/relism/models/SimpleHandlerTest.java | 19 ++ .../relism/routing/AbstractRouterTest.java | 133 ++++++++++ .../dev/relism/routing/GlobalRouterTest.java | 111 +++++++++ .../dev/relism/routing/PathUtilsTest.java | 63 +++++ .../FastPathRouterImplTest.java | 76 ++++++ .../fastpathrouter/FastPathViewsTest.java | 64 +++++ .../dev/relism/template/ByteTemplateTest.java | 58 +++++ .../dev/relism/template/ErrorPagesTest.java | 59 +++++ 62 files changed, 4037 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/copilot.data.migration.ask2agent.xml create mode 100644 .idea/encodings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/vcs.xml create mode 100644 CLAUDE.md create mode 100644 dev/relism/fpr/core/FastPathRouter.class create mode 100644 dev/relism/fpr/core/internal/runtime/FrozenRouter.class create mode 100644 dev/relism/fpr/core/internal/runtime/RouteSearch.class create mode 100644 dev/relism/fpr/core/internal/runtime/SegmentCursor.class create mode 100644 pom.xml create mode 100644 src/main/java/dev/relism/Flash.java create mode 100644 src/main/java/dev/relism/HttpServer.java create mode 100644 src/main/java/dev/relism/HttpServerConfiguration.java create mode 100644 src/main/java/dev/relism/Main.java create mode 100644 src/main/java/dev/relism/RequestParser.java create mode 100644 src/main/java/dev/relism/exceptions/DuplicateNamespaceException.java create mode 100644 src/main/java/dev/relism/http/ContentType.java create mode 100644 src/main/java/dev/relism/http/HttpMethod.java create mode 100644 src/main/java/dev/relism/http/HttpStatus.java create mode 100644 src/main/java/dev/relism/models/HeaderMap.java create mode 100644 src/main/java/dev/relism/models/LazyBody.java create mode 100644 src/main/java/dev/relism/models/PathParams.java create mode 100644 src/main/java/dev/relism/models/QueryParams.java create mode 100644 src/main/java/dev/relism/models/Request.java create mode 100644 src/main/java/dev/relism/models/RequestHandler.java create mode 100644 src/main/java/dev/relism/models/RequestLine.java create mode 100644 src/main/java/dev/relism/models/Response.java create mode 100644 src/main/java/dev/relism/models/SimpleHandler.java create mode 100644 src/main/java/dev/relism/routing/AbstractRouter.java create mode 100644 src/main/java/dev/relism/routing/GlobalRouter.java create mode 100644 src/main/java/dev/relism/routing/PathUtils.java create mode 100644 src/main/java/dev/relism/routing/Route.java create mode 100644 src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java create mode 100644 src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java create mode 100644 src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java create mode 100644 src/main/java/dev/relism/template/ByteTemplate.java create mode 100644 src/main/java/dev/relism/template/ErrorPages.java create mode 100644 src/main/resources/assets/html/default_404.html create mode 100644 src/main/resources/assets/html/default_exception.html create mode 100644 src/main/resources/assets/logo.png create mode 100644 src/test/java/dev/relism/HttpServerConcurrencyTest.java create mode 100644 src/test/java/dev/relism/HttpServerTest.java create mode 100644 src/test/java/dev/relism/RequestParserTest.java create mode 100644 src/test/java/dev/relism/http/ContentTypeTest.java create mode 100644 src/test/java/dev/relism/http/HttpMethodTest.java create mode 100644 src/test/java/dev/relism/http/HttpStatusTest.java create mode 100644 src/test/java/dev/relism/models/HeaderMapTest.java create mode 100644 src/test/java/dev/relism/models/PathParamsTest.java create mode 100644 src/test/java/dev/relism/models/QueryParamsTest.java create mode 100644 src/test/java/dev/relism/models/RequestLineTest.java create mode 100644 src/test/java/dev/relism/models/RequestTest.java create mode 100644 src/test/java/dev/relism/models/ResponseTest.java create mode 100644 src/test/java/dev/relism/models/SimpleHandlerTest.java create mode 100644 src/test/java/dev/relism/routing/AbstractRouterTest.java create mode 100644 src/test/java/dev/relism/routing/GlobalRouterTest.java create mode 100644 src/test/java/dev/relism/routing/PathUtilsTest.java create mode 100644 src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java create mode 100644 src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java create mode 100644 src/test/java/dev/relism/template/ByteTemplateTest.java create mode 100644 src/test/java/dev/relism/template/ErrorPagesTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..480bdf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..f938323 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..12f302d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f06836d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run Commands + +```bash +# Build the project +mvn compile + +# Package (JAR) +mvn package + +# Run (entry point is dev.relism.Main) +mvn exec:java -Dexec.mainClass="dev.relism.Main" + +# Clean build artifacts +mvn clean + +# Full clean build +mvn clean package +``` + +No tests exist yet. The project resolves the `fpr-core` dependency from a private Reposilite repository at `https://maven.relism.dev/releases`. + +## Architecture Overview + +**Flash** is a hand-rolled, zero-allocation HTTP/1.1 server built on raw Java sockets with virtual threads (Java 21). The central design goal is extreme low latency — no intermediate String allocations on the hot path. + +### Request Lifecycle + +``` +Socket → RequestParser → GlobalRouter → AbstractRouter impl → RequestHandler → Response bytes → Socket +``` + +1. **`RequestParser`** — Reads raw bytes from the socket into an 8 KB buffer and scans for method, path, headers, and body without creating intermediate String objects. All parsed values are wrapped in `ByteView` implementations (see `FastPathViews`). + +2. **`GlobalRouter`** — The top-level dispatcher. Holds a sorted list of mounted sub-routers (longest namespace prefix first) and a default internal router. Dispatches each request via byte-level prefix matching (`ByteUtils.startsWith`). + +3. **`FastPathRouterImpl`** — The primary router implementation, backed by the external `fpr-core` library. Builds a compiled state machine (`RouterBuilder → FastPathRouter`) for ultra-fast byte-level method+path matching. Routes are marked dirty (`router = null`) when added and lazily recompiled on first request. Thread-local `MatchResult` objects avoid per-request allocation. + +4. **`RadixPathRouterImpl`** — Stub; not yet implemented. + +### Key Abstractions + +- **`AbstractRouter`** — Base class for all routers. Has a `namespace` (byte array for zero-alloc prefix checks) and two abstract methods: `route(Request)` and `addRoute(HttpMethod, String, RequestHandler)`. +- **`RequestHandler`** — Abstract class handlers extend. The `handle(Request, Response)` method returns either a `Response` object or any other value (serialized as the body) or `null`. +- **`SimpleHandler`** — Wraps a `FunctionalHandler` lambda for the fluent DSL (`server.get(path, handler)`). +- **`@Route` annotation** — Applied to `RequestHandler` subclasses to declare their HTTP method and path. Used by `AbstractRouter.register()`. +- **`ByteView`** (`fpr-core` interface) — Zero-copy abstraction over byte sequences. Implemented by `RequestByteView` (slice into the raw buffer), `MethodPathByteView` (virtual concatenation of method + path), `SocketByteView`, and `StringByteView` — all in `FastPathViews`. + +### Routing Registration (Two Styles) + +**Annotation-based (class handlers):** +```java +@Route(method = "GET", path = "/profile") +public static class ProfileHandler extends RequestHandler { ... } + +apiRouter.register(new ProfileHandler()); // namespace "/api" + path "/profile" = "/api/profile" +``` + +**Fluent DSL (lambdas):** +```java +server.get("/hello", (req, res) -> "Hello World"); +``` + +Paths registered via `addRoute` are prefixed with the router's namespace inside `FastPathRouterImpl.addRoute`. + +### Response Writing + +`HttpServer` writes HTTP/1.1 responses with pre-calculated static byte arrays (`HTTP_1_1`, `CRLF`, `CONTENT_TYPE`, `CONTENT_LENGTH`) to avoid per-response allocations. `IoUtils.writeInt` writes integer values without String conversion. + +### Performance Invariants to Maintain + +- No `String` creation on the routing hot path (use `ByteView` and byte arrays). +- Thread-local `MatchResult` reuse in `FastPathRouterContext` — do not allocate per request. +- `GlobalRouter.route` must stay allocation-free (sorted list iterated directly). +- `RequestParser` assumes headers fit in one 8 KB burst read. diff --git a/dev/relism/fpr/core/FastPathRouter.class b/dev/relism/fpr/core/FastPathRouter.class new file mode 100644 index 0000000000000000000000000000000000000000..73c729197d3bd16356ced18f5cca2e6b6915c65d GIT binary patch literal 392 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 a6y+Rqv$~W60kuVK6>>7UmZi;QnD#sIZ&1(x literal 0 HcmV?d00001 diff --git a/dev/relism/fpr/core/internal/runtime/RouteSearch.class b/dev/relism/fpr/core/internal/runtime/RouteSearch.class new file mode 100644 index 0000000000000000000000000000000000000000..98f12035b4adfb5bdb6ca0f5ed89354194e26839 GIT binary patch literal 7420 zcmc&(3w)H-mH*%GOEP5gC4?kj5+FiiLGmyO&p{0+Nq7wo;gK{7>Y5?*5r#}=#+eBa zwHB55sMc1i?Sd()MOyY6ncx{t2==q|0U>Hpks28PZ2 zR`mDV-@=bM=ezgZbI&>Vyzb?tcb?&NTGaZa96NB5{wSDZ`iie z8B8e@Eel7(smm42#-@G`rYQJA&aU=^6A33r+PB9O?Srv|(;kkdoJ2GjX-|wsQ{fS( zeRFIy<@7qi#NduOs}sQy#|sb4VwAx0pwvPcrYg*TudEabw+l0cO9Jn8tt&=HF{Yuy zgGvikuoYU*i_NOgkaJ}?8dC5C{C%r7cLo&N&rgEv(he^-uCY*y>Eseigu*nJHmhH( zGSfmGE>I{L<~D&attqprOT?XRpkh(833Vx>%^!hxa%kp_^;rB2Blk zb93iw}45A=)fcnyAmh2hS48sr#cL zM{~MLrZmwO3tMpw&rNf3J?gylryrEyI$8Y(qJNj@HeewrI~EN)WByQ5C*Ki{jbe ziMu?w+rmdAmKNr7x=Hz{*BKgdqN(oDL^773rzbP+BFyf!@KNDZ#6ij_Lm#v7aUG~p z*CD)k0H5&SK?@Jb@a%kh=6EbkJ3&9`cjq>)%`cr@Og$pHcV zMSj~EnhICv50*(xRwrUNI#Hd36CHWut7|Of^oO0@9e)2-e-q>D5ghd3kcGo|RAIq+ ziJH5M!c@9i%F)@VJH|2~j@m1HKW*WN#BC0Nc9{}9j-wuY#=@WAvkGBuvFqwSydn!*uR)v@=b*Sq>nUV@x3=xEyN!PgirXZs3&B;pJOBeYJ+ zS+!@-iKoJ`=oEZiVP?+4db-l!aJ{oY2@+QeRb7k}o#pIi7I{^Fblr+18uN0?iJi9~R0Fcu$EXwGHrc0e8e zoZYfRes{mWwD1GGEC(KoM7B}jzL@Ks{c^HDwD5|YtW8}qVd47PnX~_~g`Y@VNL@)e zx~@q5k;SiC_}SShDan~?@(6xm;jd+CnqH;LaAzbU3kxSQf4t0{e`n#B_UTYDVJ8E z`dmrJ;#q0uyxZbs&6iWK;@=I(TwgAj>g3UX60RdMn@LB!A!HVI4|X-ea_Wa8mrNG zJjUiP5Q`2`wXIYn;@&Kz5c05w9Vf2qh0Z2oqrCia?myk8@eljx2FlSzl4h5GW2`K%}g({D9um7z>1RlqL;ukj!6 zWE$s{OG93)H013`Ltc* zd4)o4uor(PeL_62hWz1C{DxM{0z!Z zp!)taW)Pj_q76jnxad5h%`VzXbiRu&dYY^ZRm9&}ta`Fp0cKLX3owHtb1|C+tVahL zl+0T0)@q6pp`g5q#?@k-ra8sTyP8{0!I#D|fmfu_C2(b$;u8AP2ng&+W0Sz%H2MWz zoyN5SuTNu}z)%`P0>f!U1V+=iL0~fN^52ujP4fMrH1;-|Y1}4Sm|BZ2loOwWDU`jO zj|#PzNBJB1w~3lG;}W!B1s@UCqLugGHeAj3g8|O$zye+p7xEIgNNZZ{YLnFzyOgC$ zc?4zT?pi@crgB8~Gt@NxmZ^%&A+^&^a!7^E$KicS6NrgjO z6n;AOxp{iQ3EbiH2)M^LMZkT&;^{>vaKF#n(rSC`DRyDqDSXmbVwV_);VC$Z0@G); z9LFEkE%B8SK33P}E3?b&(gEL8(^qc#rrD(j)va9nMf|bB2bGCid=)KqcEu??v9z+f z@)Vx-*#gqOYP+rC=6)~vsAMv0n34cmhMfh_<+wfHz)TP8Y1hMK2 zo=J(X<|tO$6`7$14pqp|0=tq!!gZdn`Y0~6t1=X=q^J^#skY6bVp1%UvlV}-!fQ|J z(OGu2j30vCEU?bdzEv#t{aejxwkBvjglZvZt+T6gDP(Jx#k>rQdFQgA!U4^p;V_En z0Y|W$zajCdhp{j_Q)1U@ubL@)G}Q5DuU*@gv(diViK7$W%aU)(IDq(4AnQRlm|R9T z%6iZ)`KB9Xzi*Onx{>}C2KKapQ~1?Uc(dH9?V3~ghbI}Q1(Hxy0@o71g2{0O^L8g} zbn(4&B~!_2-bU7NW-WjH=)pQ{;=5ZP0@#A}7~)7A8?c*c<`&M}j?KJV^zs#^4~O`w z_86|hai*W=N&O2zMG5?lLNHw=o?bF=pc)V=nGBF2+ZVg}Be?z{iYEeB4-r`;8tvVD#Y=##VgN z7{I5D5DpkgJZy~P5#vD|G>+o1aRQGTFXGe2t2ko(ib>`-c-%OR&zR*nVpijrIU7%! z7vXbe3qEho$5ZACJZ)~oGv)w}n9iPfmfGGjFu zQgKzou?oc1kg8>EucW>Me99Q-Dcz#B@Y!M<)yCgoafSTO!M69W{A~m>13F zX4OEf1WV0T>Oyj{aD};CeSl-7SYs|xbBL9p$86&XkF)4*GF#L|#LCfUHmZ5Vrm5G} zgQ}5Og?dxntD1;as?%zpY9>}?K;2BwpuUEw#^lCHeT&pCxslRVQmk|#qwLew#jdux zY!#_i&b@$!2`rZX*C_GOtR6h>!JPyTDER}dP!sgf+Wga+hTW7)S~>SEe6Z-ZD14h` z(EXU8)%gDv`B=Mafl}FF;k)dhiP<)lRg@IhH`G_YjY4JD{0@b0!EY61>Tererd`d? z)Zg#&5P6Kaj?sSG0Ti0Ynzs-u2yJDvu)Kw%%j(+F__bhNg8i#tYXtju!2*K)MzBqS z{g+_@hNU&kVR1IzJE=7?`G z3rM7YTl|~etp&{bBd${xJ%EocSgRKB^bCDM?DdN2ntTs&H6xKpcLRng$8G$#Y*MLR zCNp&#_}e9zT_EThjTQ6YXv|rE*1iMzSnSHLo2F_`&Ts?n4KYp2h^+W3L z3frk4!G|9+Gya5`@2BX;t8A!#hMoAi)^oEf?ioFYwU^Sl_->zxJ7VImVvk`RS<;~o z>&TLhcUVW3^iRX`{sK?dGSv(+zU*Yqypy5HAV%qo@$&kynd4OoAHG%cxZB>kz1M46 zCbzjO)sm2a(J1@#s)z}-FvD&EwCS>^Q~y6b*9>|92_)QsFt(4!VH`GCvcW=eP4 r^j0pG<>sur{^!y+a+aEzrKVb}H(0_P&&oqtJZdR1uV5%qm!S6d0d6xP literal 0 HcmV?d00001 diff --git a/dev/relism/fpr/core/internal/runtime/SegmentCursor.class b/dev/relism/fpr/core/internal/runtime/SegmentCursor.class new file mode 100644 index 0000000000000000000000000000000000000000..dfcf53bee3a263ab03006fdf44b7decd67d8625d GIT binary patch literal 1503 zcmaJ>%We}%6g}l<657NhaRPZbB$!}#APwQE$xKEB3CUn!5flklbnGHD*zM46hai@S z?*Oua1)Gt^8f6yq0rLm5=06zDtvGRrTFC0Uw{F#`I``aL{`cG8{{Xm-s{@E3o<_n# z5-El3b9c*i>aM@xJXn9OtD!>bSI_stWrcXLwAPP4^rtak;TSB1#hTu70$ul74d>}* z;8dG|c051Sf$!FxpzViVLp!T_qoMuqRy$}l1BImLZ?<`Gz7mCAc^&FCPrq6^hT|Aa zBWvLVh7=}$wwWyJ+TRGD8J1;XIbtD)QO>!bpiJzfg;OTx)wbpM2@8{$B6yvb-Xx_& zv0Pda^t6T3va_$HH&#P82qidc;fw@R3|6!+p|cj|B$W2`c37@$^N3r`npPOBc)q^h zZmjFzp}SsZWT?`ty7e_T@MPW%CcPfSvb3g$rHsZ>Ib$goZ67~292Cycb7+-B?7yJ?pc6xpqb$p|5=X1++nDDJIFAdA z2>3JNhQwWazJs(qzl)4Q2g7!}gE9M0^9}D{a$fvhjK`35J|JU1;xzF@_Hbj~j&(3& z%a#rbLLqJ_%%PwDagrG1FN+a2;{t|ogWhk>16z>{M6!*(%>IRKN z$@W-(l@KHE_8EReGXAAYAip2n!7SahOuNkABim#C>Hq%>g6sL(A^tu1^bxouE~}Dw zLA(M5NPIC2Me+r52F5u~AcHApn8zZf@jGTv!7Lu~1-wK7Z`k&4DSxjt@49(Qwkaes z>CzItOGNJ_+@LxMMsoIq$UP}^r-THDv0lwbDK%K*Br80?8V{1v2z8T-?7agH1kTAD zH7e%hVH){!@*b%zxHi + + 4.0.0 + + dev.relism + Flash + 1.0-SNAPSHOT + pom + + flash-bench + + + + 21 + 21 + UTF-8 + + + + + reposilite-repository-releases + Reposilite Repository + https://maven.relism.dev/releases + + + + + + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + org.projectlombok + lombok + 1.18.44 + provided + + + dev.relism + fpr-core + 1.1.0 + + + + org.slf4j + slf4j-api + 2.0.16 + + + + org.slf4j + slf4j-simple + 2.0.16 + runtime + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.44 + + + + + + + + \ No newline at end of file diff --git a/src/main/java/dev/relism/Flash.java b/src/main/java/dev/relism/Flash.java new file mode 100644 index 0000000..84832bf --- /dev/null +++ b/src/main/java/dev/relism/Flash.java @@ -0,0 +1,8 @@ +package dev.relism; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public final class Flash { + public static final String VERSION = "5.0.0-dev"; +} diff --git a/src/main/java/dev/relism/HttpServer.java b/src/main/java/dev/relism/HttpServer.java new file mode 100644 index 0000000..81df8fa --- /dev/null +++ b/src/main/java/dev/relism/HttpServer.java @@ -0,0 +1,227 @@ +package dev.relism; + +import dev.relism.http.ContentType; +import dev.relism.http.HttpStatus; +import dev.relism.models.*; +import dev.relism.routing.AbstractRouter; +import dev.relism.routing.GlobalRouter; + +import lombok.extern.slf4j.Slf4j; + +import java.io.*; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * Entry point for the Flash HTTP server. + * + *

Internally, the server owns a {@link GlobalRouter} with two tiers of routing: + *

    + *
  1. Mounted sub-routers — registered via {@link #mount}. Each owns a namespace + * prefix (e.g. {@code /api}) and handles all paths under it. Matched by longest prefix first.
  2. + *
  3. Internal router — the fallback used when no sub-router claims the path. + * Routes registered directly on the server ({@link #get}, {@link #post}, etc.) go here.
  4. + *
+ * + *

Error handlers ({@link #onNotFound}, {@link #onException}) registered on the server + * apply only to the internal router. Each mounted sub-router has its own independent handlers. + */ +@Slf4j +public class HttpServer { + private final HttpServerConfiguration configuration; + private final ServerSocket serverSocket; + private final GlobalRouter globalRouter = new GlobalRouter(); + private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); + private volatile boolean stopped = false; + private final CompletableFuture readyFuture = new CompletableFuture<>(); + + private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8); + private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8); + private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8); + private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8); + private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8); + + private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8); + + private static final byte[][] DIGITS = new byte[10][1]; + static { + for (int i = 0; i < 10; i++) DIGITS[i] = String.valueOf(i).getBytes(StandardCharsets.UTF_8); + } + + public HttpServer(HttpServerConfiguration configuration) throws IOException { + this.configuration = configuration; + this.serverSocket = new ServerSocket(configuration.getPort()); + } + + /** + * Starts the server on a new non-daemon virtual thread and returns a future that completes + * when the accept loop is running and the server is ready to serve requests. + */ + public CompletableFuture start() { + Thread.ofVirtual().name("flash-accept-loop").start(this::run); + return readyFuture; + } + + private void run() { + log.info("Server started on port {}", configuration.getPort()); + try (executorService) { + readyFuture.complete(null); + while (!stopped) { + Socket clientSocket = serverSocket.accept(); + process(clientSocket); + } + } catch (IOException e) { + if (!stopped) { + readyFuture.completeExceptionally(e); + log.error("Accept loop error", e); + } + } + } + + /** + * Stops the server and waits for in-flight requests to complete. + * The returned future is already completed when this method returns. + */ + public CompletableFuture stop() { + stopped = true; + try { serverSocket.close(); } catch (IOException e) { log.error("Error closing server socket", e); } + executorService.shutdown(); + try { + if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) executorService.shutdownNow(); + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + return CompletableFuture.completedFuture(null); + } + + // --- Routing --- + + /** + * Mounts a sub-router under the given namespace prefix. + * + *

Any request whose path starts with {@code namespace} will be dispatched to {@code router} + * instead of the internal router. When multiple namespaces match, the longest prefix wins. + * The router's own {@code onNotFound} and {@code onException} handlers are used for its paths — + * the server-level handlers do not apply. + * + * @throws dev.relism.exceptions.DuplicateNamespaceException if {@code namespace} is already mounted + */ + public HttpServer mount(String namespace, AbstractRouter router) { + globalRouter.mount(namespace, router); + return this; + } + + // --- Global error handlers --- + + /** + * Sets the 404 handler for routes registered directly on this server. + * Does not affect mounted sub-routers, which each carry their own not-found handler. + */ + public HttpServer onNotFound(SimpleHandler.FunctionalHandler handler) { + globalRouter.onNotFound(handler); + return this; + } + + /** + * Sets the exception handler for routes registered directly on this server. + * Does not affect mounted sub-routers, which each carry their own exception handler. + */ + public HttpServer onException(AbstractRouter.ExceptionHandler handler) { + globalRouter.onException(handler); + return this; + } + + // --- DSL --- + + /** + * Registers an annotation-based handler on the internal router. + * The handler's class must carry a {@link dev.relism.routing.Route @Route} annotation + * declaring the HTTP method and path. To register on a specific sub-router, call + * {@link AbstractRouter#register} on that router directly. + */ + public HttpServer register(RequestHandler handler) { globalRouter.register(handler); return this; } + + /** + * Registers a route on the internal router (not on any mounted sub-router). + * The handler's return value is used as the response body; returning a {@link dev.relism.models.Response} + * instance replaces the entire response object. + */ + public HttpServer get(String path, SimpleHandler.FunctionalHandler h) { globalRouter.get(path, h); return this; } + /** @see #get(String, SimpleHandler.FunctionalHandler) */ + public HttpServer post(String path, SimpleHandler.FunctionalHandler h) { globalRouter.post(path, h); return this; } + /** @see #get(String, SimpleHandler.FunctionalHandler) */ + public HttpServer put(String path, SimpleHandler.FunctionalHandler h) { globalRouter.put(path, h); return this; } + /** @see #get(String, SimpleHandler.FunctionalHandler) */ + public HttpServer delete(String path, SimpleHandler.FunctionalHandler h) { globalRouter.delete(path, h); return this; } + + // --- Request processing --- + + private void process(Socket socket) { + executorService.submit(() -> { + try (socket; + InputStream in = socket.getInputStream(); + OutputStream out = new BufferedOutputStream(socket.getOutputStream()) + ) { + long start = System.nanoTime(); + Request request = RequestParser.parse(in); + if (request == null) return; + + Response response = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + RequestHandler handler = globalRouter.route(request); + + try { + Object result = handler.handle(request, response); + if (result instanceof Response r) response = r; + else if (result != null) response.setBody(result); + } catch (Exception ex) { + Object result = globalRouter.resolveExceptionHandler(request).handle(ex, request, response); + if (result instanceof Response r) response = r; + else if (result != null) response.setBody(result); + } + + out.write(HTTP_1_1); + writeStatusPhrase(out, response.getStatusCode()); + out.write(CRLF); + out.write(CONTENT_TYPE); + out.write(response.getContentType()); + out.write(CRLF); + out.write(CONTENT_LENGTH); + writeInt(out, response.getBody() != null ? response.getBody().length : 0); + out.write(CRLF); + out.write(CONNECTION_CLOSE); + out.write(CRLF); + if (response.getBody() != null) out.write(response.getBody()); + out.flush(); + log.info("{} ms: {}", (System.nanoTime() - start) / 1_000_000.0, request.getRequestLine()); + + } catch (IOException e) { + log.error("I/O error handling request", e); + } + }); + } + + private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException { + byte[] phrase = HttpStatus.bytesForCode(statusCode); + if (phrase != null) { + out.write(phrase); + } else { + writeInt(out, statusCode); + out.write(UNKNOWN_STATUS_SUFFIX); + } + } + + + private static void writeInt(OutputStream out, int value) throws IOException { + if (value == 0) { out.write(DIGITS[0]); return; } + if (value < 0) { out.write('-'); value = -value; } + int divisor = 1; + while (value / divisor >= 10) divisor *= 10; + while (divisor > 0) { out.write(DIGITS[(value / divisor) % 10]); divisor /= 10; } + } +} diff --git a/src/main/java/dev/relism/HttpServerConfiguration.java b/src/main/java/dev/relism/HttpServerConfiguration.java new file mode 100644 index 0000000..3a2d9f8 --- /dev/null +++ b/src/main/java/dev/relism/HttpServerConfiguration.java @@ -0,0 +1,11 @@ +package dev.relism; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class HttpServerConfiguration { + private int port; + private String host; +} diff --git a/src/main/java/dev/relism/Main.java b/src/main/java/dev/relism/Main.java new file mode 100644 index 0000000..b236418 --- /dev/null +++ b/src/main/java/dev/relism/Main.java @@ -0,0 +1,41 @@ +package dev.relism; + +import dev.relism.http.ContentType; +import dev.relism.models.*; +import dev.relism.routing.Route; +import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl; + +import java.io.IOException; + +public class Main { + + /* + @Route(method = "GET", path = "/profile") + public static class ProfileHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return "Class based handler: User profile info under /api/profile"; + } + } + */ + + public static void main(String[] args) throws IOException { + HttpServerConfiguration config = HttpServerConfiguration.builder() + .port(8080) + .host("localhost") + .build(); + + HttpServer server = new HttpServer(config); + + FastPathRouterImpl apiRouter = new FastPathRouterImpl(); + server.mount("/api", apiRouter); + + // apiRouter.register(new ProfileHandler()); + + server.get("/headers", (req, res) -> { + throw new RuntimeException(req.getQueryParam("test")); + }); + + server.start().thenRun(() -> System.out.println("Flash running on :" + config.getPort())); + } +} diff --git a/src/main/java/dev/relism/RequestParser.java b/src/main/java/dev/relism/RequestParser.java new file mode 100644 index 0000000..c32dce3 --- /dev/null +++ b/src/main/java/dev/relism/RequestParser.java @@ -0,0 +1,143 @@ +package dev.relism; + +import dev.relism.models.HeaderMap; +import dev.relism.http.HttpMethod; +import dev.relism.models.Request; +import dev.relism.models.RequestLine; +import dev.relism.routing.routers.fastpathrouter.FastPathViews; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Extreme Zero-Allocation Request Parser. + * Scans raw bytes to identify paths, protocols, and headers without intermediate String objects. + * Uses buffered reading and direct byte comparisons for maximum performance. + */ +@Slf4j +public class RequestParser { + private static final int MAX_HEADER_SIZE = 8192; + + public static Request parse(InputStream in) throws IOException { + byte[] buffer = new byte[MAX_HEADER_SIZE]; + + // 1. Robust read loop — accumulate until \r\n\r\n found or buffer full. + // prevTotal tracked per iteration so findEndOfHeader starts from max(0, prevTotal-3), + // skipping already-scanned bytes. The -3 overlap catches \r\n\r\n split across two reads. + int totalRead = 0; + int headerEndIdx = -1; + while (totalRead < buffer.length) { + int n = in.read(buffer, totalRead, buffer.length - totalRead); + if (n <= 0) break; + int prevTotal = totalRead; + totalRead += n; + headerEndIdx = findEndOfHeader(buffer, Math.max(0, prevTotal - 3), totalRead); + if (headerEndIdx != -1) break; + } + if (totalRead <= 0) return null; + if (headerEndIdx == -1) { + throw new IOException("Headers too large: buffer exhausted without finding \\r\\n\\r\\n"); + } + + // Timer starts here — after I/O, measuring only CPU parse time + long start = System.nanoTime(); + + // 2. Scan Request Line: METHOD PATH PROTOCOL + int methodEnd = find(buffer, 0, headerEndIdx, (byte) ' '); + if (methodEnd == -1) throw new IOException("Invalid request line (method)"); + + HttpMethod method = HttpMethod.fromBytes(buffer, 0, methodEnd); + if (method == null) throw new IOException("Unsupported HTTP method"); + + int pathStart = methodEnd + 1; + int pathEnd = find(buffer, pathStart, headerEndIdx, (byte) ' '); + if (pathEnd == -1) throw new IOException("Invalid request line (path)"); + + // Split path from query string at '?' — FPR only sees the clean path + int queryMark = find(buffer, pathStart, pathEnd, (byte) '?'); + FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart, + queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart); + FastPathViews.RequestByteView queryView = queryMark != -1 + ? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1) + : null; + + int protocolStart = pathEnd + 1; + int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r'); + if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)"); + + FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart); + + // 3. Scan Headers — store raw offsets into buffer, zero objects allocated per header + HeaderMap headerMap = new HeaderMap(buffer); + int current = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1; + int contentLength = 0; + + while (current < headerEndIdx) { + int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r'); + if (lineEnd == -1 || lineEnd == current) break; + + int colon = find(buffer, current, lineEnd, (byte) ':'); + if (colon != -1) { + int valueStart = colon + 1; + while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++; + + headerMap.add(current, colon - current, valueStart, lineEnd - valueStart); + + // Direct byte comparison to extract Content-Length without String allocation + if (equalsIgnoreCase(buffer, current, colon, "content-length")) { + contentLength = parseInt(buffer, valueStart, lineEnd); + } + } + current = lineEnd + 2; // skip \r\n + } + + long elapsed = System.nanoTime() - start; + log.debug("Request parsed in {}ns: {} {} {}", elapsed, method, pathView, protocolView); + + // Body is read lazily — only materialized if the handler calls req.getBody(). + // Bytes already in the buffer past \r\n\r\n are handed off to LazyBody as read-ahead. + int bodyStart = headerEndIdx + 4; + int preBufLen = totalRead - bodyStart; + return Request.forParsed( + new RequestLine(method, pathView, queryView, protocolView, headerMap), + in, contentLength, buffer, bodyStart, preBufLen); + } + + private static int findEndOfHeader(byte[] buf, int from, int len) { + for (int i = from; i <= len - 4; i++) { + if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n') { + return i; + } + } + return -1; + } + + private static int find(byte[] buf, int start, int end, byte target) { + for (int i = start; i < end; i++) { + if (buf[i] == target) return i; + } + return -1; + } + + private static boolean equalsIgnoreCase(byte[] buf, int start, int end, String target) { + int len = end - start; + if (len != target.length()) return false; + for (int i = 0; i < len; i++) { + byte b = buf[start + i]; + if (b >= 'A' && b <= 'Z') b += 32; + if (b != (byte) target.charAt(i)) return false; + } + return true; + } + + private static int parseInt(byte[] buf, int start, int end) { + int value = 0; + for (int i = start; i < end; i++) { + byte c = buf[i]; + if (c >= '0' && c <= '9') value = value * 10 + (c - '0'); + } + return value; + } +} diff --git a/src/main/java/dev/relism/exceptions/DuplicateNamespaceException.java b/src/main/java/dev/relism/exceptions/DuplicateNamespaceException.java new file mode 100644 index 0000000..182898d --- /dev/null +++ b/src/main/java/dev/relism/exceptions/DuplicateNamespaceException.java @@ -0,0 +1,7 @@ +package dev.relism.exceptions; + +public class DuplicateNamespaceException extends RuntimeException { + public DuplicateNamespaceException(String namespace) { + super("Router with namespace '" + namespace + "' is already registered."); + } +} diff --git a/src/main/java/dev/relism/http/ContentType.java b/src/main/java/dev/relism/http/ContentType.java new file mode 100644 index 0000000..97cd6ef --- /dev/null +++ b/src/main/java/dev/relism/http/ContentType.java @@ -0,0 +1,63 @@ +package dev.relism.http; + +import lombok.Getter; + +import java.nio.charset.StandardCharsets; + +/** + * Pre-compiled byte representations of common HTTP {@code Content-Type} values. + * {@link #getBytes()} returns the pre-computed array directly — never allocates. + */ +@Getter +public enum ContentType { + + // Text + TEXT_PLAIN ("text/plain"), + TEXT_HTML ("text/html"), + TEXT_CSS ("text/css"), + TEXT_JAVASCRIPT ("text/javascript"), + TEXT_XML ("text/xml"), + TEXT_CSV ("text/csv"), + TEXT_MARKDOWN ("text/markdown"), + TEXT_EVENT_STREAM ("text/event-stream"), + + // Application + JSON ("application/json"), + XML ("application/xml"), + BINARY ("application/octet-stream"), + PDF ("application/pdf"), + ZIP ("application/zip"), + GZIP ("application/gzip"), + FORM_URLENCODED ("application/x-www-form-urlencoded"), + MULTIPART_FORM ("multipart/form-data"), + GRAPHQL ("application/graphql"), + NDJSON ("application/x-ndjson"), + MSGPACK ("application/msgpack"), + CBOR ("application/cbor"), + LD_JSON ("application/ld+json"), + + // Image + IMAGE_PNG ("image/png"), + IMAGE_JPEG ("image/jpeg"), + IMAGE_GIF ("image/gif"), + IMAGE_WEBP ("image/webp"), + IMAGE_SVG ("image/svg+xml"), + IMAGE_ICO ("image/x-icon"), + IMAGE_AVIF ("image/avif"), + + // Font + FONT_WOFF ("font/woff"), + FONT_WOFF2 ("font/woff2"), + + // Audio / Video + AUDIO_MPEG ("audio/mpeg"), + AUDIO_OGG ("audio/ogg"), + VIDEO_MP4 ("video/mp4"), + VIDEO_WEBM ("video/webm"); + + private final byte[] bytes; + + ContentType(String value) { + this.bytes = value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/dev/relism/http/HttpMethod.java b/src/main/java/dev/relism/http/HttpMethod.java new file mode 100644 index 0000000..473987a --- /dev/null +++ b/src/main/java/dev/relism/http/HttpMethod.java @@ -0,0 +1,47 @@ +package dev.relism.http; + +import lombok.Getter; +import java.nio.charset.StandardCharsets; + +@Getter +public enum HttpMethod { + GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, TRACE, CONNECT, PURGE; + + private static final HttpMethod[] VALUES = values(); + private final byte[] bytes; + + HttpMethod() { + this.bytes = this.name().getBytes(StandardCharsets.UTF_8); + } + + /** + * Resolves the HttpMethod from a byte buffer segment using O(1) dispatch. + * Extremely fast, zero allocations, branch-prediction friendly. + */ + public static HttpMethod fromBytes(byte[] buf, int off, int len) { + if (len == 0) return null; + + return switch (buf[off]) { + case 'G' -> (len == 3 && buf[off + 1] == 'E' && buf[off + 2] == 'T') ? GET : null; + case 'P' -> { + if (len == 3 && buf[off + 1] == 'U' && buf[off + 2] == 'T') yield PUT; + if (len == 4 && buf[off + 1] == 'O' && buf[off + 2] == 'S' && buf[off + 3] == 'T') yield POST; + if (len == 5 && buf[off + 1] == 'A' && buf[off + 2] == 'T' && buf[off + 3] == 'C' && buf[off + 4] == 'H') yield PATCH; + if (len == 5 && buf[off + 1] == 'U' && buf[off + 2] == 'R' && buf[off + 3] == 'G' && buf[off + 4] == 'E') yield PURGE; + yield null; + } + case 'D' -> (len == 6 && buf[off + 1] == 'E' && buf[off + 2] == 'L' && buf[off + 3] == 'E' && buf[off + 4] == 'T' && buf[off + 5] == 'E') ? DELETE : null; + case 'O' -> (len == 7 && buf[off + 1] == 'P' && buf[off + 2] == 'T' && buf[off + 3] == 'I' && buf[off + 4] == 'O' && buf[off + 5] == 'N' && buf[off + 6] == 'S') ? OPTIONS : null; + case 'H' -> (len == 4 && buf[off + 1] == 'E' && buf[off + 2] == 'A' && buf[off + 3] == 'D') ? HEAD : null; + case 'T' -> (len == 5 && buf[off + 1] == 'R' && buf[off + 2] == 'A' && buf[off + 3] == 'C' && buf[off + 4] == 'E') ? TRACE : null; + case 'C' -> (len == 7 && buf[off + 1] == 'O' && buf[off + 2] == 'N' && buf[off + 3] == 'N' && buf[off + 4] == 'E' && buf[off + 5] == 'C' && buf[off + 6] == 'T') ? CONNECT : null; + default -> null; + }; + } + + //Only to be used for debugging/logging purposes, never in the hot path. + @Override + public String toString() { + return new String(bytes, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/dev/relism/http/HttpStatus.java b/src/main/java/dev/relism/http/HttpStatus.java new file mode 100644 index 0000000..5e6e8e9 --- /dev/null +++ b/src/main/java/dev/relism/http/HttpStatus.java @@ -0,0 +1,71 @@ +package dev.relism.http; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Pre-compiled byte representations of standard HTTP status lines (e.g. {@code "200 OK"}). + * {@link #bytesForCode(int)} returns the cached array for known codes, {@code null} otherwise. + */ +public enum HttpStatus { + + // 1xx + CONTINUE (100, "Continue"), + SWITCHING_PROTOCOLS (101, "Switching Protocols"), + + // 2xx + OK (200, "OK"), + CREATED (201, "Created"), + ACCEPTED (202, "Accepted"), + NO_CONTENT (204, "No Content"), + PARTIAL_CONTENT (206, "Partial Content"), + + // 3xx + MOVED_PERMANENTLY (301, "Moved Permanently"), + FOUND (302, "Found"), + NOT_MODIFIED (304, "Not Modified"), + TEMPORARY_REDIRECT (307, "Temporary Redirect"), + PERMANENT_REDIRECT (308, "Permanent Redirect"), + + // 4xx + BAD_REQUEST (400, "Bad Request"), + UNAUTHORIZED (401, "Unauthorized"), + FORBIDDEN (403, "Forbidden"), + NOT_FOUND (404, "Not Found"), + METHOD_NOT_ALLOWED (405, "Method Not Allowed"), + NOT_ACCEPTABLE (406, "Not Acceptable"), + CONFLICT (409, "Conflict"), + GONE (410, "Gone"), + LENGTH_REQUIRED (411, "Length Required"), + PAYLOAD_TOO_LARGE (413, "Payload Too Large"), + URI_TOO_LONG (414, "URI Too Long"), + UNSUPPORTED_MEDIA_TYPE (415, "Unsupported Media Type"), + UNPROCESSABLE_ENTITY (422, "Unprocessable Entity"), + TOO_MANY_REQUESTS (429, "Too Many Requests"), + + // 5xx + INTERNAL_SERVER_ERROR (500, "Internal Server Error"), + NOT_IMPLEMENTED (501, "Not Implemented"), + BAD_GATEWAY (502, "Bad Gateway"), + SERVICE_UNAVAILABLE (503, "Service Unavailable"), + GATEWAY_TIMEOUT (504, "Gateway Timeout"); + + private final int code; + private final byte[] bytes; + + private static final Map INDEX = new HashMap<>(); + static { + for (HttpStatus s : values()) INDEX.put(s.code, s.bytes); + } + + HttpStatus(int code, String reason) { + this.code = code; + this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8); + } + + /** Returns pre-compiled {@code "CODE Reason"} bytes for the given code, or {@code null} if unknown. */ + public static byte[] bytesForCode(int code) { + return INDEX.get(code); + } +} diff --git a/src/main/java/dev/relism/models/HeaderMap.java b/src/main/java/dev/relism/models/HeaderMap.java new file mode 100644 index 0000000..7454401 --- /dev/null +++ b/src/main/java/dev/relism/models/HeaderMap.java @@ -0,0 +1,96 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Zero-allocation header storage backed by raw byte offsets into the shared request buffer. + * At parse time, only int offsets are recorded — zero objects allocated per header. + * String conversion is lazy: happens only when getFirst() or getAll() are called. + */ +public class HeaderMap { + private static final int MAX_HEADERS = 32; + + private final byte[] buffer; + private final int[] keys = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs + private final int[] values = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs + private int count = 0; + + public HeaderMap(byte[] buffer) { + this.buffer = buffer; + } + + /** Called at parse time. Stores raw offsets — zero allocations. */ + public void add(int keyStart, int keyLen, int valStart, int valLen) { + if (count >= MAX_HEADERS) throw new IllegalStateException("Too many headers: limit is " + MAX_HEADERS); + keys[count * 2] = keyStart; + keys[count * 2 + 1] = keyLen; + values[count * 2] = valStart; + values[count * 2 + 1] = valLen; + count++; + } + + /** Lazy: decodes and returns the first matching value as a String. */ + public String getFirst(String name) { + int idx = indexOf(name); + if (idx < 0) return null; + return new String(buffer, values[idx * 2], values[idx * 2 + 1], StandardCharsets.UTF_8); + } + + /** Lazy: decodes and returns all matching values as a List. */ + public List getAll(String name) { + List result = null; + for (int i = 0; i < count; i++) { + if (keyMatches(i, name)) { + if (result == null) result = new ArrayList<>(); + result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8)); + } + } + return result != null ? result : List.of(); + } + + /** Lazy: decodes and returns all values as a List. */ + public List getAll() { + List result = new ArrayList<>(); + for (int i = 0; i < count; i++) { + result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8)); + } + return result; + } + + /** Zero-copy: returns a ByteView over the raw value bytes without allocating a String. */ + public ByteView getView(String name) { + int idx = indexOf(name); + if (idx < 0) return null; + final int start = values[idx * 2]; + final int len = values[idx * 2 + 1]; + return new ByteView() { + public int length() { return len; } + public byte byteAt(int i) { return buffer[start + i]; } + }; + } + + private int indexOf(String name) { + for (int i = 0; i < count; i++) { + if (keyMatches(i, name)) return i; + } + return -1; + } + + /** Case-insensitive comparison between a buffer slice and a String. Zero allocations. */ + private boolean keyMatches(int i, String name) { + int ks = keys[i * 2], kl = keys[i * 2 + 1]; + if (kl != name.length()) return false; + for (int j = 0; j < kl; j++) { + byte b = buffer[ks + j]; + if (b >= 'A' && b <= 'Z') b += 32; + char c = name.charAt(j); + if (c >= 'A' && c <= 'Z') c += 32; + if (b != (byte) c) return false; + } + return true; + } +} diff --git a/src/main/java/dev/relism/models/LazyBody.java b/src/main/java/dev/relism/models/LazyBody.java new file mode 100644 index 0000000..8b418e5 --- /dev/null +++ b/src/main/java/dev/relism/models/LazyBody.java @@ -0,0 +1,63 @@ +package dev.relism.models; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; + +/** + * Deferred request body reader. + * + *

On the first call to {@link #get()}, the body is materialized: any bytes already + * buffered from the 8 KB header read-ahead are copied first, then the remainder is pulled + * from the socket stream. The result is cached so subsequent calls are free. + * + *

When {@code contentLength} is zero the instance is pre-resolved to an empty array + * and no stream access ever occurs. + */ +final class LazyBody { + private static final byte[] EMPTY = new byte[0]; + + private final InputStream stream; + private final int contentLength; + private final byte[] preBuf; + private final int preBufOff; + private final int preBufLen; + private byte[] resolved; + + LazyBody(InputStream stream, int contentLength, byte[] preBuf, int preBufOff, int preBufLen) { + this.stream = stream; + this.contentLength = contentLength; + this.preBuf = preBuf; + this.preBufOff = preBufOff; + this.preBufLen = preBufLen; + } + + /** Returns a pre-resolved {@code LazyBody} backed by an already-materialized byte array. */ + static LazyBody of(byte[] bytes) { + LazyBody lb = new LazyBody(null, bytes.length, null, 0, 0); + lb.resolved = bytes; + return lb; + } + + /** Returns a pre-resolved empty {@code LazyBody}. */ + static LazyBody empty() { + LazyBody lb = new LazyBody(null, 0, null, 0, 0); + lb.resolved = EMPTY; + return lb; + } + + byte[] get() { + if (resolved != null) return resolved; + byte[] buf = new byte[contentLength]; + int copied = Math.min(preBufLen, contentLength); + if (copied > 0) System.arraycopy(preBuf, preBufOff, buf, 0, copied); + if (copied < contentLength) { + try { + stream.readNBytes(buf, copied, contentLength - copied); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + return resolved = buf; + } +} diff --git a/src/main/java/dev/relism/models/PathParams.java b/src/main/java/dev/relism/models/PathParams.java new file mode 100644 index 0000000..367072c --- /dev/null +++ b/src/main/java/dev/relism/models/PathParams.java @@ -0,0 +1,51 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; + +import java.nio.charset.StandardCharsets; + +/** + * Path parameters captured during routing. + * + *

Values are stored as byte offsets into the original path view. String conversion + * is lazy and happens only on {@link #get}; {@link #view} is zero-copy. + * Lookup is a linear scan — param counts are always small (typically 1–3). + */ +public class PathParams { + private final ByteView source; + private final String[] names; + private final int[] starts; + private final int[] lens; + + public PathParams(ByteView source, String[] names, int[] starts, int[] lens) { + this.source = source; + this.names = names; + this.starts = starts; + this.lens = lens; + } + + /** Lazy — allocates a {@code String} only on call. {@code null} if the param is absent. */ + public String get(String name) { + int i = indexOf(name); + if (i < 0) return null; + byte[] bytes = new byte[lens[i]]; + for (int j = 0; j < lens[i]; j++) bytes[j] = source.byteAt(starts[i] + j); + return new String(bytes, StandardCharsets.UTF_8); + } + + /** Zero-copy — returns a {@link ByteView} slice over the raw path bytes. */ + ByteView view(String name) { + int i = indexOf(name); + if (i < 0) return null; + final int s = starts[i], l = lens[i]; + return new ByteView() { + public int length() { return l; } + public byte byteAt(int idx) { return source.byteAt(s + idx); } + }; + } + + private int indexOf(String name) { + for (int i = 0; i < names.length; i++) if (names[i].equals(name)) return i; + return -1; + } +} diff --git a/src/main/java/dev/relism/models/QueryParams.java b/src/main/java/dev/relism/models/QueryParams.java new file mode 100644 index 0000000..2170375 --- /dev/null +++ b/src/main/java/dev/relism/models/QueryParams.java @@ -0,0 +1,100 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Lazy access to URL query parameters ({@code ?key=value&...}). + * Backed by a zero-copy {@link ByteView} over the raw query string bytes from the request buffer. + * No parsing happens at construction — values are decoded on demand. + * + *

{@link #view} is zero-copy; {@link #get} and {@link #getAll} allocate only the result String(s). + */ +public class QueryParams { + /** Singleton returned when the request has no query string. All methods return empty/null. */ + public static final QueryParams EMPTY = new QueryParams(null); + + private final ByteView raw; + + public QueryParams(ByteView raw) { + this.raw = raw; + } + + /** Lazy — decodes the first value for {@code name}, or {@code null} if absent. */ + public String get(String name) { + long r = findFirst(name); + if (r < 0) return null; + int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL); + byte[] bytes = new byte[l]; + for (int i = 0; i < l; i++) bytes[i] = raw.byteAt(s + i); + return new String(bytes, StandardCharsets.UTF_8); + } + + /** Zero-copy — returns a {@link ByteView} slice over the raw value bytes. */ + ByteView view(String name) { + long r = findFirst(name); + if (r < 0) return null; + final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL); + return new ByteView() { + public int length() { return l; } + public byte byteAt(int idx) { return raw.byteAt(s + idx); } + }; + } + + /** Lazy — returns all values for {@code name}, or an empty list if absent. */ + public List getAll(String name) { + if (raw == null) return List.of(); + List result = null; + int i = 0, len = raw.length(); + while (i < len) { + int keyStart = i; + while (i < len && raw.byteAt(i) != '=' && raw.byteAt(i) != '&') i++; + int keyLen = i - keyStart; + if (i < len && raw.byteAt(i) == '=') { + i++; + int valStart = i; + while (i < len && raw.byteAt(i) != '&') i++; + if (keyMatches(keyStart, keyLen, name)) { + int valLen = i - valStart; + byte[] bytes = new byte[valLen]; + for (int j = 0; j < valLen; j++) bytes[j] = raw.byteAt(valStart + j); + if (result == null) result = new ArrayList<>(); + result.add(new String(bytes, StandardCharsets.UTF_8)); + } + } + if (i < len && raw.byteAt(i) == '&') i++; + } + return result != null ? result : List.of(); + } + + /** + * Scans for the first occurrence of {@code name=value}. + * Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. Zero allocations. + */ + private long findFirst(String name) { + if (raw == null) return -1L; + int i = 0, len = raw.length(); + while (i < len) { + int keyStart = i; + while (i < len && raw.byteAt(i) != '=' && raw.byteAt(i) != '&') i++; + int keyLen = i - keyStart; + if (i < len && raw.byteAt(i) == '=') { + i++; + int valStart = i; + while (i < len && raw.byteAt(i) != '&') i++; + if (keyMatches(keyStart, keyLen, name)) return ((long) valStart << 32) | (i - valStart); + } + if (i < len && raw.byteAt(i) == '&') i++; + } + return -1L; + } + + private boolean keyMatches(int start, int len, String name) { + if (len != name.length()) return false; + for (int i = 0; i < len; i++) if (raw.byteAt(start + i) != (byte) name.charAt(i)) return false; + return true; + } +} diff --git a/src/main/java/dev/relism/models/Request.java b/src/main/java/dev/relism/models/Request.java new file mode 100644 index 0000000..9f00525 --- /dev/null +++ b/src/main/java/dev/relism/models/Request.java @@ -0,0 +1,89 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.Value; +import lombok.experimental.NonFinal; + +import java.io.InputStream; +import java.util.List; + +@Value +@ToString +public class Request { + RequestLine requestLine; + + @Getter(lombok.AccessLevel.NONE) + @EqualsAndHashCode.Exclude + @ToString.Exclude + LazyBody lazyBody; + + @NonFinal @Setter @Getter PathParams pathParams; + @NonFinal @Setter @Getter QueryParams queryParams; + + private Request(RequestLine requestLine, LazyBody lazyBody) { + this.requestLine = requestLine; + this.lazyBody = lazyBody; + this.pathParams = null; + this.queryParams = null; + } + + /** Convenience constructor for test mocks and pre-materialized bodies. */ + public Request(RequestLine requestLine, byte[] body) { + this(requestLine, LazyBody.of(body)); + } + + /** + * Factory used by {@link dev.relism.RequestParser}: creates a request whose body is read + * from {@code stream} on the first call to {@link #getBody()}. + * + * @param preBufLen bytes already buffered past the header end (from the 8 KB read-ahead) + */ + public static Request forParsed(RequestLine requestLine, InputStream stream, + int contentLength, byte[] headerBuf, + int bodyStart, int preBufLen) { + LazyBody lazy = contentLength > 0 + ? new LazyBody(stream, contentLength, headerBuf, bodyStart, preBufLen) + : LazyBody.empty(); + return new Request(requestLine, lazy); + } + + /** Materializes and returns the request body, reading from the socket if not yet done. */ + public byte[] getBody() { return lazyBody.get(); } + + // --- Header access (lazy: String allocated only on call) --- + + public String getHeader(String name) { return requestLine.getHeaders().getFirst(name); } + public List getHeaders(String name) { return requestLine.getHeaders().getAll(name); } + public List getHeaders() { return requestLine.getHeaders().getAll(); } + + // --- Path param access --- + + /** Lazy: decodes the named path parameter to a {@code String}. */ + public String getPathParam(String name) { + return pathParams != null ? pathParams.get(name) : null; + } + + // --- Query param access --- + + /** Lazy: decodes the first value of {@code name}, or {@code null} if absent. */ + public String getQueryParam(String name) { + return resolveQueryParams().get(name); + } + + /** Lazy: decodes all values of {@code name} (e.g. {@code ?tag=a&tag=b}). */ + public List getQueryParams(String name) { + return resolveQueryParams().getAll(name); + } + + private QueryParams resolveQueryParams() { + if (queryParams == null) { + ByteView raw = requestLine.getQuery(); + queryParams = raw != null ? new QueryParams(raw) : QueryParams.EMPTY; + } + return queryParams; + } +} diff --git a/src/main/java/dev/relism/models/RequestHandler.java b/src/main/java/dev/relism/models/RequestHandler.java new file mode 100644 index 0000000..f4f546e --- /dev/null +++ b/src/main/java/dev/relism/models/RequestHandler.java @@ -0,0 +1,17 @@ +package dev.relism.models; + +/** + * Base class for class-based route handlers. + * + *

Annotate the subclass with {@link dev.relism.routing.Route @Route} and register it + * via {@link dev.relism.routing.AbstractRouter#register}. For one-off routes, prefer the + * lambda DSL ({@code server.get(path, handler)}) which wraps a {@link SimpleHandler} internally. + */ +public abstract class RequestHandler { + /** + * Handles an incoming request. The return value determines the response body: + * return a {@link Response} to replace the whole response, any other non-null value + * to set it as the body, or {@code null} to leave the response as-is. + */ + public abstract Object handle(Request request, Response response); +} \ No newline at end of file diff --git a/src/main/java/dev/relism/models/RequestLine.java b/src/main/java/dev/relism/models/RequestLine.java new file mode 100644 index 0000000..93cf4f9 --- /dev/null +++ b/src/main/java/dev/relism/models/RequestLine.java @@ -0,0 +1,17 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import dev.relism.http.HttpMethod; +import lombok.ToString; +import lombok.Value; + +@ToString +@Value +public class RequestLine { + HttpMethod method; + ByteView path; + /** Raw query string bytes (after {@code ?}), {@code null} if the URI has no query string. */ + ByteView query; + ByteView protocol; + HeaderMap headers; +} diff --git a/src/main/java/dev/relism/models/Response.java b/src/main/java/dev/relism/models/Response.java new file mode 100644 index 0000000..c121104 --- /dev/null +++ b/src/main/java/dev/relism/models/Response.java @@ -0,0 +1,45 @@ +package dev.relism.models; + +import dev.relism.http.ContentType; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.nio.charset.StandardCharsets; + +@Getter +@ToString +public class Response { + @Setter private int statusCode; + private byte[] body; + private byte[] contentType; + + public Response(int statusCode, byte[] body, ContentType contentType) { + this.statusCode = statusCode; + this.body = body; + this.contentType = contentType.getBytes(); + } + + public Response(int statusCode, String text, ContentType contentType) { + this.statusCode = statusCode; + this.body = text.getBytes(StandardCharsets.UTF_8); + this.contentType = contentType.getBytes(); + } + + public void setContentType(ContentType contentType) { + this.contentType = contentType.getBytes(); + } + + public void setContentType(String contentType) { + this.contentType = contentType.getBytes(StandardCharsets.UTF_8); + } + + public Response setBody(Object body) { + if (body instanceof byte[] bytes) { + this.body = bytes; + } else if (body != null) { + this.body = body.toString().getBytes(StandardCharsets.UTF_8); + } + return this; + } +} diff --git a/src/main/java/dev/relism/models/SimpleHandler.java b/src/main/java/dev/relism/models/SimpleHandler.java new file mode 100644 index 0000000..49e0103 --- /dev/null +++ b/src/main/java/dev/relism/models/SimpleHandler.java @@ -0,0 +1,26 @@ +package dev.relism.models; + +import lombok.RequiredArgsConstructor; + +/** + * A concrete implementation of {@link RequestHandler} that delegates to a functional interface. + * This allows the use of lambdas while keeping the base {@link RequestHandler} as an abstract class. + */ +@RequiredArgsConstructor +public class SimpleHandler extends RequestHandler { + + private final FunctionalHandler delegate; + + @Override + public Object handle(Request request, Response response) { + return delegate.handle(request, response); + } + + /** + * Functional interface for handling requests, used by {@link SimpleHandler}. + */ + @FunctionalInterface + public interface FunctionalHandler { + Object handle(Request request, Response response); + } +} diff --git a/src/main/java/dev/relism/routing/AbstractRouter.java b/src/main/java/dev/relism/routing/AbstractRouter.java new file mode 100644 index 0000000..abf5245 --- /dev/null +++ b/src/main/java/dev/relism/routing/AbstractRouter.java @@ -0,0 +1,133 @@ +package dev.relism.routing; + +import dev.relism.fpr.core.ByteView; +import dev.relism.http.ContentType; +import dev.relism.http.HttpMethod; +import dev.relism.models.*; +import dev.relism.template.ErrorPages; + +import lombok.AccessLevel; +import lombok.Getter; + +import java.nio.charset.StandardCharsets; + +/** + * Base class for all routers, used both for the server's internal router and for + * mounted sub-routers. + * + *

Each router has a namespace (e.g. {@code /api}). Routes added via + * {@link #get}, {@link #post}, etc. are relative to the namespace; the + * implementation prepends it when registering. The namespace is {@code "/"} by default + * and is set automatically by {@link GlobalRouter#mount}. + * + *

Error handlers ({@link #onNotFound}, {@link #onException}) are scoped to this router. + * When a sub-router is mounted on the server, its handlers take precedence over the + * server-level ones for all paths under its namespace. + */ +public abstract class AbstractRouter { + + @Getter + protected String namespace = "/"; + + @Getter(AccessLevel.PACKAGE) + protected byte[] namespaceBytes = new byte[]{ '/' }; + + // --- Default handlers --- + + protected SimpleHandler notFoundHandler = new SimpleHandler((req, res) -> { + res.setStatusCode(404); + res.setContentType(ContentType.TEXT_HTML); + return ErrorPages.renderNotFound(req); + }); + + protected ExceptionHandler exceptionHandler = (ex, req, res) -> { + res.setStatusCode(500); + res.setContentType(ContentType.TEXT_HTML); + return ErrorPages.renderException(req, ex); + }; + + // Package-private — used by GlobalRouter only + SimpleHandler getNotFoundHandler() { return notFoundHandler; } + ExceptionHandler getExceptionHandler() { return exceptionHandler; } + void setNamespace(String namespace) { + this.namespace = namespace; + this.namespaceBytes = namespace.getBytes(StandardCharsets.UTF_8); + } + + // --- Public API --- + + /** Overrides the default 404 response for unmatched paths under this router's namespace. */ + public AbstractRouter onNotFound(SimpleHandler.FunctionalHandler handler) { + this.notFoundHandler = new SimpleHandler(handler); + return this; + } + + /** Overrides the default 500 response for uncaught exceptions thrown by handlers under this router. */ + public AbstractRouter onException(ExceptionHandler handler) { + this.exceptionHandler = handler; + return this; + } + + /** + * Registers a route relative to this router's namespace. + * + *

The handler's return value drives the response: + *

    + *
  • Return a {@link Response} to replace the entire response object.
  • + *
  • Return any other non-null value to use it as the body (via {@code toString()} or raw bytes).
  • + *
  • Return {@code null} to leave the response unchanged from what was set on the {@code Response} parameter.
  • + *
+ */ + public AbstractRouter get(String path, SimpleHandler.FunctionalHandler handler) { + return addRoute(HttpMethod.GET, PathUtils.sanitize(path), new SimpleHandler(handler)); + } + + /** @see #get(String, SimpleHandler.FunctionalHandler) */ + public AbstractRouter post(String path, SimpleHandler.FunctionalHandler handler) { + return addRoute(HttpMethod.POST, PathUtils.sanitize(path), new SimpleHandler(handler)); + } + + /** @see #get(String, SimpleHandler.FunctionalHandler) */ + public AbstractRouter put(String path, SimpleHandler.FunctionalHandler handler) { + return addRoute(HttpMethod.PUT, PathUtils.sanitize(path), new SimpleHandler(handler)); + } + + /** @see #get(String, SimpleHandler.FunctionalHandler) */ + public AbstractRouter delete(String path, SimpleHandler.FunctionalHandler handler) { + return addRoute(HttpMethod.DELETE, PathUtils.sanitize(path), new SimpleHandler(handler)); + } + + /** + * Registers a class-based handler. The handler's class must be annotated with + * {@link Route @Route} declaring the HTTP method and path (relative to this router's namespace). + * If the annotation is absent, the call is silently ignored. + */ + public AbstractRouter register(RequestHandler handler) { + Route annotation = handler.getClass().getAnnotation(Route.class); + if (annotation != null) { + addRoute(HttpMethod.valueOf(annotation.method()), annotation.path(), handler); + } + return this; + } + + // --- For implementors --- + + public abstract RequestHandler route(Request request); + + protected abstract AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler); + + /** + * Sets the path parameters captured during routing. + * Implementations must call this whenever the matched route contains path parameters. + * Centralised here so all router implementations participate in the same contract + * and produce a consistent {@link PathParams} regardless of the matching strategy. + */ + protected static void setPathParams(Request request, String[] names, ByteView source, int[] starts, int[] lens) { + request.setPathParams(new PathParams(source, names, starts, lens)); + } + + @FunctionalInterface + public interface ExceptionHandler { + Object handle(Exception exception, Request request, Response response); + } +} diff --git a/src/main/java/dev/relism/routing/GlobalRouter.java b/src/main/java/dev/relism/routing/GlobalRouter.java new file mode 100644 index 0000000..c06bb07 --- /dev/null +++ b/src/main/java/dev/relism/routing/GlobalRouter.java @@ -0,0 +1,77 @@ +package dev.relism.routing; + +import dev.relism.exceptions.DuplicateNamespaceException; +import dev.relism.fpr.core.ByteView; +import dev.relism.http.HttpMethod; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Top-level dispatcher owned by {@link dev.relism.HttpServer}. + * + *

Routing order on each request: + *

    + *
  1. Iterates mounted sub-routers in descending namespace length order (longest prefix first) + * and delegates to the first one whose namespace is a prefix of the request path.
  2. + *
  3. If no sub-router matches, falls through to the internal {@link FastPathRouterImpl}.
  4. + *
+ * + *

Not intended to be instantiated or subclassed directly — use {@link dev.relism.HttpServer}. + */ +public class GlobalRouter extends AbstractRouter { + private final Map subRoutersMap = new HashMap<>(); + private final List sortedSubRouters = new ArrayList<>(); + private final AbstractRouter internalRouter = new FastPathRouterImpl(); + + public void mount(String namespace, AbstractRouter router) { + String sanitized = PathUtils.sanitize(namespace); + if (subRoutersMap.containsKey(sanitized)) throw new DuplicateNamespaceException(sanitized); + + router.setNamespace(sanitized); + subRoutersMap.put(sanitized, router); + sortedSubRouters.add(router); + sortedSubRouters.sort(Comparator.comparingInt((AbstractRouter r) -> r.getNamespaceBytes().length).reversed()); + } + + @Override + public RequestHandler route(Request request) { + ByteView path = request.getRequestLine().getPath(); + for (AbstractRouter sub : sortedSubRouters) { + if (startsWith(path, sub.getNamespaceBytes())) { + RequestHandler h = sub.route(request); + return h != null ? h : sub.getNotFoundHandler(); + } + } + RequestHandler h = internalRouter.route(request); + return h != null ? h : notFoundHandler; + } + + /** Resolves the scoped exception handler for the router that owns the request path. */ + public ExceptionHandler resolveExceptionHandler(Request request) { + ByteView path = request.getRequestLine().getPath(); + for (AbstractRouter sub : sortedSubRouters) { + if (startsWith(path, sub.getNamespaceBytes())) return sub.getExceptionHandler(); + } + return exceptionHandler; + } + + @Override + protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { + return internalRouter.addRoute(method, PathUtils.sanitize(path), handler); + } + + private static boolean startsWith(ByteView view, byte[] prefix) { + if (view.length() < prefix.length) return false; + for (int i = 0; i < prefix.length; i++) { + if (view.byteAt(i) != prefix[i]) return false; + } + return true; + } +} diff --git a/src/main/java/dev/relism/routing/PathUtils.java b/src/main/java/dev/relism/routing/PathUtils.java new file mode 100644 index 0000000..7968c02 --- /dev/null +++ b/src/main/java/dev/relism/routing/PathUtils.java @@ -0,0 +1,47 @@ +package dev.relism.routing; + +public class PathUtils { + /** + * Sanitizes a path segment to ensure it starts with '/' and has no trailing slash. + * Replaces multiple slashes with a single one. + */ + public static String sanitize(String path) { + if (path == null || path.isBlank() || path.trim().equals("/")) { + return "/"; + } + + String sanitized = path.trim().replaceAll("/{2,}", "/"); + + if (!sanitized.startsWith("/")) { + sanitized = "/" + sanitized; + } + + if (sanitized.length() > 1 && sanitized.endsWith("/")) { + sanitized = sanitized.substring(0, sanitized.length() - 1); + } + + return sanitized; + } + + /** + * Joins two path segments and ensures the result is sanitized. + * Prevents "double namespace" if the path already starts with the base. + */ + public static String join(String base, String path) { + String sBase = sanitize(base); + String sPath = sanitize(path); + + if (sBase.equals("/")) return sPath; + if (sPath.equals("/") || sPath.isEmpty()) return sBase; + + // If sPath already starts with sBase, don't prepend it again + // Example: base="/api", path="/api/users" -> "/api/users" + if (sPath.startsWith(sBase)) { + return sPath; + } + + // Otherwise, prepend base to path + String joined = sBase + (sPath.startsWith("/") ? sPath : "/" + sPath); + return sanitize(joined); + } +} diff --git a/src/main/java/dev/relism/routing/Route.java b/src/main/java/dev/relism/routing/Route.java new file mode 100644 index 0000000..ba34b82 --- /dev/null +++ b/src/main/java/dev/relism/routing/Route.java @@ -0,0 +1,22 @@ +package dev.relism.routing; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares the HTTP method and path for a class-based {@link dev.relism.models.RequestHandler}. + * + *

The path is relative to the namespace of the router the handler is registered on. + * For example, registering a handler with {@code path = "/profile"} on a router mounted + * at {@code /api} results in the effective route {@code /api/profile}. + * + *

Used by {@link dev.relism.routing.AbstractRouter#register}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Route { + String method() default "GET"; + String path(); +} diff --git a/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java b/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java new file mode 100644 index 0000000..14a92ea --- /dev/null +++ b/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java @@ -0,0 +1,114 @@ +package dev.relism.routing.routers.fastpathrouter; + +import dev.relism.fpr.core.ByteView; +import dev.relism.fpr.core.FastPathRouter; +import dev.relism.fpr.core.MatchResult; +import dev.relism.fpr.core.RouterBuilder; +import dev.relism.fpr.core.dsl.StringRouteParser; +import dev.relism.http.HttpMethod; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.routing.AbstractRouter; +import dev.relism.routing.PathUtils; + +/** + * Router implementation backed by the {@code fpr-core} byte-level state machine. + * + *

Routes are compiled lazily: the internal {@code FastPathRouter} is built on the first + * incoming request after one or more routes have been added. Adding a route after the server + * has started is safe — the router is marked dirty and recompiled on the next request. + * + *

Matching is done on a virtual {@code METHOD + path} byte sequence to avoid a two-step + * lookup. Captured path parameters are exposed via {@link dev.relism.models.PathParams} on + * the request object, accessible through {@link dev.relism.models.Request#getPathParams()}. + * + *

Thread safety: {@code FastPathRouter.match()} is safe for concurrent calls as of + * {@code fpr-core} 1.1.0 — traversal state ({@code RouteSearch}, {@code SegmentCursor}) is + * held in method-local variables, making the compiled router instance fully immutable. + */ +public class FastPathRouterImpl extends AbstractRouter { + private final RouterBuilder builder = new RouterBuilder<>(); + private volatile FastPathRouter router; + private String[] cachedParamNames; + + /** + * Thread-local holders for per-request reusable objects. + * Both {@link MatchResult} and {@link FastPathViews.MethodPathByteView} are reset before use, + * so no allocation occurs on the routing hot path. + */ + private static final class FastPathRouterContext { + private static final ThreadLocal> RESULT_HOLDER = + ThreadLocal.withInitial(() -> new MatchResult<>(32, 128)); + private static final ThreadLocal COMBINED_VIEW_HOLDER = + ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new); + + public static MatchResult getResult() { + return RESULT_HOLDER.get(); + } + + public static FastPathViews.MethodPathByteView getCombinedView() { + return COMBINED_VIEW_HOLDER.get(); + } + } + + @Override + protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { + String fullPath = PathUtils.join(namespace, path); + builder.add(StringRouteParser.parse(method.name() + fullPath), handler); + this.router = null; // Mark dirty + return this; + } + + @Override + public RequestHandler route(Request request) { + ensureCompiled(); + + MatchResult result = FastPathRouterContext.getResult(); + result.reset(); + + HttpMethod method = request.getRequestLine().getMethod(); + ByteView pathView = request.getRequestLine().getPath(); + FastPathViews.MethodPathByteView combinedView = FastPathRouterContext.getCombinedView(); + combinedView.reset(method.getBytes(), pathView); + + int labelId = router.match(combinedView, result); + + if (labelId == FastPathRouter.NO_MATCH) { + return null; + } + + // CAPTURE PARAMETERS + int count = result.paramCount(); + if (count > 0) { + int methodLen = method.getBytes().length; + String[] all = cachedParamNames; + String[] names = new String[count]; + int[] starts = new int[count]; + int[] lens = new int[count]; + + for (int i = 0; i < count; i++) { + names[i] = all[result.keyIdAt(i)]; + starts[i] = result.startAt(i) - methodLen; + lens[i] = result.lenAt(i); + } + setPathParams(request, names, pathView, starts, lens); + } + + return result.handler(); + } + + /** + * Ensures the FPR state machine is compiled. Uses double-checked locking on a {@code volatile} + * field so the fast path (already compiled) is a single null-check with no synchronization. + */ + private void ensureCompiled() { + if (router == null) { + synchronized (this) { + if (router == null) { + cachedParamNames = builder.paramNames(); // written before volatile router + router = builder.compile(); // volatile write: establishes happens-before + } + } + } + } +} diff --git a/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java b/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java new file mode 100644 index 0000000..ebedd6b --- /dev/null +++ b/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java @@ -0,0 +1,120 @@ +package dev.relism.routing.routers.fastpathrouter; + +import dev.relism.fpr.core.ByteView; +import lombok.NoArgsConstructor; + +import java.nio.charset.StandardCharsets; + +/** + * Container class for various {@link ByteView} implementations used by the FastPathRouter. + * Consolidating these views reduces package clutter while maintaining high-performance byte access. + */ +@NoArgsConstructor +public final class FastPathViews { + + /** + * High-performance, zero-copy ByteView that points to a shared request buffer. + * Used by the {@link dev.relism.RequestParser} to scan for paths without allocations. + */ + public static final class RequestByteView implements ByteView { + private final byte[] buffer; + private final int start; + private final int length; + + public RequestByteView(byte[] buffer, int start, int length) { + this.buffer = buffer; + this.start = start; + this.length = length; + } + + @Override + public int length() { + return length; + } + + @Override + public byte byteAt(int index) { + if (index < 0 || index >= length) { + throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + length); + } + return buffer[start + index]; + } + + @Override + public String toString() { // CHANGED: decode slice directly; avoids full-buffer String copy + return new String(buffer, start, length, StandardCharsets.UTF_8); + } + } + + /** + * A composite ByteView that prefixes a method's bytes to a path view. + * Enables method+path routing in a single pass with no allocation. + * + *

Mutable by design — reused across requests via {@code ThreadLocal}. + * Call {@link #reset} before each use. + */ + public static final class MethodPathByteView implements ByteView { + private byte[] method; + private ByteView path; + private int totalLength; + + /** Binds this view to a new method+path pair. Must be called before each use. */ + public void reset(byte[] method, ByteView path) { + this.method = method; + this.path = path; + this.totalLength = method.length + path.length(); + } + + @Override + public int length() { + return totalLength; + } + + @Override + public byte byteAt(int index) { + return index < method.length ? method[index] : path.byteAt(index - method.length); + } + } + + /** + * ByteView implementation for raw byte arrays, typically from a socket. + */ + public static class SocketByteView implements ByteView { + private final byte[] data; + + public SocketByteView(byte[] data) { + this.data = data; + } + + @Override + public int length() { + return data.length; + } + + @Override + public byte byteAt(int index) { + return data[index]; + } + } + + /** + * ByteView implementation for Java Strings. + */ + public static class StringByteView implements ByteView { + private final byte[] bytes; + + public StringByteView(String str) { + this.bytes = str.getBytes(StandardCharsets.UTF_8); + } + + @Override + public int length() { + return bytes.length; + } + + @Override + public byte byteAt(int index) { + return bytes[index]; + } + } +} diff --git a/src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java b/src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java new file mode 100644 index 0000000..da29a87 --- /dev/null +++ b/src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java @@ -0,0 +1,5 @@ +package dev.relism.routing.routers.radix; + +public class RadixPathRouterImpl { + +} diff --git a/src/main/java/dev/relism/template/ByteTemplate.java b/src/main/java/dev/relism/template/ByteTemplate.java new file mode 100644 index 0000000..1d77b46 --- /dev/null +++ b/src/main/java/dev/relism/template/ByteTemplate.java @@ -0,0 +1,75 @@ +package dev.relism.template; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Precompiled, allocation-minimal byte template. + *

+ * Placeholders of the form {@code {{name}}} are detected once at construction. + * Each {@link #render} call makes exactly one allocation: the output byte[]. + *

+ * Layout: seg[0] slot[0] seg[1] slot[1] … seg[n-1] slot[n-1] seg[n] + */ +public final class ByteTemplate { + + private final byte[][] segments; // literal byte segments + private final String[] slots; // placeholder names in order + private final int staticLength; // sum of all segment lengths (precomputed) + + public ByteTemplate(String source) { + List segs = new ArrayList<>(); + List slts = new ArrayList<>(); + int start = 0, i = 0; + while (i < source.length()) { + if (source.charAt(i) == '{' && i + 1 < source.length() && source.charAt(i + 1) == '{') { + int end = source.indexOf("}}", i + 2); + if (end < 0) break; + segs.add(source.substring(start, i).getBytes(StandardCharsets.UTF_8)); + slts.add(source.substring(i + 2, end)); + start = end + 2; + i = end + 2; + } else { + i++; + } + } + segs.add(source.substring(start).getBytes(StandardCharsets.UTF_8)); + segments = segs.toArray(new byte[0][]); + slots = slts.toArray(new String[0]); + int sl = 0; + for (byte[] s : segments) sl += s.length; + staticLength = sl; + } + + /** + * Render with alternating key-value String pairs: {@code k1, v1, k2, v2, …} + * Unmatched slots are rendered as empty. + */ + public byte[] render(String... kvPairs) { + byte[][] values = new byte[slots.length][]; + for (int i = 0; i + 1 < kvPairs.length; i += 2) { + String key = kvPairs[i]; + byte[] val = kvPairs[i + 1].getBytes(StandardCharsets.UTF_8); + for (int j = 0; j < slots.length; j++) { + if (slots[j].equals(key)) { values[j] = val; } + } + } + + int len = staticLength; + for (byte[] v : values) if (v != null) len += v.length; + + byte[] out = new byte[len]; + int pos = 0; + for (int i = 0; i < slots.length; i++) { + System.arraycopy(segments[i], 0, out, pos, segments[i].length); + pos += segments[i].length; + if (values[i] != null) { + System.arraycopy(values[i], 0, out, pos, values[i].length); + pos += values[i].length; + } + } + System.arraycopy(segments[slots.length], 0, out, pos, segments[slots.length].length); + return out; + } +} diff --git a/src/main/java/dev/relism/template/ErrorPages.java b/src/main/java/dev/relism/template/ErrorPages.java new file mode 100644 index 0000000..62c158a --- /dev/null +++ b/src/main/java/dev/relism/template/ErrorPages.java @@ -0,0 +1,87 @@ +package dev.relism.template; + +import dev.relism.Flash; +import dev.relism.models.Request; +import dev.relism.models.RequestLine; +import lombok.NoArgsConstructor; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; + +/** + * Precompiled error page templates. + *

+ * Static placeholders ({@code logo_img}, {@code version}) are baked in once at + * class init. Dynamic placeholders (method, path, exception …) are filled per + * call with a single allocation via {@link ByteTemplate#render}. + */ +@NoArgsConstructor +public final class ErrorPages { + + private static final ByteTemplate TEMPLATE_404; + private static final ByteTemplate TEMPLATE_500; + + static { + String logo = loadLogoImg(); + TEMPLATE_404 = bake("assets/html/default_404.html", logo); + TEMPLATE_500 = bake("assets/html/default_exception.html", logo); + } + + public static byte[] renderNotFound(Request request) { + RequestLine rl = request.getRequestLine(); + return TEMPLATE_404.render( + "method", rl.getMethod().toString(), + "path", rl.getPath().toString(), + "protocol", rl.getProtocol().toString(), + "timestamp", Instant.now().toString() + ); + } + + public static byte[] renderException(Request request, Exception ex) { + RequestLine rl = request.getRequestLine(); + StringWriter sw = new StringWriter(); + ex.printStackTrace(new PrintWriter(sw)); + return TEMPLATE_500.render( + "method", rl.getMethod().toString(), + "path", rl.getPath().toString(), + "protocol", rl.getProtocol().toString(), + "exception_type", ex.getClass().getName(), + "exception_message", ex.getMessage() != null ? ex.getMessage() : "", + "stacktrace", sw.toString(), + "timestamp", Instant.now().toString() + ); + } + + // --- init helpers --- + + private static ByteTemplate bake(String resource, String logoHtml) { + String raw = load(resource) + .replace("{{logo_img}}", logoHtml) + .replace("{{version}}", Flash.VERSION); + return new ByteTemplate(raw); + } + + private static String load(String resource) { + try (InputStream is = ErrorPages.class.getClassLoader().getResourceAsStream(resource)) { + if (is == null) throw new RuntimeException("Missing resource: " + resource); + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Failed to load: " + resource, e); + } + } + + private static String loadLogoImg() { + try (InputStream is = ErrorPages.class.getClassLoader().getResourceAsStream("assets/logo.png")) { + if (is == null) return ""; + String b64 = Base64.getEncoder().encodeToString(is.readAllBytes()); + return "\"Flash\""; + } catch (IOException e) { + return ""; + } + } +} diff --git a/src/main/resources/assets/html/default_404.html b/src/main/resources/assets/html/default_404.html new file mode 100644 index 0000000..ba88da6 --- /dev/null +++ b/src/main/resources/assets/html/default_404.html @@ -0,0 +1,145 @@ + + + + + + + 404 — Flash + + + + +

+
+
404
+
+

{{method}} {{path}}

+

No route matched this request.

+

{{protocol}}

+
+
+ +
+ {{logo_img}} + Flash 5 + · + v{{version}} + · + +
+ + + diff --git a/src/main/resources/assets/html/default_exception.html b/src/main/resources/assets/html/default_exception.html new file mode 100644 index 0000000..2671a2c --- /dev/null +++ b/src/main/resources/assets/html/default_exception.html @@ -0,0 +1,171 @@ + + + + + + + 500 — Flash + + + + +
+
+
500
+
+

{{method}} {{path}}

+

{{exception_type}}: {{exception_message}}

+
{{stacktrace}}
+

{{protocol}}

+
+
+ +
+ {{logo_img}} + Flash 5 + · + v{{version}} + · + +
+ + + diff --git a/src/main/resources/assets/logo.png b/src/main/resources/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f66eadd4e09e9489b188468ab300ff334c510be0 GIT binary patch literal 65114 zcmeFYg;QI9^etMX#U;1~cPY}M!QH(`aVQjrV!<7PyA;>ruEixtX-jd}0L3ME(UZNb%8A@TD+|0q9AV!KkIQY{|O& zcu%*duwgmlRTF4zN336YYzHan?dcVx7QGyDID_N>{;MrTvQSrt(UV`NHMfD@Hi)OG z-ahpnj-MXZj6YU&mY?_&Yt5|b=ox#>W*;jvpD;=$OB9642RbtMlzDjEk;MYl`cK>=6)o*!nF&g ze_CQ0fUEF(c?BbVZ}hS758fv1I8{ifgZZLQLC+gn6ZIiBD#SR9LmHfeit@8B82zD= zYP7V_66Q`NcEcfb*HS5JJO+xZzJ_hosQ8Lm9%D~^&%Ae8fio|2jScR_89Lstl*113 zkm!Kd3+2wqMoG~7E(NWJPEKGsL#p7C@ z1aqph>&chwp@?l8)6XqfD}$<91(0yt>}3&CihKcGOWf;*4>4m@%6vX5R$``4zF?$2 zoY9cp?>cy)G6WE4lMa0or=)|Hh(G8V>4~GNYGcb0JX#NG(C>@fQ~S6$JL57iR+U|F zX4e1NUR7$}7$b;IP6o(v?gv?PiHO$$@IAQ|R3B6DlS82g?}gh-(Z{Uyw83TPB675< zokffUB2(xLB=P-a11kp|57f|5@wJTukL(GzK0yJ2X_s5VKEieH+rT^LCZtN8eTO>3 z)6fD%#??QKjg3UTgvpBA4?+Ru8joH-UI!*tp3ySP^6a*RrIoRLQ8t@b&Pb3P(21Mw4+&(ixea1Db!>c8w5kG--W)LbUBZfzwNqRTAZAe z^poNH>IhwzEm+)79Yf2Dtv}7s`+kS6Xa`7|wxTOUN8@^|)Q$0?CuK{D=k;fr=8e&~ zS1;IJ9Qn+GEKMn{AZ7`B#@mexO~Qv4p4sd7MO(8TK+Hmmt6g*{!-JuRYQjFn2wddV zQdAbcjtf@GD=wuBEG|-f6ELm5Q~ZO0LI)xl%=j)$Uv=OmTMC#Bm$;V*5U_Hy8*F5D z{CyQQQGVHd7JsEw8V<%7*V`F-`>$dzu;P0g8%(McC7s1)t0`8?QI>O`P}UOqaQIw0 zM8@Ee>m?!(dbCD8x|)|zbi4utke6w=+@=ELq>VAK);1nmd?sh9MzptDEDGIcWJMGwrJ&n4 zCo5L#;(Q|#1tMt` zD0`NuNDs#8ED8L;2{SI>)>iZQUUXK%*yL{3wME`K*d! zNc)8s2i0N@Pu8ALkY;<<$C$f^C^--ZN{PYA# zhgAHYW*lE@4trzELU;iXWg*1~Y3ndXGM$D7Ri-AXY3!tUfuzU-Spi83n@MjNIlt{h zZbF{8U4a1NgI%-D?`)BP`ztnCzV1ATJVUQwXcqnwZE+E{zLkKSdi`tEF$mF)1=Wrv z_D&cCq9Xr!^hF+$wA$fjMfNaxeY6mD|M1}A`|#>{A#wXjsGer;wj@^yKLsgGiE)Qn zkxfbQkuhAC&q5ntt8aGDyfRe;6JC*;kY=Nbj{JN_rras@qLYI2ADrkV2D`Qp3Vs=!Q-s;lXF3_dmj~ z%uGm-rFn4&dB0pA&aEuenNW`GaUIKKa+^?e`2V|j)q88z=e0YYend;VAf;Rv7PlJw zu9Rjv5h*3k+<%RcIG*LTo0E(*I^+j>E4N#^ydEwBH`9kqPmRUU_qB0TpWY}55pB^D zW1jF!3fY8M;ZJ1+pa~?!3eyrb@BJx|aNin?;D`HB1h#VUAYp~2X`sItlU zeco{r;t(Kt>t^S8L}+kAFg_kRVJbC}Sat>b<+ zmA|2yzf4*_PjwH+lKwGlb6leGpXgt2^EjBcOA1U%lNFFEWJC|vELG13`CKBh8AVo7 zJy*z$m~jWQSBCY=0fFLpdPqnJM^diw@b@QKX>&Y2$TsvhEDg{veaDfCw=qUlvi{A&*Vy4*hB>%;GhwzVc*UY(?~v3-8O`0;hd$qzeO z{_3>`_zu3WZFI89St0Tj{9)?yRxDj}u>O}1#;x~Ne09*z&IKx2yJ%DMIDR(gu_BqM zdnxg{?aOKS_UPNt@Z2chzf{Lk6JdqMkz!-S6()5uPcVv-VsPJ;jGTfVTw?!}z?D;; zW~|Z%H5@#)pXG>tcOv}QM#YYcR;f3ZP?C<01{vif3z~p4Qe76Q8=?+2;>%izLq@va zJIwzY6c3=6q&ha(Yhk+oBF8j?8xcGCk%Wcwl(Ol_b3wgKro=D{dV5?)H+>R=sZ0ekyLIvT=h25rSS7NvdyRC^#2;Q?(c zrjqby7Cd4;ioEDWa_?Y|er}IbX?m)knQzUoF-AksiE>3>qGR$w!l~E$01qMa-;^Hm4fmi75eaJXj~xCK zBkVkdk6v-5fJcw}zYAD_=v`f1^;Uheb90^~+DS8`3Dlw(gh?|=!N$S&y9A|-3sT}^ zH!?~RkNIz<#WCegxn}|YbrP0Mw1p#`62HEGNn{%2olE`l>lHi;XOI^UmmSOET_;(% z6z&K!OC`vtWNE`)eQB$gbn3dPb0w|8b31a?xd%OTeI~p z@3W2 zR{PB~Yk!57T5->^Z&F@nN|rTYAEtfE5pXhUa$0G7z0&SdK)4XOeIL@b`nr^E=}c8E zKTJ04@71qJM~k9GnW2;=BD%^2prtKMtlDd?aV7cmVy;!rxIn#_YIX}p5l6bil8R7c zdAntZS9W5mto6HAd}Ggyu;8PKTqigqUBeUE-F`{p1R_uNgi2`@iUNfrg=lT@U$#RP z#P0)k!srX``C&2DT%uV!sFcXZC2!9$kl!Z+$9p7+p(Q5>;KwZSQ01XbH{v@t{4A6= z#2sw(N4z=}1T+dKA_vF~hpem4LAZbB45u0E{ zJV%i`#<=CM6VgAnIT~Io1YId`NIWe4;T|}%(^?kC9qNx1=R>CQj(=^(z$J<4MfLpK zWTDBPXais*_GF)q=nZsB=O^6!AarPNJfpG5yX9k8fV~e#RjI61B-J9Lt8J=JC;7qx zdRSZX(DBv>TA0+QG|P|W~tp}gRa6NA=p1R z^yi5%6+`C7GB0>49`SA(CTR{HuC*{XqGU*}G2lmgNDlL%m0HnBXTLRRv>awnN|-Bn zdEC=~g44XfQ!0@9--rtIQ|{Ir#TTzwTleVryY5wLANEpay_vSOGwk3m9qa~o{c~D8 za`GJO3##akxN8XMJ%_rD8^i=GXW`=CAvz>kiKi}sZb9%(fqPAd&Fwkfw{xgN)+ zIWTxbx-_R*SwlAs??=Joj8DU9<5(^slou!3OwA7OsDJd@o_xpPwu$VqR7%HfF{vmQ z4CWcBv>WM|<8CaZND657i8Tp|Z9J;IV$blW6{)|>*?9cBa~62pC3Eh7AT`OW-IW_& zec}V|HkMIKZ^u#+*NyxbWcl4USEW>qNs@N?*f5k*B{@_w8h=s=t=4(`TbQK>sv=sP z!h-a8BSoo|5>b76!&sQ2R9vEn-m3qr5JyY+OFFY{d4cJvFDxfB4 z7&}tnB_{O$BUl|lH#2rqN0AA_A6+9i8`9?#usB~azb+_ zVzRq~YP4TKvB9eGd}N2Ok>o`7Q%k9or=^@5obqpDR75r@{8*B4JI3pJUY;KoWErTB z_>$!azY9@m1rzGUB9)<6WfX6F7|7%*pntEcHhQP`rW`Z0I@%M$D5RGlAiB5Wx_BaU z;c~oGv>8oC?cyFe9h+-cZPu8Ahf3UaJ&5)8s`qx?Zxfh@mTYy3ek?XY|7z(|+KHfm z9y@7}^$crbWG;U+0!DbFMf(!6>qZ)l)UGzo`3t4LqZ}NN0uE?pv@7)IN9m}Ylw_&m zzb`4G5i&*f`w4e<3tNL7(d-^#F&$<+rUCGh+em$aU#e%pIzNowpQ zx^*y(lKzudQD#Es(2e*tA#~!s{fzLd*|}p>p#7^W@?$FHuRfx60&;R6^ovu64A``{wILRSo8nOu;`?bR zv-hgZ)9hh+;S}3lLKml0a43iRqxp;*`b%+^(&Fq)@ds4q((m|q<~BGj7O7xOV@w-g zOJh5yryRGiPsnKpB>~cUT#%sw*}loOXp|F+Xq9gPG2pl%2d0Q_p7=4W?d5BFt*wUU zWqVi5>+yXZ4Y4^KKRk5FWl?U^ZZPW3HfJeNUlX~~cx-&xqJ1t?b&a|-sH#S$ zRHPAq@&xd>!1R%ayu8THq$r=>rjsL&S(^>75A51{G46A+*n6<3>(G0BOC3tP^) z^5xYqC}WjSBE6#u-=yr3m!PX-=?P7iSq^o) zhe4BM68%fHOB=$N;sdUjPt3rj<6dK3tr4(pCIrd=1jI=Q!?dsu3f%^mI|{9g8&W3r zV^Gv}nuV#SQZ>Yv5Sb@V;Fl}x{tYiBX@-_&d{Xz%Ef$G(T&WNBD5|(1VrYo#A z7?VgUr6&68FR!BV(-=Q)IF^LVsK@hYKHl}~+T(ARKzHiEREJtO%ERDN6angqMOwN1 z3@%(`5qLwmC>}?Kbm5-U@nDYd$4u}JVItXKS_eU*vfmH=-=J;{icnYWB=0MfUcy^R1z^g?clI!zJHvC9!ZY)W?~5>$^0F*`j~na^&z zm?HB6uJ_82BBS?6sxK=ZY0#UGKQ^>8qUr=(=%FP74^B-B%cYiDUes7g4xh+Vx@)^0 zPP^JcSCcY1Y($-U(^KDpuoU;kZ4cGMgw2!+JFK870qV{}$@Kc)mQ%W1jBJf6s2=sH z=1*Uf_EdqH!Ql!tsv>Tf73%bh4X!9ex3t~H3(nvkp`+i<+tx=&m7}H|3#W2I*UnAX zlE#Tx9VsAV#kFGT%IYkjw7AeC%YhpmFtugdp<~Iyl(RHib@=M{iH)-&1f9c;4jzc;$X^Y{ECz-0I% zxnxO)pA6nlLB19}c$0bUv?S<%&8+cuqPG@58ZF0Ueif!jkVxOOoe=?EoK;f>7Tp7$ zdrG!z)o1Pl@e4r2U=YCUb=9@H6ABS#Q2B=f>TLVB7PLg2(9hXg|TCUvg7yPlRkN)W4V< z?W(sDp zqb%u7Nd$L)G5J3Rx1~HXv@7sLiC&gk*qeZ*;U7J4oo)4?W+`W8* zDA`|EToR2Bld+CuCVW~MEo7(zU!d7_lPOYsBQFS|=C7~a>ONNDzO8St0Y>dTzT50cl za)Qy=yv| zo)VJfq;&4`jG_CqYclHed1F-?$*%DM_|2#@5P~zVXsde8PNtXUFI@d$Q_ht_8$-Ln0kyQb5V>eSx*8o?2Eppjuz^>>1o~XEFcE|(1aE^>ax};=LM7nMK&CL|EMgiTy{($i%?~8yp|cO zniS1&=t%)7IVph<$u{{PW_^q05D(Muu~0w%sCNA}Qipnx%Q#oQkb! z<&M&fRDwB4P`i}cWao{;0KAv}@mrm7s-eoScveEc9%L!?@;6TBy(NjntLzVbk`D|? zSSyRWjN&9P8wTLHHF-qdD6nHL5Lq-4H^x##NJ!BOxbr~;Dg*Nw%e9mX_G@`9E>$>< zzCVqt-Pm#3oaCWta8-sL)*Fc}(tD9!@A$HQ+uJ^s{i=>oi^8M~=NTP7cD5n}9SYd~ zV@CE<1w=-P+x|%!P?jlvdUz-j9M?)Te3h#Jg7&ve z^V{I8njzxhG0j+44CIV+6jh8UrcdJzf8dOe+qnDwC%}2dyZ17$7SUPpQlyzKcddP0 zm_Ch@8Irc+BPh0Gd8eYf*xg9E0;%&avadeW^#^D61`SQRc&9_9@U z1a_MhEGWl5oi>lBMwC)Fvl@_HG_GRUjV>buKPQ>LOVk3lOGPL-o5RKQyp}#?f_lf& zpo^%%aDqhb3WD)Gu?(Hp!#JKtEWkkj7DEFit;OR`Vn$#Z4=zAe0tx9~IKhYycdIOj z^)|R;c7B}mW05aH$i_ea@{t2sz;2XEQFJWz6Y)i(<1>u3Pw`FJN(QH)bSa~UO6#9c zQWZt+3|~Cj4@%Kd_=)&PVJ@AuZmRLOyGXXE-6+N5;X`mH+Jp5NsdnwTfs=AS#+|!GMp+ zFq^>!phcVOHxXbWC6GDj=Mt@+CQM5BBLqyFG0H;p6GZ^XVs5TXfJYBxw~a*wRQK-~ zVzm>&w6J>KeLo~qSZ>_@PgMD^W6P^7N<|0!Iz?!+QM81QiekAtrchDMX&WY4Co>=4 z2(Xa(`PYml?n;S54NxE5L?81xk?hbyBToJ!-I+PgLW_+9b}BcK)+m=G!M-$4 zOOKb4k@Aedkvpo@K@KQG(C5DZJTs~k3Pxa{-p^=4mQJzgs2Edr0pRdNsAph6k4BH@ zdVuEw2+=|6kf)_&e-bH9J8YvCeLk#zwx1^7EL(GSwb;zRWb-8rK38SQG^n9*IuR{>M1D4RVtN^ERib!3YGC|^`5L2gH^}p}B zyhfa-Eqhz)4~?0ur-g5|axd%w-pgY%>pP-ah@c)_+B@_9p#Q8o9kZLb!b_RY8Oy57 z=F{YVpklU+v4UI866>&H!3(U#@-~`qko!uM^^VK9j@a4a8e3KGy=8=Ixz^Z@(G*JB z>o;{}f!qfW+9`M@xx_-uhmu!XMO%)IR#QhVOh&#(J+JI|Lt73`w$IhxZ32v+mG=GN z_eb>w{)d(2;!8b#-UhFDwObJi{ok)`gPw#itu5T76Yva5~{I`sAvnf2&UIXH}fY?bU_pZ@?R$9 zThjo2Ci?O$%!i}Shfv^En{ex4fA38`KM5PMqMQ^9yZ^1BwHqbvJ&(wPtTe8Xl)and zMbZ9J8kcniWq5QFWns0TPbFc6j{y>4-5)UNfWC*mFW`2clSv;q6VT{EnhBV}lE4J? zdURA;k6{AJRHjd~ynk`7dDIBJNS(+u3;dUgf`)r6lhPqhmYF~BW1&%}j)Wd)wfH0N z14MyXqcgoK8$VH*?(OY0!=`tUo_v||GA}7XB9rS^lh|C&6&m0DqP#@0n?)lA|1t5L zwZj09=BkVza-a@Imrk5m@yH7Zno4IVK}4aNVp`HguMrgchba}jVXui?(aFoE=AYcY zl>i@K%6(MOT~yE5!iAEC#-9+xRD=o%E|i#BZxQvrJ*PZEmy6xFQxRm{`$wj5z1-&D zbXig|(<@1fZTN2%4l&lJLK9q!(Qk+*#%0gD?_upEN6xuwK--{`@VCuL%v)^&TN>h6>t83~YPEKJ)ic~yTA9Qm_~s7-==_#$k70suVq3WxbNtZ& zHR-S1N2P*g>4D#U->iK-f&OCFQmR>ZIHrQ0?fYy+kcx-wSV>^3;7KjJ$%Bm8h2!Vf zdm_NIrUi34@k7Xw$t^=TWRcdc-rOdg9z(X`mtRj3K^J2aXh5)ebS3;C41;Cs_v6&6 zu*0hn2my|j%4drfZvT7H&U(hqiGY$@Kw^J<$M9D?S{su`0>T^zrrKR)rH(C2p-K#; z45ESQa+QR~4o8-#Y%|z4@h81<9)ho`!2%=lP?94kF^~2`w_Bn3LAG9(Ocb%B@W_vb zt9Zfn#dv?Nz@h2tj^JZaXDiag42mpMc0)PD=vUV4RIT|NoB5(;$7Ee(ixnaIXdTVt zCfds1i98kg@(`}{&;?TstNAvD-C81|gTilulyC^G@}47l6P7xeA`(oBWmube(|;mz zpx2c}SqU^LF3?nE)Plu~0E~Aq1QBBB%elLEH+>t8&=d2WJ>_+<@-p84i4Yfw!lmwn zyS&`E_^k)ruB|B~0#7aw@4docGdr(hrh;Vup7mGYre>*C_1kL zzH$jT`Y4JI42Zz!r_2l5QPnt|AWyepFq#vDZ^DeN*Y{%=5t%io09uUD)1W-d1k2I^ zJKsqG(B3!)Ka6E;($;?=7YXc~q-j?Bml33^`H@e8qLU5%hu^uCURI z7{l9rV_rL4-fW@8s!bu ziiqh{HVI7ShTJX+;o-{*TUDMAYVBD?e7>)Dq%HA zNCjePwC0PrANv~y65gvj3?=6_zxD~9r+}YRvXe=>MqOLWs}`KK>rlV|18cf^JQIVI zmP5%JpfUNOURT0KUIIM!f`E-S09qfVV-gruF-m3jqFg5dY-o}<)n0>x#_zYH$MxLC zLVaOFuC%tLYVz=NG8!eaRM29h@hs1KeQ5=81#XYTZzG7=d#qhsfZ%df-k0inON7i> zuW|TYKE`9RT=~r&9$6b1($H8$1`e5gX$h0tx}EA|u6`NiURytOY4U9%F>MU(mM$04 z_7pyql`IhF_w!*_z6y+_Thx}JIMl*5B~SH-|5M9B(vXG^4!Mn1-2?wqYPIM>pQ8^& zFgeU(=*=j_=0DUy4?pX#{GWd7jG#q#8wURSX?9BILb79j5aKjy$E+%%@=$eEnQots zgs`Q2dS&RGEGLQJ2|qTkf-OYxc^sp-%ol|mMPocjxB}W;IbJ(}vdrY1Eim6fV(qIU z9&PGvrSs96`s{Dt(!e!ioq8MD&HDHV67lPa>w>;-r7Z;2E#MLc$15uFh0Se_`IbAa zX~+~{T)er5CkCxPA6D0&9vmLp-*y{LNdK0WFAG~_QuRHsP3`Oq!%yryoTzfa{{7|M zUkLv@EB)z&CCC~;*nDw+%&GifO!rSiNzBTxTB1+%K;8JlRb$+2J$M|v7TqcD>|W~!Jt>(-qs`H1x9i&fQmT7| z-qCt`co_R#{9w8LZw};_OjP%9I9EN|0K4;|A^cU#C^?(mu?(w*hkbI9~1x)s)@7e^?Zvu6kfl1|ciK zIv5>mprh-7bwP{IpBRD3Fb@No9`mXAV`d!3WG6Dw%T|;q#S^XRkAad8YHf zDvDU9I|Bp5g4VlA!?6n8rl}AAbakEP5S-gdOuOJ3!@Ve?4*C^?_^0tPVsY`*{T?g$ zRiYK*JK^BPhj1-9bhtT$dYjupkTas+6?B6A?G!;4UtC54eV?uuG{jyWFa5mF)&pMU zbM^EtQm5raIEtnT#>VKfPbVDaL|4_xBQ{H&%3+gqXNwGZ`b55f)AAs~M>2{|HjjFX zS{ho|PrZueQ&v>-4!-Fq)FBv1vN^2+s&NyUC9q8ce^c2d;}7h-jn!`uWz&+uK`u*$qnC6;?{k z?uEAGtq8eN{v-I_BZv<8e%A24(HxH*TBYGP!T0aikYJ5O+$|1mmVSulvKDE``S@d0 zutfklSvXkMBeON0f>}Q5XqX_tBDTyvn)SpyTH&*B)dT>7=J4up=%a@Y=x0VffBy{Ty_DJ%Q5{=c~+19C(o(vUVaKab&bY(_gAP^=tV z)eD3Am(06+x}K(mjO=4k<~Yc4gg8nHWq#Zq%)*2YkG**u$6Vnud5THw{K5+C5pAowp+1mM_pdNjPc}r#p`@X?68JzZ%zzZ8iX)fax6<` z?|qIVMpW^uwi#DQu6aev>f?tMC9SiuhrU?~`(Ew$6Ka6mf=j6ptnaY<%kH>a$DjOL`gH9CWF>U{DZADi z7j9_D%|nCHM;;4A^0sW8{Bm4PRTIe}rp|nz<~VPG>PA6*81RzuW#eU%hoZFPcy6eX zD2d*7dztqiz@=0LfWG})&Nmi%fZUI%zn_stE#(t%+H#T&KE0hhl9FXqJPuWY3q=z% zz|XUUKNT@WD^Q#^dnD@G&)OMZR=Ta<&D95bq9P65oNV;D-0(7T{LN~;;vQbev^=*MR-6#mIF*I3bM4nBH9Xr zEf(-<+`qlg_snL+szsz|%_uaV1ku7=7+Lfv`>UBVG9!hEQ$`KG(fAv`+IM&8vXk4z znw}rj!&0PqJsqcm(pqT~J&FpzW6CRTJG$#MWtoo`t^??0f;s(G?DAoiTr%U|&iCuP zFEGQYA65XZX&^)OQ1D$D^>~|1DK(*Jmul5CLU5=$<^E{pX{RuNG&CZDU#V*tqe~1w zXAbxYOEUCukCmAcpyGu@`7O{xq8x&xU_KkzfJ`vzw(n=?kOb9`BD%u(YQ+0*og8QR zH%BULaLv2yXf(zTAENVn)=o=`WMoU%|{Fra9oBI+T5ZjbEKmwV2os_PK$+F&~$ zcr5?>_a`V2pKkg#EZ76#?mgvkLC1L_-qAoJ8;2ZcWvK1XA zR-j-(khqF$*O%|P$7ux9GK3{#1>0!n>@Fn6a#GR)Da7UcYN3q4i8LFQ_k1Z@OEr&T zyjzn<3mY2}_pxS=#Sj0vswAgz$0f0pMh!KVi$+iIR}wJQ6s|o!B2IG5=A^sC{=X1- zH4r|u1kC9EPRtBUc>m)g>Plq4bh?zIG(_REF>X*|Qq>4w52`MgN3GJDHbt~TB=~L| zx24i68f_^dPR1}zy%l!mZ;Lt0`R(MSeM9^bVSj01Kye!Vk;;O%*5S5|+HJs1?}er$ zn&yP2BqF=4b)jVYWmOU(HSX_yY1RH3LPE^r=}|Mko?@5fH|^Vm`a z)Nwpm(Y_(1cg8XA$OilXMlq&MUk#uY{BtJjD4&g4P31j(bh@V)^`seLQ zQU~>Jwj9eq0AiDcs&nfQdfK5GHnv(8@OmE3`ix0;IUY=x9i4R^)tzkI&$oN|KOLEw zSyU~UFUZ^4caGYz-tNb3bAPmlNyXCHu8wmb598xSdUSL%7Tm>!f$s$AfFiu_6`fV_ zkYzyHaq$XGl~d$YhDR;6kEGi~!HllurvC6@6u+BeI|+n`p3b^ZZ1+>4-ZXB^3U5pv zBx=W+ZN}Q@tex$4*7OXl{`cm9yX7?+wu-0PXwHo}kEU(q6jw0#F4Ji3hz`jm)Zinn zB$;=>I|Kt+_R>|YH>2GVW@)VSgcS|5O_{i-ayI@vTXUsMG^TmO1pKwnngTAR!?iPz zYop$x1nE1|z4IOY&sK)g)6-+U;)Y!s78;Svk_=1ER2Z&$l^lAJy zgMOa!pOh&$Sq-C3+!@B{m_qrt?-og)R9$&6o6=VhiXw@VnGw0lC;!`MO6@!oGO;u4 z6E=OU(sZzEX_(PFIz#~*=T*}ft~N5pZV_T4R0c^qB1V*WExd)kKWXHdqtWatMv?-& z-tpquXRyRs;e`yUOD;=AZ;GhriOgX!=%jchJ#Z?CMmD6jgdP8vVErESnOXn2G)t`L z@ez74B2*=uEu|k>96~VgqU#d9R$vsJJi9WxBbA$>#{b@8;A44Tb7Kdw$I_umQKsQ* z1x;PI>lV0^#}mBz6{Fq@W%)d0sZSa)x_iFCgZZyTwGK&-yu?1*t|X<0pZo{GylWmc zCAm8VpxO%TUpdosPdYMA$9{mXB)aaim-p0gvLE?ytd)eTjH)o5Aa$G;lA+6)64KVd zqFdL5IEf__snCcXt|W?yNhYw^i?KE3N6n{Q8ZhVtvLwLsyV1lig%CuoA{Xfew{G!x z#2XdH&;t>pxDFwLwxuuzYVJLlt{isd6v?4KY@of~84?v54dJmsR@$1pV=nhRkswEs zCFrnP#6twFXbR768ke4${)E-bChaKxzj@q%7AJBHn24IkvBp6lqmz>Rz*UZwP)uZd z(RK5gm<4D79sFBdbzO@2GtVUR>bR*fI<~HPtMvUZw*;{3A&@JWj8s8}WR-ZoTk$>yy* zV!6lky*CDswO`p2lf$mZfrN}`nr*cZEQLlPs*sX9V645{7w% ze={M4B`-vGs;LL!xtGQ;eDj@dFU)32n$-~GwgE2YayT*9V?C*tE$2q2*P{|l{EPKH z{l3QvSVdwCJql*QijddO=R^!g+o-KImF-EYios23=CIsjIfP~En4z%ah65(W=S~m& zpMA!XYt3BXS0_K5pnl^kPi3}4>2u&-yGT0Pk*siCr0J1MfGaW48^L56*VXXtG@3GV zNNF-@!s)<6TyHa8PB;fweXi7mCdFZ-0FtB|G-PGH0`GuV-mrv*wQSQ)uRjnDBqP94 z)Z^AL*^5X}V|4^xc>sh$!6$?%*WdbbRZ6XP$0=mKD7hz6Uc2Qw-)%KGroa}B)>Y{4Mn=!si6~^W z@IoPa>zRRh{_cnL>|t4u!Sq&>;zu;u&wX>%%m^83A#D@B$k@QD7uy-6C_2R=E0nUo z5gHw38&;~QfGHK4$`Rz`pHZYuF7^4!88d!89%o=ML2}wnonU}iKCOF|qJf7G6#{uFoss<7~C(?G72Ad`i>mQNo zU!B!OYSsN$oJvCEjJXc09y^fwI|Pl?^<;lvrUM`#d&&aC?WNU0vd-eKh-=U7kb80d&p#!GSu8`QVHknETMu2g*dP*~vB<*RcWIA){L%>ak@apg=8HVeE8i7?3tb%yj;J5fGPgE$DeZZvNwi z81tb0$BxGXGO(5Fx9HQ+)ul^F#j$>*zy2>{UQIH?nf@1?Av9xAoz%VD5!BxSubqv@ z+&xS9DiWd>bA+$(Mqj&{%{s zH8etrz5}_O_InKvVsJ?mxnkX;>|MmgZEnz*;4246ROJ&n+NOJa_}^Xt61`S>C_@iN&-J}0v&$KY0sH)NMy=bAu|Fad}|92 z$)ltG)ryM4r_VJB5~WvYq+k}YdMT9R5h|d^XCB|u1xc#-ensnh!KU~a{RZ`LF6g$` zy}IpqRRLuIPQintXt!vtsy-%0cXt!^QLcQ9e$$k!H?;7ng2<1$IX8E z^7m6=xgS`YG%ut$Qn=K^-`?rEA$rNJn`Z<;|7yS(CtA8g!g9SA0{(AmSq}=mbf}-Fa|8_|y|AF&07DJdeV!aeSSC`*b z=}?hwrtoJ`k?@e#BIZeW99J%Of3B#Gm37&MxJVAy*aQ*&b5$sp)0p7GE=BPseI+)5 z@-(j~W$mS4} z@-)+7IYa@qJ4=_U?D#WIWafp)tu*S3>~4!JtXc{4MFLdp zj|xab!2fURtiz#04JN#aVlFdWPy|_WJBD}`lfgp#nw2JE7=dxD>&hUqX(`PHq-jSe zL(L-w6VNSAfuEZ47qbHm&A<)+Hd~!NR1q{AKp%&9$YMggFGg^0l`QQR9eFUZ2;0m3 zX2VyHxq*G!oaxq<;stqdR8t?i*0DqMJ2RPOj?rezQr}(}Pd*Qe`z^QFVcGo2@3BOj z<;*$lI_u5bcsliNN1WR7X=~&|%USyz%Q$BBk4KXn&yC6S@%PwGEo3dpe%w!~FZaFe}U zO&CX2{yPYt(r=_6agS*2E7D4-oz&FSyb;4zlLqsFY%YXb?5SDeq1hzpQZT4_Y4-m4 z39>TfieB~P@kEHHKTLR9_mKvHDR#P!-b?oMK)RDpOu$u;IWw?Sy9mTtH_M<_jH@Bm zanU{^JsglR^f5sn3&GS8l3`UlkePIOiHnQt5^+$s75Rqh=&a|~RJ-x`4&f2)>Yq)U zO88JthjiWKpo1>f`Y_z?cA8~0(7Eu&fwNLyRi6d=yVcTt6h}+XHJL9q9#kdx7O>!; zkomdeL-Bg3iqqUfRI`(L)59BGx)5VgGP*h>dz)AexN8fe%Ok_L71z&DUh`&{61}SP z0${Cmh!N-m9r_K({W3DDqdt^<8}}Rk*048fH||gpQ6oIBWZo}?+E`Jr{6AEkWmr^k zxAqAI1nI6Jh7ynv>7hGBx};k~I)?6!p+i8VOIoC)hY+N@VL)kQkj}Gto^!7EdiemK zfPp>xzt>v#@4gp+Jsa4S$}I9?!YwBN!|tpDaN+*x0Z5;*#O($?z^yli_dBY2gxHzk z<~ROI16gWv#Xy_oy_c!$p3d@6V=#b(yNqgx*G|Z0kT zL4DYBHJR_e8N7Ue3aXF^4we+P^sUmT!|dVaJW^Vf69}QUxTB%}8c>r~Ti=XK zDvyhY7HNItVawfAH9jM@bnEl~eoI`qTqpGSjEOWAGaYFe=XyG!-$c74*ny1CUAr3f z(#`Io5ORj`wVMmRsjH5A`MYYz`+biLnV}q)c1u3ai#Ey1%HBA1%RlzKoLe?%Un)bS znYgP7?ZsVNj#*kqf&&;?DJ>G*j(Rx1D>GVr$u7cfNbUX;gJjat4f$QYoaOmvV0XRt zCxPC+P1=0esWp8YkD!=yMGuCpm0wnh{yDyyR!Z}jtKATCQ3`mg9iHm6e5g z%{Nx$`c|o1wRrkCOTsLo6%}+`7MjxT25`Te*@(~2k%OFs)m50}^*N0jX5{_POrT&Q zd}0?Y&N$^6%?x-bY)XR-iN!XK1_v|ls&6W^RPJ>OBM5l}M*b?M^Sp<%Dt@KT=}u}? zTUvY=yF38XT%Xx~_GVE&H*m>&dGVb6g6}^Zh5`keInesANXZu)kQ-u&e)m88V7m0I zaPp17On^*F4{T!&fB`yK?n0)=%5X3|aXadX1kJ#T?lOpNyNf#R? z@hH+pBXJIMIds3yW6dqlK1$wr&6A^ovtOrtZ%$L5oJG8V%RCD@S+Atji6wyDno9&% zq`SbksVO_rztcY0_9p$+Ei z?ze>P(yAsLN*N6X0&0kE&VyDx)pfPL?|B-`I|4)^c&+TrxkKcreL9V7Iyzi8HFF5I zh+ZjYR}(=Nomia$`LIvOMRUf#i*bRGT3#YAIRfozAi&k8@CVeuwl$yqD)cDby)kj$ zr{oNT$>0UseJ982Y%+kB&#um?jo9J(ro6b(IzF1oyE$5{lHqvy%T~Mro$H(E`b0h4 zeY+b^mi^7S4PKkD!Rdz0TptI*Jm-tBj!GR)4d6kgZYO|E62h{}0i-DHVdPRh)H%l+ z*+;$vXk;rcx0lGz!NI6M#YN4vzL03!KR#2bHWoSZ=27BtNI}L*qBd`HMLdvzy+-lz zHQcXbO3VuY_E&Yco1~?t9QcNunoP1uN3HCYiD!d=pJO6B-xWQ=+~XO*!V|jV=s4Xg z{fWZMTOR?E zJ25e_l|gae=L@!DO6LoU*$%89;{W3%!*^=pI;7{v0q`!=NnZR_&SCmP{lG_d@nN>9 zqj3|EL$0xj{WfXi9mUD`%iG|+X;At;K_o6wklDs5rcz9!vujbWwWtxz)6tu5&-T{u zk_dBVBT6Q;3hcx8RbMN@7ynYu+0$l#P0DI(a_^6~v(6Vv@{HE3KAJ!nZS@+R{XYpG z*@&ao@e#tT=xr3{6`NrAqm|5@MElUz8Ok?fbrl8yz{oBSdi6U4Kk?fp0RKMbO}xd$ z$2XP6jsCp`51o-*#CyGBUCeG<~iG`oV#rTL_U7jBhB}vj?2&`0@(0xq8mOc9QvO53p8%7y6 zZv`$%GTjpggxiR7Z-PP5)DR2nSDN2k)F1QliQ;QhksbAnPf_^JO7+y{p;6NM4Sf-| zbc3XZOaDt0LNo$R*aOXhajF?elh1aK8C~0SpQu_U+moD_b)xFjyS8FA5>pb z2-Omnf52ZTrfe3ni`$_~pxMPDA}5UzqpD}XDanI2A=&e}IqQ2&jesOi*=($>uh@@06xtWP-Isvu z0gxU;X&i#9z>`@N;0FI5%>yL?rW9dDwD9%Pw|5y`)Q+irGjlOP zK%aE|Bm<{NAJNT~8V8SfN6~TuZW&(WsV9bgJ6b84VU<`nBq1uEVP;zT81Q2GlARZM z?4V+Vc0~9Seli!28~$(TEbx9i@cxNZFCLJ1aJk<2VNPwi)nosM?NWXbZIJ~~T4-|u z6654EuUC31Q3F;-cScLSMYO>|=I`pn0Ec#J_$&p_uU*DXT-6l zA&W06RtT1s&7mej7#k5oNgy9wMyqBEDVadg>@ejecSwkT78O^zl+*v4o65FOn?3FR zrt{%O4DhJ?=jP@b^YAdgAD|?_XFhBWk5%fEhKB}`F5zV@RgwX%tm9W|(LVFaQ>V_O zj}fL3(jh9X7{;B?ZXy?%n!R0~w+1FY3{P=BJOEQD(H~~!0}Ar;T#}0}wB%RCv;m~C zilUd1lA~oPKyky4;*++b3FyO22n|4={cz(Y+>niL6@}R&{Pwa(GzlK+yM#&Lu;?v6 z^J?R8j9RGMh^;OPvr(czFS@mg0QQ`S_zh%ve1~I|M-4~4JWjo~wrMTk24$La1qg8w z!TygJG6BP3*4IyaQ=v&K3j9v1FyKH3=6r1vSCqCku6cr@p-_T{+BhCTC8H;3iH8N! zQIVau^Qga?uR}}aoMZWMd37zi%J|$Zi0MHz-!b?xQQo_z5=TGkWPz^#_+J%tHHG0{jGbb+39r zD0Psfq!U08I=s_2N{}S|;i7W>QWhew^>Q@zH#g-6>;jj6yQO(YK0u#Td(r`F+(%21(M=@@S0 zp6sEr)o!a`U%EWT*19X%%R3xkvmBNWi1}x@!K^Fm`AjkJ2boSDi9;>&p}1yUn0F5O z{|$Zq{mSAa;XL?8#;Hn~8gt{2Q^&fkO9I>%q~Vgv2WGb~hu6h3F3$~)-zzJ~P(IM`dCw9~_W+L;G z>5zU2k2GJqh<5ohV^imG%u=x%Q?JMLU$Hs#FwbR?>b%vdMws+z_>5TuIV+Ow)s}EH z;;hMC;;J^{12-mez4Zqi-fIt`UJPiLpwvmMx-qVy;2KrIqpyF8gt1--&P<3zu&YAq zi@;hik|HDJaCcvt1zWwlc_Fkx|Hl&WZ?OLWD)5@Tj*RF|>T)oU>I=3n^eL1?ne-_x zXnrCq9&@;6Jd_sLi&R9h@hChiEgSgw7Y1Y*ao_7{01p9eyGi_KSucS(uS`Jmx#VAf zvP3EW^;?Tqj4zEDBut${kEC#I7`xZo>;)mjebJ5@NH|rOZHEQqhT@gOfj?T4;oZvb zi2n#5>2-(AI}4ub&+%igB?n|#LHS7vg*(Xh5K0{s@-a@+w4U}iDM6*pLNv1s2#X{q z%ly%?KbpUzveyC_Wv}5aH`jmZL}AZ$($nn1nz&Sei<bdn>C}xv?KmZ@~w?f~wWwg*!p!yGJa7 ztg&rnzQq0^AuO#6c9dlf@ZLt3^GEpVgu&4HEml+wU$_y+!t4D{Q|4F9AO<-5-x2YL<^(w_ozU*t zh}FRnP5QloFY&5h2N>45_B%ylD#8XU1?ArJrBOSG$V)E`wE7Z9(39!C`G*__n~Rs-G`xvc18;MC5oTbeZ~}z^F4X=V5CniW(^a-<+~I z**E7UjF_62Wc;!5PJi-f*Li0$@J<_+qxbe%j@`^7)Jjh zuFyWeTB(HyW+cJ<<&(-w?+GPCZFh_dgP{TG@3ew-u{RFyw-sKrKL^?tHuOOqPn=J< z=QoEc`^Vld9^PFmnPA5S&oGpXcvNDUl@gU7$#LyTEWFV&oMrt+snwiF zMe9Xp->vki`lF_P4d6<;(9sKb>OOWgm127O?f)e+a*im+4QwsQvK&xiz#xVqGwEo+ zE+++j%ZB_S=*^|3H2VqM+4Lb8Dw}kk8Ks~*Nf_#GHSYvcdk??}?VdugOeh?j=IBAC zdp~(O4V+u%#Gj0GvLCy&kpPZwv#mHE0sdpsnB)X(G@0bjYb&qE(xty)!eK-tNLw^7 zK;yH*@(Jo}!fPOxBK1`JeY67op?{R@PZ3RA0j}4MLN~a2TVIUnYLOeD6hHJilwg}d zVui1K>P2YcAL(5+4ldFN915%pixtVlDkrRyF_s4U|?>E`ay z2>-Xvq_(4;?;>o}urA>&*QuNe`O3`C?E;4#$=iE&u_sis@~=RRPBgsJ&&}={G)NHL z3HTAw?~vI+iAY=?7P;jFrWM=W;r^khn}e;au;O9>znr1~d>dCU zbePk_wW1>9p|bQQ#Y~{p9<{r&@6pU4n%ysLKHFg}ztVG{&ec7A#4z5>ow(8xAuZjz zHkWF`i-n)BaaGU0~&j!K07Vez^d zb?`L*ixLD)wu0>utH@k_!#7-SYh;KW9qDAQw^hi4Fb7i*4g#@9i4?Syg4#zuj<*Xn z+sPCs3*K2Rg>?&*rpGx`<7Vm4qi+t1G6Rc~T2FNd%6*>zK7z3VfXI<>&f{y?ol-0c zRU6`*w$TtTt*BezX|^X5R7`1kNEQG}VtY1IK9-TOf6};HWb(1_-b!lbdcKcWE+Qe@ za$1c%m%f5yinXt62&n(cEE(vek2ud;SIfb7oi}}t;nU~t8Ocx}CI_pzZam9p`{Bda zkmj-3yJZgC*YxxPpficr|HZe?=$hBERxg!2gR%R6Vo|Go?`vrm)b-zhk)y;#X#}hw zeE6S4s4W}u`Jkd&8cK*Wt<(X2B+PBf9&hU>$G8N-5zr(!8<%i{8IkJs0Ac5C4+5C- zC^j(8N;PdKy}%b5!Fdq1jEufGlHM#}Ab5jI_IWUL{i>{TO)v#pCG2RWJla>6&xgS`bF?%nbQ-{4^V2eY?)mv%+Pns6{g z?TtL*kK+Of>gwMY?{d4}HRXO!d*~Bt2F6|YtS0aC31B}(FjzRv=AE}~zmy8^zGiW< z`eVodlI;GX{BzP9u>WD!)Wg9sZFRy@pJC8u{4j;TQX)AMTmuH@$#uKHU13ma`Myb7 zk_mJsMh5(#%&Abt;>}IB3=w{UwoV&7RLdA?!i~yLq^Mwj z8RaG_M%>-QbmQRvwraA~;<0Ic?la?m2$(wBChiA#*jHc~B4gQdFO!XuUynBPlvFS$ z;)K1BpjcO?J3_8Dqcco-WTdK7<1f)q=)Ac)d*%xs6LhwA&=$HqD}wsy7X`uVs zxPUQi4DoVGT-=N(;75mURC-iC$K;6S+k#YZ0(L z1FpF;+J`=z+}a@XP+HO4*@#%E7ws{iZQcC3cA5!r+$hIHBoQxg)DX$oHzi7$Cqu2A z0RS}|x9f1A}3h_Ar@xxLT9NCiq3^`;nS>{6H_Si!<9i|L^}UIv@OR zCJGhsO#mB;kmA7A#Kc5pvmL<=k0)T*{MnJY&>JD$$4XHiM6doFcttT%H9j@DfPZA= zqa8EZ9Eo=ww6`zCZ$4V#34a7XUMP8C?}aX%bB0H$)j$8s!SZyx=Nxl2%K4kCxFk|h za*%o#ww}E)V*Jd-<~zh%2+qR2|P~GC+zM!8))ng8T zZZ^QpZ|y5TDb-7`V4LD49{s9k0K6b4c7M4kh6M}wQdn4cv)tn59~v53Dha7xcTM&L z()o&LW1!k0?#`oHGot%SBleF+H2;)N5{C%TJYI`-uK%)w1W{L#bNdxew#x;l*plpX z?PHX&v*10k4ed!A`7OP%Y>nh^*Bon45>p7t6rrFskC3qMml z6Qy1(c(R2~YATDM*~l0c&$z67;g5@($BH3Hic!65bkkx0HBRYi*gsxtpnB;rVlJi! zokKWI1xz)gFomgaOhn6PE*~k}i<(a;HN?qR8!&^+spxp{OcVP<&nZl-&@(<2V*7M+wGg^=`CxHKN>Gth%sJ?kADjCy6KdHlKm#{wL@pDKBoDdu|~ zlg6Rv*b0Ooc>-|Y+DeD_zds8lB`{BJ_fJ$2^+yFMSx|~!IL13)T|hfwuaqF}N|6Cmb9Ey}bA38llsL%~46u=H70 zx_PtqPzV=13MUik2`2QF2X**rh`CnVYc4V1U84;|EXM8v+DvUtjESN}DCotJ`F~5FXO|EL` z)2B~IC`kQQv+fXnKzm)dwhcVDZM*;DbdQlwhWl>JU65QwKJX|*AF7GJ_OM(SP##ik zU#A3-E+X_Mg0W4;ShVYM65$&@QJT>ZLGYrsNKe}ri}g)JVcG^Joi2_N!V+EUR|U>@ znPpzP&TU7Aj!FDE5lX+bvuxGZQ5jBfaOSytuM|ARnl5G@w~%e}6_QH#axY=Z#5mKJ#U`14St=qb6`#rIa*Z^r*qXPwROjYGg%r+(>RH_Jq{( zWI2y#s#yII7i9`+@=Av2ucX6dls=N6YOD$e5EU}91M%t*Sv1Wvm0xgO^{4?j!k~Oo zmFru>J43@iBS2o#(*xnzHQ&GOMHUOlg|Pt7;PQg<)!i&XZ!c`^qfe`vCGsk(JwzSO zR>K#BQOy-3#yZ6MP<_{e##f$Pr2Tk74d(O+>nh9pf{zY)yDy3bPxQ0piQK)6+Qq&v zbhqn%Fl7VTgcK6P%x*jwlOC2bKghLoSBQM#nY?D9t=-)j1Or8yLk6WiWB)N<$5>qxYbz=L_QSJx=BLFfOKuAk&-vr0)f9dOc%}`ec|tb zkDVBg_M6zzxCk*y#0Ey`i2#2RW)vndN`>O)#1K%gy+)ztI$^h!v51H4u?9rAXrIA* zxpX#e1hJ7{e66Bf(woPjzZE0fmVeK2t3Zs-VzS)Fiv_hlGcgo z+UgI_!^M}kB~N|hm}pIkS7JCOHI#4g-o_|t;$5WE<5c^E3uu3c^GuA;C<}>#H0R06 z?z9cFNDA$+-b=)}?H0ZJw#nMKPnM9fXGhV8yLrR2D7{UvOHTycA2DG&=iuKw6)%^< zI0fs$!2jS}AqtQ;Sd>I1UmY0%cSb9KNci4>VUYU$O4GZ&S6V4GUJvDb;Ae<@lIJmg zSV*kMuha0**s1PC=bf3^=%Dj=jS8%p{kCu}q*;8dFoM)|?RL&W-l9mwD${m_8Dx-+ z_qM>YnFSlEp0NsqfYU^je5-BK#DLjpBXHN+`42yT1HX~RA&7J#h~ibWTBco7h>VDc z@@p1Vl8lNPPuOg7alkrfmrj*h|#NRgHOozFm2 zV3B@#DD7T2y_lC({8Sz&O{eeId0NItBLHlSwsN-GxNSEW6EuHz_ct(bDVbSiaQ(Hr zGTlzc+=#U3izv+YLqml?Qbh<$DwL-vR9+Tn5<=e6P}6xmafLGkW@6~sFP#xyzxBnQ z)M~nN5IF!U{fc6;QZj-F5-eHl#{UBVVpXSaLy9~<+mqY` zsQUVM&15W=bWl-U{LRd*F<>l5u#Yt5#IR-;H8i-KQw`ZG$gG3p(WL62i~J0{|HXt3 zgW=r)^aLKt2(uB)yHFN5skHV>0KYKW%zZn3exM2wIV&5_3lzU zjOX}{=gT1`Lrd@-)nVeP5juGbM!hR7{I^Kd06kU|2X1$jP9`*sVyq@W(}V|zR^BNz zY-XC(*22O4WaaSaXDO z9aV>EcL3$q)+;7(hRdoBLE;A>5&uhw%gihBXHh3{6X53b=_%VuFOzT-ppRl$XI=l! zLT>nqotb=***V|1jdUp#UtCeXI9v7N$1HNdk<74>$7WN@ihR+I2w&{8>>CwGMHJME zTzX8*;P)@3Yuq3i(YY@s6D!6ZUPgZf$xFjhpw0pyrNKF1>~1yZ&_QoB|Hb0tQzl}Q zHcIp16y-d(Wqu-^!b|2o8Kv{ zZ%xoBh4G1837>2+7SU%&iBuz&1Cm3&{Faq88lyP4&*ArZGINchWN1F;iX*NQA2br~ zDQ;x9A=Tz5W@PIHRM>^0Na3~VPxqs_qu(%O+0UZn6QN zF7!_|mNrlKSB z+|>{1osOldXZS<(R65>7%0ryn&#HcL2vYPyRrZ-H3!vCyy_vB3LV1A;^x^=_klw(7)yQ{PGaw5ERUJAxD5?mJD~ zzG!$Ml}~=H$hfM?on9)=;mBxPZu-{K(tJX0+698$3hC~?G2Iziv;4aK1!DvP_K#3d zGSwt&T!=QkPs=oCzL{nbmy$Zofy#H2x<&W%{pdyMRIKMRv6(z~V1 zn>@Sn7|z=f+Uzhhyo3@36BymXd(R9v$ixnt3GnI481Bepq3WLI{gj-H6_cy6k( zi16u#-F0oLy6DK!CHERyfuEl{QK&j|kX(G;E_1e3UD50M7N-Sy@>41~CB+YX!G_0i z&kA_s*s3$|X@s0HT0W>rQMnR5h@$Tf2qzM2b|&6oS|2c<#kzK$Rhry#H9eh^;ra?e z3x^d9&H2BY>)MfCT3-I)e6-vG7&Na`Os^+LJMZ^9R~=pRX{l*ls9-OpjigjX7-G2u zdH0Euup=X%toOu&m_F2&vtb0WuV14R>E{Q-|l;fQw|9FB;`{0_nB&CUn&qRsEqOBCnOQY z7&b-7x8-vjuVboDSzka*{Bh#hA_))Cvr(ZmT>AcLJys8@0Pj2UPu8<0fm#pS5hQB? zOG9>ga&l1dW5~Ro$>*F!7Mcuw3PE;Jy#SGwgE(dz5%8vx?c(Iaiy;WcQnL0nkWkSP zb;mT#tpHpcjK3MgSn{4^Aa3#(=a|@&npoB%}RSs3BWIF-X3r>P)9R-aMObYyd1mphpWqjpsH)1TCs3+coCi98 z%6?&6?Ee@SxDK=eUbg@ta)3^_D+YMecoSh$i9SXlBaz8|Y>ha(KB-$WgP53(q#z(o zCOp@oJo#stJ6=e{OWCek{{6X{wcFp^fmhXZcRLD81jC_GN@jq{PHOA3S(-_v8pgfB{xu zJlm#6{h~^7Vja$MfqMD+8ZPjbz2pmkgV<#4 zQl@dqku9D9Hq_<&DkuLBP;eb;(n5HqhUe)~@w>=%S zM;q(!TjxFt}nNufGgY`mx-2 zhn<{92LFcUZO?FDZf#1&ue*_taij`N4yvw`pNO^@CpWj|qYU-Ws*>JCH(1vVn^DZ> zDfcZtYMyah7gv+PD#Ohyu@~_>EnT35~Zein?Q1g#)4e|wrAsqzQ z>j{MW4oOBepq<#fK3Xw zsf?tz6g2Tan~|x#*@+lY;7*>PjjTMRq$o5&z1kUwW{3M^W`9g=(H<@5L37a!!PfJb zTwCQnq(>y#YKs8S_smkQ7?7vzbqBz7Jb$cr|K8^CRqZmqF4AK_`@Kr>kCz-_9%v*~ z0|UyQSA_U6-^=+c{or6K7hbU|z%Lxb$0b)O9Q`bXD5y<>1;s|Qk!G$am}7Aq^#zSqkaV|E_=QalRP zpnt(3En9p*orsZsK1-ZgwkaR*`Zr=+{=M!@&zn&~a+^E;a`l?{E+!C9M;xp9kSu8A z%_3ZyvHvP&FG<3&z~`_CpbL+xmQPVqcHQfa5EI9IY7Bjg&dy`_ zwqV)Ih0lbZ=iKR+>{dTq{X7`5VRdi^+aYPbs~|(>Ga%QGF5u>{N%Z3H+S=32DKJ)5 z0M!GlFMf=pGVsR#n_mh1J-T`DMytqGrv4JcPe&P^m+{J_z)rYip!bT=ZUy!h&{}E7 z*cEfUA*F*{8rT}19K(S`PK^)#gs@8!YGM@#+lk#hT%sIsfjqaUK>Ih~pGyo7iNk4^ zys{a&cP}HF-~h**&5$WPnKa^4WwFQjVrA#+gH=2fyJ;~S;LU4%9=)TU^8qxE%bo~N z+=Hh#v9VdxVkJs}%?|MP?}oZuDq{2bdgvd^F4(W*mG6?2ZGq{;#|?mb$*3`D_u-3E z_UbNk-v&b5Y$8yl9Y0z|>I&gTcjr(S-OGa%5Daj1p2)Dw(Ga@Vj$6yHvv4;}0{mfW zZ!rQ`j(PEH+tG=Bj%+uT!MsQ0Z)S&_;U6Dee@7{>I0UNY7REKEbc`QZ?!HjlL_Ca$ zLm*xReMdO640;tUxMGUiP)`l;(n0@GsxtorEjE(r_49< zX=H(A{$W$hNqYhg5KZUQsk);4nD_hKu(@UM3(fb`0r=})#A1<$_cxfSoX9V^FMj>B zqS(7UvviyJ-4)nFr`*Oh)Eu_am=0o-mbHQ(f^%qVgS(Ro z?Bt2C;pQl9E*~@ABp>aoRu*_+tLR{njf;>nvj>|m(3T;ux6OULt--h0YnB4h>vuE6SzK3;OoD1O-jE-u5R3K>m z1xbq8d1kvXu zmJK(;bi(_T-_cDrcm3;5;;4F16;NBPYQEm*uczj|xmo2&i187R7V>rpiQD4Pvm6S@ zHoDA<17mm_UFUiK$?}vHT!H;v;dw2Fhj1CSwcCRoGHBu-EmR%@Veq6!D1Xia_+tTN zondl2$er_XTC5D;pFb()_TR03T&g1qz<0_6XiMweH9{Y8Z~U3Mv+V$nkHdroi@e*r z)98^K<7s$A8)dXY$!!b~etP0lW_E8A?CONi)HxAP!DzeA$cZ>&bQ~|`_>^Ox+Bvrw zZh%&HtI=oDB-9Q@z4!xog&6Lry9xnyY%c?2n3%-c58!YXt66{aO2uKOB_H~)w_bkd=9w!NB_-Am?+&EUwx(wrD0H1M^&Q753V`d2D^D{j=dsglBHJ z`xCOESBj7@sD4x=N8QwN(i; zdN*Hw6iaOlC-&3*i>*Gu4+KQD=YBY`$$GQOsXT(#hNq0xlHs-6%)=bN?9^(`EZsoqpVt8bhd=6-ckxjN$#t-$kzgf+=5VF>}@Ij79}o+_v& zS9>Sd-!lG#>uZu}sYApY1w9jT`IsrfSMm)EOI4`NcDz>F3`R8ZL9zi4K<}WT)fGQ!}S(02nS{(x1)aUo869 zp6xsiRe9&VjUi(8QYJJtw2!TeHaZ83q#K=Kuyj7qDTitFcg9L!al+Waw=t`A}`?C zCeIlOVQ~X7fgEvA^=BOiDr|gDmm0SG8mch8n{Th@I#)xZN-)wxAuV2mS*LQI>*CgKdr-RG)(MeE!a4D6d z0s~xTpw%33V)}ed`w^~Belqad1h;JHbxocuQ*@;D^Par9KDgOt63!*5xKh1~4H^-+ zipC=+ZrS+jE01CkX7by={aKV6sx=Ux5AuE7NY$PgAVw&R`8rSp8=C0 z%?3>SKlG{5G?q@jh3NZ9e;(6^gS8~4-!jB&u9ZQxuV;|QUcmn6q62a>O*!|Jpd&jo&XLp#Le^gnW$Tn?|*k3*{ zswQ~LxgFJAvDz-18-rgvUGx3$&SHHVco&-Dpk3)#SXb7o_}XEQpa~N>a2{rWJR^jC zU1}KeN3bUR=?m9M!L4U@aj$ zT7&tD8M{#tQjj2VCYhE5wJQDkbc68C<2sl-TVUteXCPfPu&LBhxTO4@ym|z1soeZG zWk_`1x9_(b4%JROtw~KNZ-+uTO@U=&C1Zj2|Ge&IOz&n^6!A*?q{k%P8n(r4l@S*G z*~J#{!ehbDG|zZ-IJbx`BazZG zru2(dzd)UpNS~&PBh&B;kow<^8n?YflnTeYi_WH_yFJs6JOm`QEj%caBtd1}@dX)d z3s+g+Pa!+zuRgFA7Fe9t(SqM-;MsL3y};E0Zs_Fjd@jxN`D}gKdK@#maTaznnS#UyWaybSaLb_F7NgiKNn?L9kW4&Uq`ZjmV#(z}OtM z?>^1{6$G22c7^_-V4KDd8aZWtlHK70B$}e5fKykbbv~rEg%839XBdyt!w%ihAVdQ6 z1OwD-Qe^m|@w32Jxs9Xd&4iA(Kz{za9{AXS0@ce+%ijVl0{!6i*l}v-e!0Gzjzt-7 z5i}pl)BsnB}yow3(TJDkoMtlX0H=DQ1j!nf%}ra#L= z==j?sIxXVl!dGZ=)6**D&P>l@O_wS3Vn)h-#^;xM@fs7FLugyb7Hs+ICvJWk@T>0A z;XKdWb|a&kWej1P_=WaC_ku52Go<^JMDxSNJ+Pz0`)X&R(8(BJk214$Rl+x!M#0{` z@6;IYS5#|{N!dRw)@l1;qT-XT)nVPzejr?{Lr2oKKl)gQ%xJBu4uX$;T=qU&pyQjS zw!NRgaV6EpKIS2-`In%ty}n_TG1BMrg!_{8DP;bQmo%&x6;!lI@g)@QS62G;|5yMe zlH;HQW(>Tq0Wm0$Y8!I>hPII4fnan@uE&)&KTS{)^YETO|AD$L6~4(#$m)0DjpFo+M%zku*UW#Ht2?7=L+}!@vKWbU6 z8xhpkk}0b=TrgoNgz4?oM_rYbeZ|63W+6ZGS0&khth2({ey&=vF3y=tTj|M-YS(;! zeu0xmOWm(M=3fUSxOSHm{mj4=aa|vreiqsX=KdgKEJ)iGicb4k+^$;eUkSp?lH>{G z3pXj`y_>f4mtK>*=SMu(#EVh=QyY7ds98UrXB=OQ&B7GaM)^NUq%i7RDPu!~RucQd zl~@x{AoA*M*1B`sGNuI*x4ja6xQ*`uPWq{xZV!NV5_1Jb3k^hMHdLKAJRi8J5vwUP7Shkby~aF z?2be`9M;MqdNdy0j_7bv3N~JxHU+k|9?_j${@EV>mO}x!)af8Mrr)CqWB;8BbfJe_R&6$4v5%#rqUD5!2b5U0P1Qo zY#!!yDbx~arv56_sZ%dh&{WZ7;=o>Tu@#bDSCjp@EpeoV*^9k~16g?$JG0$m>bZ9f z`*=4k9hPVOW*RvT$N4_hN6O~pD?OZ44j)Npap4Zw2js;{8`GXYzDE8ue1cZ&Ozydv zdc3QE38Jy~3)n{nDafL6{sp^x3$J%4&^6Ru2Nhh9+l));o&0LL!jF0iVGOpfLKhcg zz>HDGfmBo=jpbOq<3#U2u4~JQ9P$Ri@`4l(tgXKPz>UQuj4p3|Nn)m)^-`LN?871V zT^|}mxn@)7?J*j@lX7%Qu#?vC*wbMR&<*&31n9qeUn2OjUZbhZy_=bVZVO>n zXw+lj1TOczX@WALLE)DLQ)i@Jn4C`oK0K#t@sn=!1k>hOY?wDrd3`=`4=3^U`^QmYs;T?yx3u0Gt_2`i?^ZJ+RK` znN;W~DlBui+U0CJrE~m>`~^nuw;-450yN_n%QuDb3d_h5WSzPwL)RuirTJaFeK>t< z>H`Gin~3;b9#cCv(#Yjt&N$?!8d@WdHA`7P^yTGGVjU~acKHS?(8?A@Rgl#~QK0kM zyC^jN|B1-wsc^3}4T_}FLaI>r9Ws9hZ4uV*f+~p?;B$twUsV zpal<;puq0$?^S@suz{)QAjP^fS8p~VUR2Pn+1p)GG)Rf|%0X|B5Ts0gI2rJfIQ;-d zgkF!!-$Vr+=Ete#7o|{O&sz)!2WZ@&=$xR4W;eG52cm?ZpN@4#60YQiW?G{#AkR(x zT^iW@&;`y5+g4ls4W06zN z30VSKMtX~-V0mXxUYh+`;V0rv_SD-@=Ha=KYS zD&9|R4hQ|t8f(Xi72~@#B3-rpu;5xdrj zb)tZNqQ}gh*GDrZXE@QgAwlmtQKz`JRm=GO>r|?{7G&(r36etvVF;?c4~p!3(87ik zz}eDBpvRj|7@!wFx=RitLzZ&+2k#@YsA)D)*O8#00TndJ(1%Tu^`x)J$>2zfmyN*# zF{B&N6uUrjnu-l`OZHQjX~7yvwAvlh>AJkJzVY$p zIFwsI19oLo!lMx_B~?%?Vx%*XoYSU?)buCXtcCWMzQ_sgzapETO;Z}?i$wy&)Riod zinADGI#j424Ss(9xu>YGoIdm}%c)3-+y9~JE2E-%-)Lv(F6kKR2O=dcG14U=Al==a z4qYM*Dy>q|-4YT5NOz|)bi>dM_xNAy-nH%r{KPtP-t(UG?q@%HKihl_xntPk_2R_; znh=z@Ed5ka+o$NsyOn+%c>;XqF} zV;fE|19|U4FsFi@*P|~2?ks2VjPgPV918z~Gl$9L@*p}#xc*uck7dW8K=OleKJ`gG z;GjYP0cz`cU+{e&$vW?DM1Y?QDT4R4AECd)=TOWk5dxT-XSF|Pxj%#re7 z8@ca*2Zp@txIbM0STlgNlj3?TZ+Jm@GB4TKvlKom-;T|aSFbp z!C(hn()5J>X7<|37h(Z6eB@-KOuYql9yDn$_oWRhy|SouAQeU5dlFV_qRCrnD~+?u zSZ#FDUW#*A#3^x?MNYUv&4?rP)BLl&jES)=dVLBa(9^mm-DA>~gYVbrkI}7f4Hb(n z_|kB!{qmh{_g<19crQe^In~d7NT#0;o*TK9C>1?Km|mloswnT)JBQ+{QXwVrWQM&o z!H^r~UOqJ(G)MQH%QqN8ANjc=+i!W3&|2xZN58MLNTiSDtq2){_JC}Omt$c`c>q=TtAl@AY%oD54! zPQ6WGcYFLS*GZF>`-@39i!85)83enwki@BnUDxTHDrjxz4h(!7qUN{#yFi{cZNV{h z%iy4f9{#)II<^BwGh-io3dn+;R>sH2L(6olvS{h(3ij%cSCjYZ2NqX6CaaHwq&F+I zYFgnkWYOPb;Cao>zPt_%w-i`*B?%Fr+Po<|DZ0Zyb>FRpRwLv5KGA-^Uq(49 zYxwd+WD?YUs@_5T@t|>dy?aC=>FCf)sqXPccaG<&^n2Bz+m;^A#}9b&blmQx%`CCm?>yu*1Q_6XYz~|T%$GjVNjfShkPG2& zdwY9})YR1Ez*PIH1zu?R|Pn%ZwlEmyLrHfx)R=?CbH#(Lk34ADqYdRgf{p6 z@oQm;X1r|5z4;w%$C#G97E;Y9v|xBQQ8#hv><^SgtmF4sgd=E3o-wsk&v={e&^~~3 zAA7vXe!4NSsy}!QOe_DEh2ow(!Q{1DGyAKgO)sQxdheV6R}={Fs=+OVMt$_4TO+_2 z=b77!{l>1vnzIGl>_5cv^In^WgobtRBCjKYzlLHy$@Orm$UDn}A4^C1`{*-WN58>ui&1Kg@G>!jVuh7y}&r=5U1Ig8*fsC`Bzg-5Y+6X|L4QYT<)2U zyI&nN*GnA_hKR(rDoPY(mXy>b@50q%0OCmPepK)a#XM{Qz%@LNj6CruAt7N?_FK@| z$ScjZ)GTyd^6`~`zRkv?pIcfT?C|h9VK>w7ERC>aDkL%_VG>w8_{90?>6ND-DF+P< z$*o=&R0x8N-z}Y#g@%70u&On^U!yR6=mjPuX?}`o^Vu5C2tvNcQ!=x!6{_d4|5s3c z*ZLlb0!ec*|=iHG6(8NoJ} z@zR6-tT5=c?dS1F$W-z{EIoF4Lzk0FmPFsxs^cktXQ=oauhEHc5L>00zL!z*0t`ndIE_~gc+dw{$l zigl&e=Po`&4{#wcV;C6w7p-=F1nd#(V!q2NA=Jd2aX*J3J3iCL7c;4hyeC=Mg9`wsABXe+Jg<_fU_{>ZL&W5?63*e>s z3(RG*@nucN!`igDpN{XKEy_?bcc5ccE!oLEz#cd%gBb zaMdLt*Lq1LTpKMma=@tajB|&a#^17ZB{_C?{MVZD*tEmUwJ&WwIZgu>&)K(FsR@3a z;oSwlV`fmE^j(orApiIpZ|ZKn!>=s70DlkM3S^iG6V8W!?W0N$f z>RPNduE56y|B7{JrAP-A*rYlu8d-&XzUm<_nhg(x6K7{<0hH^SI%ifueH`&QfQ{>C^WDZAHl2^r-(&KBi%OBeqrLzJc;lgm#D+kqWaAL6$?O!1a zFky!vYw-G3;Lc!*Iu0VY-KIMvvb)X<^!9i5BoTq=&QVtgW{;U0IpLN!BSJ&6#q&pK zWaf-kRFLq)X@d?Nu()old4P;V-8kJpV}mbIWZNmAokx=8Pqbsd8q$~>@gNRn zeI!uIV~LGy(*JQFJmnS-O0-w^`=H>r4kh(&QT-1CV@ghVw)0PI;8@n{HsFxLcRb#E zG_3eIP0WdeJsqfuo8_55%s;E>i#KQEgsC~lK{B3e4l&8cg+@fW9yoJtG=3v zZ6cF2lY2NJ(?0X5bxBI03tVcdq2Y3vCf`0{n`Sa&0M*ftRk$o(Uvok5U>m)OG{Jw? z$OSs$1I>)$htEW|G=zG4ZF%uxCU7QlU!~{|=xfGLa#C?*LK+-e+KF0JfT-lh&BrQW zVAsp=igUqfTgI>FN!31VU%lMxWarHa??{ejzPsGm?45bPS4JhBKtoJ?WSBzlchPTl zU+?8={NrOt$UftOI;MaCa+@Fj=vWTE3C!=tfcQ8?T5@2iqzzBO%q!7cRueM?iEv@R z6M3TuptNlt3i!9R`kkT6y#-^BP=u7$2Ct=A{iSqhA;4oTbTNx|o?E_?pBn z+mKzjRqxby#g1D&DRV5b7C2?8Sp{~jYb|k*id&Mq0gz;!vSlt~XJ=>SG$82uWYMwe zPY^&n9>OQ67URPmJ#u7<%tw2Tt}P9r^TobXYvk2#DO5;A2-tBjfvDR&t9@&PIj)FV ztQ1}fBFqcI-Jm+@VIvda;J_1j3p(f$=L|S>gr_&AUpM%(Oj(TVJ*pHhb}sQE6Q8aV zs}7bLj|F+sj)H+%C2yJ9*EwCVnDLXY6VQ&_0nS&Ltx~b&S425v!(~_2;81$Js0t%? zm;6)o1r+F2?RU4hdp|krIL}a_eI;1BP;HF_CEo9vI>jn1fC*pu_e`L=^0GU|Duf6! ztjv(C#%7}*74}3}B-f`GQsF@-wY_Kc3MU+P{08j~uTRrD?(2?&pYHB}=~62#ZmV79 zAqi4y>LrbA-?j2okN&dm)??GtETXiY@5Z~8srx9uDu)c|R+*@Nhge-z|p2B`aV8LFXS*y>)sWIgptp_bsKD+n4I3OppT0}uiNJ$kI>-=)SO-Kwn&s$P$ zFsxmO)AlCn47==l^Ub)Ip!HiZ!!8yxyn8~%8btWiMGsVVoOBKrILPz`-~_!M59^$+ zT@7J5379W4>TUUwK}azqlUv4QU3)*WazC>Fbo%n?l;5iMQ^R_(3v(!L&*lEMH48MWa$!dxZ1=(YW{M}Qr#o|rs$8yQ z)D+hk&wPRMsjTC+tWiDUawhxfe%m#Ndk7?n+2fRX$j7n4ixFOsT$d4jr;aP&#z= z^i1w=v7pRTU?s-!{!s`4`*q>f(qBAx*M?|Y=B|65GxhX4m#f&-8^pHhV?Z=bcdi1< z?MfnLPB+Hg*T)kMpP&zw1eHPa?#LptGD=8XweQ9H;?Gcp^P3{nXJ2jgd6IR;9(3hJ z9)b4Nt}u#{Y4B!>u8D-lMt>+UJsTot)JFw8&e+Ys?-qxOm*QJVZ$lJLF**D9q-r2s z!oNV`#X&<~f#3#Jw`QWeT@uSQ0+&l?0WHY)W$vG9E58=`#LuN)jAbHPq9ZuSnkF`0 zI{keqE2cO5OIW^XrHqETtZ0;&xEgCajOS23x0``pybd%yst3FPa)H(pXYJLm_sj=V-u1`3@d4-zXTHr z(6`Z9Numl?wPlCW6MVc;J>HX*6lLcV{A+N5w+yOGnz~e*nRA-#Kadx_>X1&uH-<#qRCn^KNF* z`(JA0t3Tdz#x&+&`&ZtM_XzHQ5t7dAI`dK(q5pa<1<9+9_Ix;VkXA?H3(c7@^~`vx zR*J#B5=qRV@J}7NTAS!2vabZyTH|jHdkFu|m~Oj`n;VLr$NLY4KX1?%1!|{JrCvQ1 zz1Sqr;3kfqHX+IEMM20rkD5;nGBwF0$WQYL8^^XkKLEUyWlpOOp~wBy^Dd|FBRLw!AN;MQ z08{-SAXuxwOu3aOO*D-3GF*GWk|1#D2}@BEHDOazfNu3!p(J9{A>o}JJpL4Zk3kYi zn!g4z>;hQI84IgD9b2EMVZH;WtDKUfh5P8t1p?mB31Qa` z70L5!yqj>DGQMw|2x8JHjlmn;H3ODW+|5c|0|^@*WIue8*G3TR1vNJs3O>)H$N$&( zhVA!BJf3_5!ifF?)!t2>TMgSNK^sbip4gz1?FNH;hIi4A9&H^$gB~n(m@#)n&b#A} zL)HVp{61ahrJwQeKGD*36f+B>Hh$D^tA28t)v@muE5aprP{SsjMKr64=g@-Pw?9S+ zeT6|$#C4TZaUp_3u7%L;7X$jZ#F$3!PR_UeA#?3>+8E;+g14!cVSrBjI_$OOsoiy$ zYT)U9CF|LpQ{i%vf5s!(SMTl_gMrX%B19)N8#hb)xX8qc=Zb%5VL)%Q>1JR=qA?h& z<|ig!(ujejHXY{gp8x77rkFCyLB%n25d0L zYPS2G++?=5K1ux!Xmy<_8eP(V`Id_UN0z*bPg=)k2pZvGUiG|z!`Dq-&>&VXIdJ8b znUQrhjk^dgNa6EzU!6fP?03E0ZYd`bOhvDW2>y1KQBSgTF^I(rKDq04dHnQvOc-9Z zJ~C(Ge^>!DP_CHa=fX107uIg;_`m0Y&mM#*9pAtrZ*wg>6tAH<9K+V`*Ua^x;(AbOZ)j@DnVpXSA8{a zY}Uw>nMca?@yqx!7NfV~*Xq6<(yYXocuM$CYM})v-5wioA2`D;)HvzEm-i77Oti54 zfQI-}1rZ#i*_PaZG1mYHLh#-YJ_d{bLC%F(G92QgectLe>?J+2v_~KzrQc0u9&dQ9czB0_s@Yoqu5{!2Gv$K zG^AL_%rkphVT!9eR17zT3q)BmXHtbzusY8LlvMVtK*eOZO|!Y*-(a81{nr+j`Z}{PbMnX5bqe=si&Yox;b+NHu0} zoy2`g85xo}-kU~I;`hDA1J4-iu~xN&-E{E{MEbYN9h!&bC3k7j#SuRGO;!V6HUX2Q z(ZbwZW58Uc@wum^7M%$-Y&u9qd+W!HUL+;ax4NRmLAzqLys@4$-Uw&1TN!8qkS75@V`=6OlOnU!U z3t%LxXWGPJ?H3v;)Gmh(A$Hi^$-F{I43olaYaAun2>Lp&cGhBx?IoO=Fh&&#mRvub zYrkwj{pXlj5p_ermk|5H9zd*;W`@dEfTB_4+#<2}i==&$d@vY-O&yp1W9I}icq+AF zffG>AyyL}PYfR2Gr@xnMA4)lnJ^k&EhR>YO5N%=>}!yAWxtEBCl`4deTCF1qc}Od2U81&Q=GE5B># z-RV%VSYM;VrQGRcqZ91ji-pUriCVZ2y=gZJQTU<|nWllsWMOSFx{!HZO%sT>&0 zMI~I@FW$W_cSjHBV8Q=VEd%tyuKr4w+&w&3%c4c1C9!|uVSj&&PDe7=d5jy~AswQy z7TM31LoKoCldERrI^4$@lGqE-iaJduqF`}#&C1wv`dG6EJrqzakyDYeY|ZFNV0u1- zqoPmFm@YDoypY|hJ%72h35#IBmG#-B9OV1QokrLW&}8x&?f7fzDBmHmMUi_cH=wVj zNLS)uCTILoN!iHFArwm2bKFrVp&AriT`V5-wSE0u!YoX*AyUP%Kkj8?jpqrmk@p`( zUfRM}Rn<2Gz3;0Z-dml`nhZQFUn=(^JO|CdQu7o4hP3`o{Bo7t>CunR`a0Zf^8Gl$ zjaPJ)OejJ0KHG5{yPBU%L1j**j+1T|JW{EF1<6Z;2xnjX$+%Ph%$@!WBuO4}Y{|&U zfIC}dDiM6KH*D>Gczbmr4ZqHY10 zaFMbOMEBF>Kvaa9$5LoySzi2276Csps<~vs&DYo-Tnhm0;5&E6P)bWHpnJHc$>7H3 zwZ0W%-+4;mZ~}Du*_7F=3;KwkW^R+U4IPE@gYK4A#0ix9q^zo21A+Mm04qp~iV&^{ zB}nOHO5P#+8A#@wRKnAiZ1~;_DODz#@r?Akx>+0q{c3iTQ~L9;I_T540veDNBusUi z`KG{erc~P)#LNX^9?_^Xjj1e_nTr(C$3d*BPRw+E&b03Sy777qLl3I*E%xwvoLI(QMb5)Xrq%A|oo5De_tj6!kUC6CKv% zIma*GPDyAJ#skDR&OU26ZhcN=)mBjP)^hcg9}}pL;himE>;37_9at0tB$~ibX$d^3 z3%(>je!XNWt!vSk_MNwZ;Q*s$vR&Y3L3yq2PLC$uf6I$In<%2S9x?wGI|PoxmlnXo z#yJ-u{ON7TrvxA)`X`)u3XMV9EaK*#OFNCdV#X84%{wwxik`F=+({c}{eaeNJUR&xqHRW2rf9+s`tO&zS>#BnFr1W!-?3 zBR@RgT%&#KQKjCa|MebC^uvGU(rJ=lNXn~fcEmKzl@vl|=Do%&p+|=RIH3MVmoqL% z5wn~Y&Ok>8{CIAUI%mIfHQ@j$aSD zJ9W{dZJbow+t>R^@k4I6uFc1Gfqfyp8CtbS7v2+%tUOD13x>7AaK18SjY;e0sGp`` zw?@RgmI3F!#QE8j@Ua!zq?G*HE{|;57IX&^#D8j7jpj4$wSsJL^QV2NC3FdA{3QKE zRSL9=-lTWiIddZ9P)$oqO+c<)!44uKPkeA~yO<4H+jE<;8Eei7=cG&SlujiOjmW5P zP25OSq2hN#7Wns}>Cp15hbf5M%p=~>%@?PqE$8!`OW+{;iQ&x+I;1is zBfvG;jFn!$?v(bY3lT?a1&OB)^`mG4e4${G?Y9}*`YlC*H0 z!AW0-{DmQU@+5j+Nia9JRu0B0k*6`!uadKI_KxAR-LXY^e*Npgm2tN%bI=~jz;&?+S!QA5T^r{7eba_5KRCpO6!?$r?()Q`ts8= zqMA^g1ooE5Fz!EbNwwdqDi5?>vYm6#=@!g52I~iMghuCURzOV9k3(cBq15uM`yXFO z_u3x7Wbr^Ew41;Fwo5ohXh`vHK2IUTX*~z8hrLuDnKF-b_U%)A_9-^lJoMZCTlYF$ zA{VQf84F*K>lo}OKAJfpO%d|?8b*Gh>r_d=gaz1h^&~ex0(8=nUce);ZD{l-j`K?2NG z3;HZroV8|^%4IkkMY^TG{F_qz{##D&>vTe>ou+$-w9N6#*A0o+4 z^Pz)3<4HM+NqF4SrA!%bFt0OO^UfwU7IJrjb4F9xx3{dChR5``S2~u? z5-Geh8k&&&ZJ3+}!HBRlk4niwiErKVhksozI#L`0iR)ks zOX>HyCrMAUO7Zx5FP79dM4Ghp5+Z-VUs@!HXay50S)N2~)Og5Z7pIV5Kv^Xta|6FQ z3tb-%ZjnK@ztrv@f21caf5UTnkBlOYyaEuX}+wgZ&HK%Ot453MI8;^&xMYZI^pxoNzy^v8bw9w)dEJ% z6hPYl7DHH~-2WL1nbC%41gm;91!^sDlIktq_^vB$ zvtXg_>_~WS;kkQ%zjX87K{H1Y9ejVBUca$f`$?@O+{4leAGRHKl#namn)-=B{RjL5 zh1L#(d??XO*3sWwr?i}(p8kd#e^t&Rxb+5`EBXb|$9Zu<`RIsJI=Cfc@t!Vxt>!2< z5@HZ59q1V*I|>PguOcNyDw`sIwdj7*cAB&em&~LMW!DSD{h^2OGcsKnJ|!8F=F5Tx_U1ad}SaXR;gN87*=q4 z#52ex*V!BoqSGpO)k1G1U^>7C|<|FHxBIbF#At!{u*zDOdg=QXI}|-QP)JL zEM?S0@bQe6$cS!LgyQE0Vx(QfOdhU2m$Jq)+XvNGf9fv=o@X!PH7e(nu>_t|tXhCI z^gej$tOzdmI-@4KDfejs;`z^F+#e$j>0cSEq()i*!WP@#C=jW@A!aPga#rw_8U{?A z&#?5Yu6JfondlPGP#<9K2>*v>zF7xXi_BmCCPK$Iz@JnBen!L_igCQ6MdHN z)_~`AqC*Zr5M1?bo0;BtnpeRGmh}6+z;ydpkv_xd5TCfntS&p8_WCq08_RcussO)M zP9^1%Dhnp8IvOH`$<}zu{)d%!%aJU!?_Gr@r6*Eh#qud-iB<`zXz%gSj^SuI#y{}O z%#L>*5t6|X1Dq%VqB}HmFphHQaQx}=r^Wf|=uaCV6bav}9-Z$jNX}4khqUk2V%_rF zaTSC;(_SB@sLAS-;-zFQ!_=_#PXvC)jd8br^Y5h|BpwUSPOcW2>O#{vi$ili6k5U) z8!6PA3#F5*hiUPq5>P*9#8N&bNp@Obx35wAStuv^7BxkH!W$tKT7U*+tFC{BW)Vxl zWl&#PRpr_TSnsAh{|S%@-i_|`YnR#VxU09KG+NaR*+RwIe!YosDWBiC53q!gMe2!o z$P=}Y&XE#8!~RH3Ka25=LdWxB0bbJ8Bev`ypurye>jut@Xto9cs7KU}@?RzK9RP3^ zEM#FkF7`yZrot@_J%_Woc= z(QvfjC*k5r%}9^n7&>UFr@9^aGgBm69q%m{HLG`-m!Yeh_3~yfX}Hu_!j}y5sBrV0 zE4o;P>j$Um;w*x)GL8b`uAc90`afr5L@=Um6~JxDLbxrIpk-=~rF&)2hpoFTKt+{Q zEFcC7t494j6N;EzmnI_jMA}?$y6c8fRdVmP+cA8>XbmmcaA0%IHC+Sd*Y<_X8v$yq%3I-lp7Twg#b?`gw7@Tq-LAYqIDoL}Ie3 z0<~UouO!TKe}8cg%R?-^(E7?U?oRZg9!)d>NbVv0fm}k*A9?pc#)uVLFFSk?iv}A$ zI7$1PCXZ@@kUF9Bk;fes(i0?TrjGBRC3u36-;;eT+&m_`y1*=4K_vF$0uzy;g!_wKpc!z=cS_+O;C=I%%X`xy zNw936cRgocYez;#M$7&(*>P9BJl}-f3rsg*nn&eZ07|41IhfofaSZv((X;ZILJtQ~ zgBMpoQ}yepZ?P?Ig-dUOu0{qBbq^nUvS&s&4k-;xB8i!Ue6V>V@d5z5JeG~-JM zlg!i&irH=Yn$;6V5Rdba6N+QptlOH?{V|axXfFSTZ#o2&$VV$cEHXijw5cz?Ccxn= zxK6kM+UR=TMQ;VmY}%bvrWy^J5s37DXT>F4#A5I#d#<<4BmXbT**nd zVZms@r?=lULY8Fn{tG70_+$T{-V9Gpc&>CXMFq)DPCCJ#NyhU0l*U4`HU$7q zRlc)#`{*c12rLJI1?OQ%kLASu&Dq=$yvBAc%LFa05^G9(Yub_MMOWsTwdH_K5tRx1Ayc2Ra_dfh9x z|BRx0?eX>Z-|?R46Q2sp&ztxYrn5hL@$Z`uv!7G7hp9?k0fvguFPp^tN~nL~?_!Pt zM&()LsuA930{$<>Q7qlO?+Rj4QRrRKc=6tPsq`o0iTbb9ne2(!BB<9e7SMTv4{1Ub;CgRKU>lrli@*j)^$MXG z7U!$>ic8Q`>0>?^q6GHNj49()Ow2BkGG>{6v!uObNaQg3gye|;QLmoTZ*yayVCMi# z$j!vR&D((4)37l>XTOE^+VA?et9~6As;B9v2x!D{Gtjw5r=A;engf~3clRA>9LV%) zhV)Q60M+i#VL~Z!BgL;7E7||75tG5xeY2B|^@wy5Rm!xdqLuoFrL=Ys`@okzS9&{{ zB{;htIC@nKygl;l;6-590L;xo_O)ss;Opt_}XNxNK96VdDa0$xY3Ko^T<& zykAnNklQqO?csAhQ*VO?(I(T&=JJ%NRMPC8-faW*jMOCW2A<)}#j}9h7uB=agBbj0 zP#cxVzh7zc*6LtQa|H)+hoAD;!@UkTWk1BNk9+oY@C}yMd?9Em&oAnnaW^R*x83v5 zX$itbvSqpyY-RvUU{+G9s0`10MxNM%OU_x1sw_o2`@WCvJtw~A*j-V(nV?oC38Ir% z(hbh|2F{FCp5agdL$Q304z=?Dr{(v7)V3bKoTzG*=lcW5L4t>8{MejjMk-Q0cdS*g z;=kAA2n~`3r96HuRP)dRV-Q4H%WU_b4bgK%aLx;T*Y0|QqI|LReT}V=prP1~&HHuy zmGq{U9>yr#AH&P=EnHlgx<6*gfUz_!Pq1P?^BCYARPumJeeOT(jvvoezRIYJ0Yl9$ zsuzP7*m6geaKfsYxyf26ys*e_@r=inW}Ly0g30oFu_=KZ%+z-Q3YAP;D9UM-1`Q5p z01BPPeKkZ-ohF3-g3WUedwXufrlvW)i@?dkB~F$HnRC}1Kk>&1l|C0>^d2c`od%%H z<6P^1pVgkok2yqdIbdTzfDH9#_zptNDRIEs)PE>=M$?;K81_@COu!~MxldN?_ngMe zN&N}kwB8#+q8+!AacnO(_H$r6l7!YNjMQJ5-qOeb!a@r+Wo|Nj%71?(iF5kRN6-*}z5gnQ7F=9hgo+6PHEBEO$#$FP(R}(rKGjcGLi8C7`V&!*7Ht>Yi@F>0A_3|L4!2vPPf@4jiki4%iin zj+uTWJ_(%t#I-v3VHC=^(59DeQ-%Faj82wHMssmO;W%K(WpCEY;KyD%DL_f-3o6iq zGmGvp0M%Z2C6(HoUV~@0V0%oLl4!SXDVmpQhyn_Rlx#5;K5Sh~OU?i|R$}UvnY8m& z*JCe8hu5zi=;&!0N*!N_&$wM<<*(^O1IUx~lDCP4jz~}wyBsQHKbe9Pd6Y5J{&b@y zdZ5`98gK}3>;x4Z#jbzJ<4pZ}IYv_ySN%ojw}IwmCUAq7GHi5;Q5ZYM?8QunI|C~} z_i9n!z8^Q3t#@A#i+_}R31<%GChc4^ul4#53#P83p!!XuFx=}|Xo?qQn#N^J^%qtv z^AcHJG?>;}u~K%S!M|^98`B|FE8m#7fa$Qm1h^!BB;&6-bDI~}Uo7yY39^d9c{e@D zvDbT*IxD_&u>4S#zIbU9hplFo3QWK$o3P>?c$r>w!L0IWYOXHVH1W_=85WxJo02baXcWD6oE;(`ZDevQJ*- z@Hv~wElzY3>U-m}S$%sW1`e;cr?!9|Xc@M#>1UBo7m~N$kmNApcwug!(G!e(G(WSM zlnd;UZPFO@Y-k$82NRAKzRlox7QY21oFqJJo!{*j*9Z1$P}|<=T~Bt?^Z4|HxEYHX zpJm5`0{Mub{sL)wG_n=~(GMxf-?C4ye%OcjzlyzNuvZjN&!y%ej`R)}cA_dBcVSxN zDOIGAr%e?<$xjV*_zQcJ)=ZiBsWE-K#w-N=Vd2*T1`1=6c4$o#<}JXRn1yw73SByKz1qq)e#9&Wu}E3Y8U)YuH~(1_wV$ z5&rK3$6+7At@7pwaC`rkn9#37K#hp%)(I1K;!vQq$qZV=CLMMbnD_T%RBkVu(qyqcleeSEj!yut;AmJAC-sFl9sa%sclu0H3^c*!_0 z2Rfmp9AL>wId-+ZH1tJV(agJ)3VNB#k3 zX|)joBi>rnqcOA5)hWlG_%Wf<_ktt6|vMTXTjmX`Qm4&WViZrlewC?9Q@x-cd>TT ze?(ZaO2~)wsXzjE{dom)qPnA|!zOXDVD(JJ$&|`zjD?o#fbeVX4%tn3<@C3;nQJGs zyYC5r-?vQ@XGENK?Pbg_W4j2%xjxzEO<#GEqbJyYa3|{_fXt)}>kFiB*WKFQN+kT? z5WMg%ZVMCTTo5Yz`2b!IAY(tbXdRvG)v}We>C>{)&(bKNX2Wq$2PJ;m<;{eL53n;u zi-c86hhm}^*_6=`sV`b-H$MD0p(9~Ghse_Z`oCHLC1VTrI#ZxI;%QW%tC<)2gD6rJ zMEF{7>UXUaomb`D4U?9ri+6rGcK8n)fF`x_MsPxg_w?D2IRZsBPGX#NS2cAaC}g76 zRdEPXb}yZ89#SR$mUo&u^^!v$RgRJ1m*1L%-bG0r=bcqG(+hu=CpVl(jn(vbEe1?h z^*p<^MzaacEzZRKox-|mQt)siV@j+es!PR^_8jgSF`o>laBuH`gqeBORDv@;)qmJ) zM`5vCJy-YCK~pLaXjw^xzc9}5z$aCqmCwnm-20KDq@j~{^dTCq1b2|=1 z771eY#|=1su8VRT&RskUt8;;6B^X^sCaTAS6^vSVrh@-WZX~V1I%;ivC$ot4$|cuf zg+rzG43@}B+tg(>DnC@*lA%!OSW9upyD|5nZVZBXwcA3-T$ zR}az7hW<$i=85`_Sck2X$hcHmDk+S}E((Lz3y~{yOp3Xv0%w+fiib0IJt!>+j4Ig- zI{e61;KMENP59HBrfSJT=cfNgEj;DC6##vrDOLdJQ#{>?a(Xi>sS(u;SjvUB#^1GA zTqBer!m2Nkh;)UEf!!jg?Xnd2iV}fWF$P$?bSY>+_==2U7cggB5#r&U`|G{&9%t0r zd1E4HQ|yESwb_9yH{$;9f&hSx>$bukRkA)|6G0*kM2!TkI_EUP9qI~YY+2By=85ws zfm;3L4iWo#n5nycj@G{uoqa^lsl>ivmeS6i^5zK*mOLOCogO_U+4_Q`!?~%cy8l@-fTRKw z;=;(`T~qX6%&7ms!nGOIu8z;t6wE7$W)~&N@nzyZ41AReOmO}@K~@l1y)i)2`HrNM z$#N2`04juION`Nt5=<(PvnFu%ifJD<>6HuB6@(wf!j(khhS>b&^xoWbe&Qg>`KJ(~ zL?g~U5K!lF*SnqO*YU=87?eY!8|^LVs4PH(O#c*tHShrD_RqZF#HO5Xoph;3w$41K zC*~0PFbbER)jh3DLGi4g4h|0G=fW^+vEc=dX><#gz<(WztHY(|<@wf)Dp7EAV2d(( z$_Nmtq4qEm&;*drCK!Ylgnt-ZnYZCY_J=0e(FD2(KLbI5bnzPN0+2Az-#mLnE;Yy0 zJ!S6--ogL)vX|}GYau`dmI%;gT}sr))+uF4CBQ8V&{BM6Z~phaou7{`s`$DmT9}3c zJvDyK8-l58>=VNYp^ACtzAjg);mMLzg1!TTmsUhH47oLF?VyPgFh!(F_SLMeu6BO@ z{28}P>FaWj)PJ%69@E76f4b2a$rv%xv^MyGqqY;slJvZL*4U^QjuCYH&dg(;o5)I{ zpE}XJmJ#;ZC<>lsNvzMyDBf{dyqJ%p!kDjVut~og__2x~Heyvy3{X(nj=({$kxTDU zG9JyKf=Sn zAXaUT|HFXIgA2T8?Fzv;>n7{PZ0ufIUtf;{Y}U9;#1Z=cF6rFNF!1k^8xzC}UyA=- zvhw?_v7^qP7eh+?0dM( z@V;uKr6f!C$t~ZAkCq9i6m%;Sv2K{XG2)UflmtG3pQ0J5qbmQstMT6-B|0^Ie}8uw zbg7LnfTFCiux>i=7Tf`_*quroLi`7XUqo3!0I`}jWV#X0t7s2rlL(}YVve{@P>C1h zo5dP;@2KGg_$Y?ral*bF>7t+LFxlY5%|ZKF0RP%UK)wO`_Bm|7&5sEmD`)fqQ4G-w zpy?>As+B}GnvF9C^AF#9;0tycA!=$*_q>co)ojl&e-Qt;d2iw%aZja?G3yePxO0F;`$5o5aQP>hn8g415Lwq+IAv_ z+L%IB3p&QXL<(h!$MZS@JgheXY@o0iS8$$xPR*7s?Bs=%=o~ra_Id3wqj0@;b@_rj zRPPNJqz43XsYt1IUtdp?XMpL3E9670y>+TOQy#E(2L-??;LbYU#y=3-oz&y`zGIj zW!193lyziddBja9T zYUGAj<gw4J}KfihD4du`$XX8cRl|6Ut{N87T$qAuYHoI7<7aw`HXl%bg0rJXB*oy9kY z3d(7M_$tX-24jLPWf8cL3#)iE<4z8ho*9SG-lkEwSX^RNe z=RijWRGXgPtoQYK*{xSBjtv}t)}CRDuJ8PHxJOPf@*{WbCzJsH9W!QqKv`z!dN_%e zmcKZLwl2xbNKDJ!as4+^L_TOG^;*Gz#^?i1U@zwj{$F_j?6|yEKF+g;)95Lo>rlH1 z&CJgJTs8XV;QarHRtHbLB+H_pzS0WOJRkjgM-&K@5M7x_=N&?dmmmv&9z$IY4T86m z)3;0h4PaU^3E7XSA7sh@d*AA$zIOgPeq(3v=kvHaMN5{~k~z#693?zoZEHt`_mM(j|qVLF3|nXf|{vGvz2|K{aQpfrc$-z|Frk!|4@hT+whFB zjGeND86suhLSis>*|KNLzLPyoO3jcZYh(?D>|6FN`yLWwrK;6YVK8F z6eUH<7ssR!lot5QzWUMQy4zJlK+*nYBnZl4|p3Ljayy#Piz9HP7}jQa(RMWz;XxO4+`TXqW=97yoz@$RU1#$zLLK zuk5?N|5)<-8n%(;-ZVEiNA+cStH)jyu923;*LT`~2!j#e6SIpTOT}i;wU9#pjmQnup6^Cg0?K zV5*tOkQWyZQeWvjqYW+?bTw;bOOpJhv{j4?eeAF=Ng@C9)7*>RqNTvG;&P>uT)F}Y zlc2X!h+sr*it_27gA?GJebxfs;+Ji!!VjeT&RTT!!F3S&O|#-6F4KHAyCiE!vu1_v^Knl$L~8}GBE}7kXVI5x{hjE{s zJ_})2N|ASyAXE-)8<#Jv^sRgs&L8tT&W|JtTcaIT&yvV4VM)is=o7yXdrxXoFWkcfg8n4ex z+q|qtHf(Rd8D)F$e@}Zj*AkxUiP({z><2ML5fmPzm>?vy?q$4>I9OazM;FE+aQ$PRb4Lx!#Loml*Y)F>AIbzf#boA z!%EhR9C8dq;fw5 zc!4HGot(*@6aTj55t)t$%QEAH#Ak5@nlUdXvcF433de=JS;6Zuy7Ul&Z`V z6sg8t&IHr0CqRT#hcaq%&r+<`u!`KMevj#+zcSzN$jh94R`3UJOkb}PeY}-087lDp zrSk_k3h{hg<1c!{)EuB>r*cZT2mli0HBkVky|^ugP+)C~^9jVsk}0I@KaXRXA|)JC zdBJyGtiVFA2$9)JtNKq(OTIh&t2Ka9MO9Kxck=9n0=V;cNAN?Y zmJ&_|c(m?TKYClm5NV0(U6Y)b{!!F~Ddjb}q8`(xn10KZ)TYQ7_${J#(Gon@XHfEd ziw$0+9-ghPVhxLLdSt22>nLITGJu4_NK;ayjIJql^RJOHW$VNv2Qp({DtYNpPIeoYUFTnv~5u z8!bebq>dX}1O8iz^&_htcFPCEtOBLAw_mgc7B5FD9#_` zeH#XWruxyw4-Pio%~?ARPKP*3Nrz@ni0QLTr5N&0@Oitxnr_T?Rwylln{B3cs6my~ zBJB=um~d9IfF&zUO-_BlV)K9P#VGyRCVaJ_7b={LLegEQGG*%ojYAmKSxE;`$`G_0 z?%QRQzy?&{*VU1rAO#>gpYhr8a!a3?_)C4<^HZlljmlnI1@8tjrFp;XhAS`sMPK^n zV7UUy!)_BlfFi?1Zvu*V`CGGOHs5rFBdg~th6l{uYx6Q>Ne;w3Cy{ah60`)+V5!aQ+T{W2XtKg8!PEehlaOKeP;lzz;>2qN7r#mvr&U?M|Dv!;>_@m%wF-uhChIT@|Rc*?b53lc>c6Xz@qSMT%5MTj*?SvoJ=z!$W z_=2YHW!556=W&7He4`N28ahxOdX!YzHoavupf5p;p;jnm2iBkvg~hSF{{zm64gC&6 zH!1p==DXh2YW2h})5h8!o{3(FFESRvC(@lDJ@6}$2I2E{5lLlGBn$bo)B6S9%>j>e zFVUw>aOYgB<;$N5C<_zE$fOMLt6BS${)r+?HC^ZWTsWv3ibZSFS(u!s5^26-x1MVP z9+*s%+oOFWXi{^-oFkD#Zz)Dz)JxNP=e?ihf)86Gy7!(fu#I)71$lx11WDun2+FwK zJUP>gV+iE0t!CStoi^lE9v~+m*K7u79*U); z;7i|WZM~^V;)>R=ytX!G*=ivpd6jheIm_$SBQt1@fK1RDS#H0(1DvFqf>=H4-T#L9 z(0}r4Z^$1cXHfV`)dyRxXJcW-phLtFH=atc5#df+Gh*CU9DKVgGnNbbjpj4^F#-CI z(qw+5rg3S&o{}=tYh?z$blq++wc`}>Stm4!PEIq z1Sszun7!AEmKLl@k$RAo#1hMSD1?!7i@W_%cO{sk1H`fcwPi*%#BdD`UMQ?+-l+ed|I<{sz6=H83P8 z3-z)jsLpBB1W%a8qybIwi?1uqelKcLMq+t61#2l=(rWvYGBz|}kMR&~?xr*Sjy6|U zYedvfKX{c&8y6{tx!o0)zd<{M(ml$qWT2A_47cyAy7+)LK`z}kUtS!ON{g2L;Ah%4 zXPve2yNAxN*y15cTnKQNIGDJIr%Je960L}5!LlB6aW4n}FMBD?+DxBNre;Tm*c7T* z_FHcAGrR_Kitq7+Zsy;1yxtaid|Jm@E>Y7{R78QiA0IhBnmqRc5*NN~&rbpjWi8YQ z$!}(@*6P33Zf|?S`xrB5a?wMog%f8s<>gQ)l)SWbpsM9H+R%)3w@Gv#-s{p?Q%&Opp`}vRB4B@yRkJ;$(|#_z`QT|q^wSR;ze>MBlSHL0rLz_w zh$D_mFALPPixyxfCnr=&W}9{sfLP*ss;ftpQVRCRHEz~qPX!qx?$w&h_WmaFP=&f@ zn*5U9MNAH=)~j+JinBfDCF#D~{vI05EkDcOl)Fb)@kO?o_?%?bWZT3=9Y)fepOD8G(_-8zQ#3uUi$AjC zF$L1t_PllSm7A=_XsQoqft6weL&-UM_yr2ge0mY*&bDRl%*6M&LF$(P!Xjrq{>Wjr zDL0*6cTR}$480j?ZOk^ib(@{M^E~<;%^}EhL~Wp0>kKw#kT0YmN4ClKHDdH@BH7?5 zwMv2VFgZ;6j*9z!DJ4^HU!C*XET(v%eD63lV1nLk)BPqXc`}UA0+5VTmq}oR2QTRa zcX0!tJ*l-2@@xL|mh3oZZ%F(+?0)19m!B0ZsXH?fn5-0a^WVIcknq-tboh^Yi*xEE zlJF6CQH_+XDH2}q^EYu@^@#tR&frhYB!V7KP+4a(`yi@(=;2S=wgFe9>-s)L++eDG z35qcv`!4*kOoW2yOlgm_+^ow?$bGzg5GbFI?L_a^dGP9xuE`EulXvup$N5vrWEGj! z!q|p`rKP2p%+a|g>V-Wy!BZZN`%DDK=QheCcWzy!0R{k`9GrhC z&p4SxJ6>K`xE5dhcWu|t(5Ub=SM}Awh#$rmZ_dgn$Vp{pIm8u-UI*5B3c&>$Z=A=6 z`Ti82sIIk}|DOz(Q5s&(OiI!*Ls>M+f3^skD<6*?$N|NWTn;VZAzJt?Hh7BA^nF(X z6Kt~81tchlAeXY5b#h32D99BT&1K}Z)44Y__~vDON;vN>+{Cl&Z!C$s5?WxV%IsDQ zfUqHbu7Isq)uepE3I%L&&*i;@ofC`OAk8rtN<=Ij?Ji6;1IP%N#OlDr^Mrf`DjzKF5BoQlXr?=ZYzH7@Nio@*+^z{EBg6&6f`EG{&*&n)RQE4evavw|OS|zDD+ae_b49a6v zKhjLQn&`~EowY2R)lNvE5fQrbvN>zB5<`Pg<{XpJk=Pa>+fDddsQA3(^1eEc44bE<@_VWyGn6_b`zKR5JAHSj!` zateiS*U;{m^A9%vf`&Lmrlp!E+wyYikUDJ_0xdO4h45$(2O;cG%0L{u9~$lX&p-Y* zj`4n;h)(hD!l{zkbU2m@rm&l!(C@@V5j=n7-BVp#@mZSfoOo1UNy{@%k;`_@uGwE} z%PZdI8CYH`*`!Y%ilNjcRdoVJa4== z#p1D+C}A)8qRUb;4lbe;0UT24zUn^OJfg_#-o@`^j1P;c6zjc4vB`{@0dENX0{ddc`PfU1wXL!s&(Vp zb6`r@_4Rn?)eD{rM>Cfn`pn#*-}uWiy2{Lq$=sA_4PID>B?)5_wXlgwgPtJmpbquc zV3|ZSlae>MZtBGm6PW5bu`0sXO*Pbz3HejsKfoe3(vEoNu=f)loYYZ&r^~NrVWZcy z;7W8Tb{d&fB!**Mq`V>$qj&Lnnx_5`CWOsHZ%y8 zAA%{}ex1KIvdj2xF|H~NbFL~@KR-DmJd!I1p+#NvIS5%ny);*Qok5G7vC_X~NC)f5 z6l-DT@EncsYm|KLTsj*Ygw>o0Gn9KuK|nib8$_Egc`;mIwZLcn^wNFd9uA)6u}8+~ z%G;dqQIGShHFq`AIzgPfMVgcOTlx3GJKyNv0u!SC-^c7N>CG?EEy_;op4Ts6Pnfb1 zhGVc-)0`PTiE@V0=`4Ho3}G=iDrnB(e2clwibsOGTdh!iA&sLz8v6|yqbyQ&m$fF` zA??j8_;R3Fcc>NZ+JF@f`|O;g)Ff3|Hkse0X1?f585iBEWAm|_pNEkGl=-m^xaf;r zoz{{^XNkO7+}jv3wN$w!H}mHEIk_+XoA2d!3Pu)FiQBgLvj}{Nmd+acH90vs>VYkt z(DpyWdMt0Ks4^H-_wEU;2I-U~o#0!OuTi%{W%E`QJw6wAo2(LRE?X14tjNm`0bpgM zHF^n{L8hq|6i<1I1brE`xHYow1-z1{ZZ^3vQJWi_upg=MNRkE61-r78oNF)po4@cJ4*6OuP(JBn%&*!}i zx6|Jm8JhsNAWLc-KYE{@96V{$)6-*sq<%BjxsOWaXV0=W@@^qxR8k`PQ|_q>CfD$c z{5t3J^^^t$pwM5tKMMOsvEY>Pod zhMzIfwMqkuT=s6*ZrUA1yjkt3c@>)W6kkoTG31<__dFP*?F+*jHZyyS=Ssr=6%87=!V+6O(D1E%FERpdHGjTDMvMSwOaTaS z#tj$d%zz^0oS50q1gti{;cIzr>`*ySF!>glEs|zwD)uToS2}o2=o1#jG{okk?#Rod zAeu0?-8`11g5)DKq$0IE`!j@5@m}OOxAwKca<9A7g2jM?2VA2`BDrz?V&E@P7fg<0 z@LKAx9W2)oXQGdxEG9wHz{cQP|JDp7TlX~}EAFN0($3=P^9$^LO6U+|EKwfXE_GMN zy47Kb3p&O)ZS?QjL02-2i}CVvbo5iX9qy*0mupbxI7;EedhtyRBfZg~H~=tepY+ZM zP}B&3%H!_=OQs z>Bp4dp}LYbON&zY=w;OoEDamO!ooVhAj6e$khbexkWuUW7DYpgR!#i`5j!t+VdP~`S9>u>Djdvta4>$QT<2*iSI+#| zv81Z|SSsC33=#a0TsSzEq(BjVVwR73ycKh0<&)PWdR3Cet;o;NvlK#eQSn%9 zTCDaZC?mK>=8MYynBDiCVfT2Wqaln%r(U3ObqC6p@^JA%GJ+#t&E5P0$Pk79(_nZ; z*}m?$?p2GUl1H_Hz}4onPzRy_hMyw+$F2FA{Iq&Zriq)W?QCpvNuS^Tgv$3{85zwO zeU`w3Bbfp6p@Iofv~~hwDcQ60ovxIXvRb70*8&f;44RWLCqDVqBd<<|Y1mq~8^)I8 zI+`J|0{>Q%%geZ9)97U$WHk5c`ALd7;|*Fwem5+S@6*}_t!BJ=$Kb~RheZ-+{;Z*TdslrkVm)R!EORfhXk$3ITz;jKa&yx;^m>=uQ zfzxQ9yPGxP!eGmS&u-Vxcf)uKg_tmT^0r|!=T=^b6d;; zgjx%2sC@aYx4lxy47-J+3OaGvn6~q-3GPeAtAAe(-UX(i5;?`X_5HeFwgxkJ4u+bw zT-fMYNQZ4~slI2qs7xL#Y2nFYNFxLzN_F$t4U+V47E_oYn|8lb=bA898-b1f`|W3z zwyfJbTfOnGC$78S+32mL$OTzS;V@(Xnu?kQ2DZ%fm2A&Clf63}R5wc}Mptv0&TGO< z%RvAzE%&`^NhZStN5zPDw6 zDT!OhYk%zSvvm}`0WkDQ*_=Fk&`d;Tk( zVZW2*6d@U-lc4WH_whcJ!OdPFz!?k^(GZ{PX7S?P9=RIH_vWPr3^bzIF`HvWdNJsD z!6}}HfA(`7#Bb52xUB!Nzq-}1I5=K1v|ZJTy+FsC2_N1q&I}c?txDs*4(Dar4{{m0 zDQOBUbB3zCrtKf6bf~2;u^u0F9(6>#YX3HZ>x*V&1c}MVO)#3|KdDKJ3r9(LS2)!I zbMp_=5A}r+;t6q-8n8E{)MPzAQb$^r)2KUQj9yUEiTRUTz3>_B)Ga}Vxg!$s7F0N6 zxk$oCvNI2n71}vuaqZAudGwpR=?8)i(Blja(~9k?F$`>UO7%_{FmP9**3(uI1qy1d z^u#qF)#N_JCLRSWnCCdlmu+&9vJf5hsMb&FG>vjyh|@ zzQ#agUIgjzm5MzTIf|Vd=v7w#cM1w$2b)OPPrpdpjea7h{y}X)WTU7DWLq798)N6l5D^f>2wqhR!J@AV za0rCU7SqIezY-8?nXZc3n+b51vd{|>@$sRbEO)#K7h_(Yp9q`*`8I61N7wW!}f!FlcLDMy6@s?p``=- z^WXC6#DvVHEUvM8>`PkS6ijD1GMh28a(2E|6a1bG;*_XIT@&07MO-KozinlJWVVn2 z$hj@JyEJq84Mq<*;j7;~v?3Q<4Gatfbzi%{T$^~i8`$@exGb@)JDp^N0P4S-y0iXC zym3^Yzht&7Q16Z@7C;!^$y(pGDiMMaA&fd~wT(BlcdCvPli-_zU3W###8HAe3)nFR z&TDLv$ugxc6p-o9@ur0gk?iU^K{M$UU8sdZZWI3Np9FJSKB)>}*cf_&*H=~k&n~xQ zX(l#$u8rwj9;UFCr2EkvG{|_H&zDgF^K1|r)(eS#rr_;UIH23O>|=g|{R3WA*XR=U z`Se7xOI7d1Nvrce`-1(GXd|pAKSd^ARfzH`%EI@3OTZFIfAsrs>bl=hG562swpWkt zSlrxZX?h?*#By8(>>d`!Zb%oSGcsZNY}<8=DNAc-Vh&?}z!54l8fIZW7RO zyfJ<%#-V?`Pc!+g)me|AcPqPnW;eYEs(F1-vlU@YppU!WN`+xwvo@g;p(Qkikv?YQ?VKmW|#glS3@)v+R5 zaaEcuPQpjj-SAJTq6p9a+tW?UZxU0m3>KP!IPfG(Qj<`=NBH&h*a_?x-}98Fs|R_z z=_Eh#$JS!mB&F|jpf~ke0!)C26V9)HROHwPe`03K$6eC|wT$R7>l8^^d8oOW-M7}Y zH|4pn$6OhQrE}O`7pk{SvkDcR%mY@|D?I4EvRB0RZcJibjMXtgKtC8z+!H}mj6N;?9>9WSsR=|*-i z;Sv8U7H&q5`5>m@0`v%#$hUB5fr=rrx=`N6kSQ?FtNnZD?=MSmNwqn0r1R&~OR-(H znLo4nSWNw_67#1!$W`1?h4$t+atC9x&t=lI6HkFaRv3^V6T)da_Mra3nNz;ToyvN+ zLY65U{s)yYagQmj=?-mY*`kOe#Y*a3ZHz(zU79UFD8_WE*s@@k?O=A&Qj7UQvk~%2 z)+S}sw&YpjR_z!m=Ga@RA){>m_e5nqMZ^A{!m@>CwAEE-QG}1y7g6mh77=h z6jd(xQ)Z7m)kI&r{5vwl-*{RKHW9E2Mml}We{r(we&VtP?gj**{yf}|Ou^D%grWdC zi8iB;8Q?E3UT@Q#L1)fHn0PM{0<##2wJhyOE<)e}`NgROnLA0AvbK3r`IzzvbBP(9Q7y|r)rBv}s2L6_v+@dEKmFGD<`XCxp6Jp?Ew0I7_I zfWWU-wD$%NoPs3M87%t#?GPr?LnSSt0b>v~D#gkxnSGQmU%^uoYx7>e&bAfbdDZpz z*x=6c-Or1Fd_~Eg6x4V%aPNu9&``3| zz5R!lW!o%}n83z~zXnvs89^xSUk~5qDbu;m7J7LxbbT@&On$+zt=`D=dTS)`TimTGOTB3+W7Y1A&nsnm7P( zf-wtO0M&*q)3L{X=OyOjy_Xz*ZF>3nX}1imrY$x5T^b!ycoHW@F)DP^9aWw4o2sI^ ziKdq4>&G_c&(>rNLO~NH&ui5j_|0u-Z_qemLltcV6n8d;v9QR!x*GqSp1Wk#Y5bs} zs7*;ll|(xBOT_Iv?^8Z}ICuM%;DqY>g-5C6!Tu4V57|Kt{olAvHObPze#GAxSF9l{ zN7enZ3XJsO+DFDEJG~AB5(ICR!+gdnN{Chbil($|e8+*gS$$Cr^(CTuQG3*mY}YWi zm7?^yUWmN)7w6IWdrOh#&Kzus`8VvtzLSe0wUb){S-26GAo7O5RpJHz1g?dFhEHHZ zbyEke9eEwI_1wdu4a5!SHW`U`1_hMiw&Ee}RCTr+dAhe0OQ&9gH)yDSGD!@O+6!CwH8HxO_odhSwlnb@`76a0ehi*C}>(~5l zs@9vs-)fnBGhDp^@d!FMDh+v}xzu#)vfFF@Mlzvv*ypQ|#2y$GLk{HZ*5j#?>t_k_ zCt&zvE^I!J*W}x&APn@Up`zD$K zTZjmqG=#?Nh*i$lmVNGZ>(=n8>{c}khO~|Xot{VBK8sqWNKwvG5^<}vT`CR9T8+!Y zFtQ$iUuIsr=i%%7<;(WAH8wtD0KyvwMYFB$_Aamq5F7lBbJ=3TSS%9sAe>*xf^%T= z9BQ$T2)8<6S3a(iwa%YuvxN@WkT588>~oXLr;^V3T1xFjX7Fr%1M8`N@e!#( zyNqG1yN~%G&f>4Y<=NDKFiGk4-Oy6S;ZNojo-Y}ZaOK>t(C^z{SVb%vVDU2Vauu6h z*q(rx;m+c)hTmTslAx!f%jxU-%%l@aiFJlIe0FC>g(iw}gRu@_8BSLrC^J#2fS}E4 zRx%{H{In2|lkkF|%yJW?8YF*)ns6B=U~>5_->VIHGpjY}(Sq?uDhl04s%%euEzxc4 zqLz~EswD_sF7GUrhY$F%Z+u3uJxbg-?NXYdp`m&>9YVEoP;&g}3B$c}4r^ra9&w0w zj%M-5Rc!JBg!^-HRUovTJHx*-IzIEr;S(>QDvXTelj(F=FhCKkGPoj<0^9{Jkf5B= z)^yQLp$u-01J})C^r+A}RHDdRxw*MwKkqO+ zxWdJj5?Yf#`*G0=lFs?wM)MM)e9elx|F!BRY@fU+nY1kGFDwy=E)}l$NSnqB&`FQATbTSoOn9P@Y8Y6{uNF_0rZPU>A?4H z?wJ`u9bjP3(hMaeN2T%8OG-s?Brsb_Rg+=clg3y$WW{0bwr!6dO00fm-#;2nEh6jQ zSLQUuMpSayWyBi7HyZtQm^>$o<7F;@C42Nw0zgla0BjP}2g06wQg5{vJSc*8JF`Is zL@m+Jh<^NNEq+lh0|@8g!{ad6gNqq@Vi2^}JMuuz z^~tYX!a{5Il?QGS?(;xgId0iTMyliJW7mjhaFMQ+a6*h6)w(b^HuB&ktcFxsTn6qo z#QcLW3^e8zMUOG#1Ufyzs>Wk_?ShdBqoi61y#i4YLm^r2VyVg?UHpC8;>+Q-Vvp2` zic6o=&}2h`50FFGESShRm#4TS%ZL19Y>Mu0Wa$WVmRgdw{Cs{|Vwxq41~cK@KFhdt zD}0KQm0bG$6s@jcsSL#vFrVQ4PCIsxYtyr&h0MhTBF*wHiruy}lm3&I@u~2vajkzU zhEL~!6dp+-B&I~7)mnxCPzBM6udGktWPEl{@vBWy7Q?a#~tAAc4 zwwd%-@g3X%n=vzllt%wJ&jb}l!xSKsFyrs@OegNHvXkZ?8}DPp*pE+H=ktDVod&}R zrP#*;H<{?oNYtDElGy8kw-{s9?u-JrEXl0f4-)d^=_%yevFepvQ?j({t>D`JXL|{B znqNUrI==noUbm3yh*rS$x`Q^-RT>)xY4<)3dnmsb|>z}UA}+* zDjmj(>;!MS!b`dYN3u&V6qEOB zu1rmxM-!ft{?MWK_Zb9AMh<5hriCZ{A%`&WqOlNME|xfN^;!c<`qMYEUVarQ1iXY7 z?Qn?~6*4Q%{O?8WSmN+?$bY?io*JH%+o<>NMZ6NcsBdQ>;B&ycL&$oa-S^J?|6lR{ du^V+Cl6Lo;i_-7*aR~T#P18`LTFoK+e*jFU_!j^G literal 0 HcmV?d00001 diff --git a/src/test/java/dev/relism/HttpServerConcurrencyTest.java b/src/test/java/dev/relism/HttpServerConcurrencyTest.java new file mode 100644 index 0000000..c503215 --- /dev/null +++ b/src/test/java/dev/relism/HttpServerConcurrencyTest.java @@ -0,0 +1,230 @@ +package dev.relism; + +import dev.relism.http.ContentType; +import dev.relism.models.Response; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpServerConcurrencyTest { + + private HttpServer server; + private int port; + private HttpClient httpClient; + + @BeforeEach + void setUp() throws Exception { + try (ServerSocket s = new ServerSocket(0)) { + port = s.getLocalPort(); + } + HttpServerConfiguration config = HttpServerConfiguration.builder() + .port(port) + .host("127.0.0.1") + .build(); + + server = new HttpServer(config); + server.get("/ping", (req, res) -> "pong"); + server.post("/echo", (req, res) -> { + byte[] body = req.getBody(); + return new Response(200, body, ContentType.TEXT_PLAIN); + }); + + server.start().get(5, TimeUnit.SECONDS); + + httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + @AfterEach + void tearDown() { + if (server != null) + server.stop(); + } + + // --- helpers --- + + private HttpResponse get(int targetPort, String path) throws Exception { + return httpClient.send( + HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + targetPort + path)) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse get(String path) throws Exception { + return get(port, path); + } + + private HttpResponse post(String path, String body) throws Exception { + return httpClient.send( + HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + path)) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + // --- concurrency --- + + @Test + void concurrent_getRequests_allReturn200() throws Exception { + int count = 20; + ExecutorService pool = Executors.newFixedThreadPool(count); + CountDownLatch ready = new CountDownLatch(count); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger successes = new AtomicInteger(); + List errors = new CopyOnWriteArrayList<>(); + + List responses = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < count; i++) { + pool.submit(() -> { + ready.countDown(); + try { + start.await(); + HttpResponse res = get("/ping"); + String summary = res.statusCode() + "|" + res.body(); + responses.add(summary); + if (res.statusCode() == 200 && "pong".equals(res.body())) { + successes.incrementAndGet(); + } + } catch (Exception e) { + errors.add(e); + } + }); + } + + ready.await(); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + + assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors); + assertEquals(count, successes.get(), () -> "Responses: " + responses); + } + + @Test + void concurrent_mixedRoutes_allReturn200() throws Exception { + int perMethod = 10; + int total = perMethod * 2; + ExecutorService pool = Executors.newFixedThreadPool(total); + CountDownLatch ready = new CountDownLatch(total); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger getSuccesses = new AtomicInteger(); + AtomicInteger postSuccesses = new AtomicInteger(); + List errors = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < perMethod; i++) { + pool.submit(() -> { + ready.countDown(); + try { + start.await(); + HttpResponse res = get("/ping"); + if (res.statusCode() == 200 && "pong".equals(res.body())) + getSuccesses.incrementAndGet(); + } catch (Exception e) { + errors.add(e); + } + }); + } + for (int i = 0; i < perMethod; i++) { + pool.submit(() -> { + ready.countDown(); + try { + start.await(); + HttpResponse res = post("/echo", "hello"); + if (res.statusCode() == 200 && "hello".equals(res.body())) + postSuccesses.incrementAndGet(); + } catch (Exception e) { + errors.add(e); + } + }); + } + + ready.await(); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + + assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors); + assertEquals(perMethod, getSuccesses.get()); + assertEquals(perMethod, postSuccesses.get()); + } + + /** + * Races the router's lazy-compile step: a fresh server with 10 registered routes + * is hit by threads simultaneously before any request has been processed, + * causing multiple threads to compete on the first compilation. + */ + @Test + void concurrent_lazyCompile_noRaceCondition() throws Exception { + int freshPort; + try (ServerSocket s = new ServerSocket(0)) { + freshPort = s.getLocalPort(); + } + + HttpServer freshServer = new HttpServer(HttpServerConfiguration.builder() + .port(freshPort) + .host("127.0.0.1") + .build()); + + for (int i = 0; i < 10; i++) { + final int idx = i; + freshServer.get("/route" + idx, (req, res) -> "handler" + idx); + } + + freshServer.start().get(5, TimeUnit.SECONDS); + + int count = 20; + ExecutorService pool = Executors.newFixedThreadPool(count); + CountDownLatch ready = new CountDownLatch(count); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger successes = new AtomicInteger(); + List errors = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < count; i++) { + final int routeIdx = i % 10; + pool.submit(() -> { + ready.countDown(); + try { + start.await(); + HttpResponse res = get(freshPort, "/route" + routeIdx); + if (res.statusCode() == 200 && ("handler" + routeIdx).equals(res.body())) { + successes.incrementAndGet(); + } + } catch (Exception e) { + errors.add(e); + } + }); + } + + ready.await(); + start.countDown(); + pool.shutdown(); + + try { + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors); + assertEquals(count, successes.get()); + } finally { + freshServer.stop(); + } + } +} diff --git a/src/test/java/dev/relism/HttpServerTest.java b/src/test/java/dev/relism/HttpServerTest.java new file mode 100644 index 0000000..c5c8d97 --- /dev/null +++ b/src/test/java/dev/relism/HttpServerTest.java @@ -0,0 +1,147 @@ +package dev.relism; + +import dev.relism.http.ContentType; +import dev.relism.models.Response; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpServerTest { + + private HttpServer server; + private int port; + + @BeforeEach + void setUp() throws Exception { + // Find a free ephemeral port + try (ServerSocket s = new ServerSocket(0)) { + port = s.getLocalPort(); + } + + HttpServerConfiguration config = HttpServerConfiguration.builder() + .port(port) + .host("127.0.0.1") + .build(); + + server = new HttpServer(config); + + // Setup some routes + server.get("/api/ping", (req, res) -> "pong"); + + server.post("/api/echo", (req, res) -> { + byte[] body = req.getBody(); + return new Response(201, body, ContentType.TEXT_PLAIN); // echo body & change status + }); + + server.get("/api/crash", (req, res) -> { + throw new RuntimeException("Simulated Crash"); + }); + + server.start().get(5, TimeUnit.SECONDS); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(); + } + } + + // --- raw socket helper --- + + private String sendRawRequest(String rawHttp) throws Exception { + try (Socket socket = new Socket("127.0.0.1", port); + OutputStream out = socket.getOutputStream(); + InputStream in = socket.getInputStream()) { + + out.write(rawHttp.getBytes(StandardCharsets.UTF_8)); + out.flush(); + + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + baos.write(buffer, 0, read); + } + return baos.toString(StandardCharsets.UTF_8); + } + } + + // --- E2E Tests --- + + @Test + void testGet_pingRoute_returns200AndStringBody() throws Exception { + String req = "GET /api/ping HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n"; + + String res = sendRawRequest(req); + + assertTrue(res.startsWith("HTTP/1.1 200 OK")); + assertTrue(res.contains("Content-Length: 4")); // "pong" + assertTrue(res.endsWith("pong")); + } + + @Test + void testPost_echoRoute_returns201AndEchoesBody() throws Exception { + String body = "Hello, Flash!"; + String req = "POST /api/echo HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Content-Length: " + body.length() + "\r\n" + + "\r\n" + + body; + + String res = sendRawRequest(req); + + assertTrue(res.startsWith("HTTP/1.1 201 Created")); + assertTrue(res.contains("Content-Length: " + body.length())); + assertTrue(res.endsWith(body)); + } + + @Test + void testNotFound_returns404Html() throws Exception { + String req = "GET /api/unknown HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n"; + + String res = sendRawRequest(req); + + assertTrue(res.startsWith("HTTP/1.1 404 Not Found")); + assertTrue(res.contains("404")); + assertTrue(res.contains("No route matched this request")); + } + + @Test + void testException_returns500Html() throws Exception { + String req = "GET /api/crash HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n"; + + String res = sendRawRequest(req); + + assertTrue(res.startsWith("HTTP/1.1 500 Internal Server Error")); + assertTrue(res.contains("500")); + assertTrue(res.contains("Simulated Crash")); + } + + @Test + void testRoot_returns404_noExceptionTossed() throws Exception { + String req = "GET / HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n"; + + String res = sendRawRequest(req); + + assertTrue(res.startsWith("HTTP/1.1 404 Not Found")); + assertTrue(res.contains("No route matched this request.")); + } +} diff --git a/src/test/java/dev/relism/RequestParserTest.java b/src/test/java/dev/relism/RequestParserTest.java new file mode 100644 index 0000000..846221e --- /dev/null +++ b/src/test/java/dev/relism/RequestParserTest.java @@ -0,0 +1,127 @@ +package dev.relism; + +import dev.relism.models.Request; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class RequestParserTest { + + // --- helpers --- + + private static Request parse(String raw) throws IOException { + byte[] bytes = raw.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8); + return RequestParser.parse(new ByteArrayInputStream(bytes)); + } + + private static String req(String requestLine, String... headers) { + StringBuilder sb = new StringBuilder(requestLine).append("\n"); + for (String h : headers) sb.append(h).append("\n"); + return sb.append("\n").toString(); + } + + // --- request line --- + + @Test + void path_withoutQueryString() throws IOException { + Request r = parse(req("GET /hello HTTP/1.1", "Host: localhost")); + assertEquals("/hello", r.getRequestLine().getPath().toString()); + assertNull(r.getRequestLine().getQuery()); + } + + @Test + void path_splitsAtQuestionMark() throws IOException { + Request r = parse(req("GET /hello?foo=bar&baz=qux HTTP/1.1", "Host: localhost")); + assertEquals("/hello", r.getRequestLine().getPath().toString()); + assertEquals("foo=bar&baz=qux", r.getRequestLine().getQuery().toString()); + } + + @Test + void queryParam_resolvedFromPath() throws IOException { + Request r = parse(req("GET /search?q=flash&page=2 HTTP/1.1", "Host: localhost")); + assertEquals("flash", r.getQueryParam("q")); + assertEquals("2", r.getQueryParam("page")); + } + + // --- headers --- + + @Test + void headers_parsed() throws IOException { + Request r = parse(req("GET / HTTP/1.1", "Host: example.com", "Accept: application/json")); + assertEquals("example.com", r.getHeader("Host")); + assertEquals("application/json", r.getHeader("Accept")); + } + + @Test + void headers_caseInsensitive() throws IOException { + Request r = parse(req("GET / HTTP/1.1", "Content-Type: text/plain")); + assertEquals("text/plain", r.getHeader("content-type")); + assertEquals("text/plain", r.getHeader("CONTENT-TYPE")); + } + + // --- body --- + + @Test + void body_parsed() throws IOException { + String body = "hello body"; + String raw = "POST / HTTP/1.1\r\nContent-Length: " + body.length() + "\r\n\r\n" + body; + Request r = RequestParser.parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8))); + assertNotNull(r); + assertEquals(body, new String(r.getBody(), StandardCharsets.UTF_8)); + } + + @Test + void body_emptyWhenNoContentLength() throws IOException { + Request r = parse(req("GET / HTTP/1.1", "Host: localhost")); + assertEquals(0, r.getBody().length); + } + + // --- edge cases / robustness --- + + @Test + void emptyInputStream_returnsNull() throws IOException { + assertNull(RequestParser.parse(new ByteArrayInputStream(new byte[0]))); + } + + @Test + void missingHeaderTerminator_throwsIOException() { + // Valid request line but stream ends before \r\n\r\n + byte[] raw = "GET / HTTP/1.1\r\nHost: localhost\r\n".getBytes(StandardCharsets.UTF_8); + assertThrows(IOException.class, () -> RequestParser.parse(new ByteArrayInputStream(raw))); + } + + @Test + void unknownHttpMethod_throwsIOException() { + assertThrows(IOException.class, () -> parse(req("BREW /coffee HTTP/1.1", "Host: localhost"))); + } + + @Test + void requestLine_noProtocol_throwsIOException() { + // No space after path — parser cannot find protocol boundary + assertThrows(IOException.class, () -> parse(req("GET /noproto"))); + } + + @Test + void headersOverBufferSize_throwsIOException() { + // 9 KB of data with no \r\n\r\n exhausts the 8 KB buffer + byte[] giant = new byte[9000]; + Arrays.fill(giant, (byte) 'A'); + assertThrows(IOException.class, () -> RequestParser.parse(new ByteArrayInputStream(giant))); + } + + @Test + void contentLength_largerThanBody_readsPartial() throws IOException { + // Content-Length claims 50 but stream ends after 5 bytes + String body = "hello"; + String raw = "POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\n" + body; + Request r = RequestParser.parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8))); + assertNotNull(r); + assertEquals(50, r.getBody().length); + assertEquals(body, new String(r.getBody(), 0, body.length(), StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/dev/relism/http/ContentTypeTest.java b/src/test/java/dev/relism/http/ContentTypeTest.java new file mode 100644 index 0000000..fe5620a --- /dev/null +++ b/src/test/java/dev/relism/http/ContentTypeTest.java @@ -0,0 +1,27 @@ +package dev.relism.http; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ContentTypeTest { + + // --- getBytes --- + + @Test + void getBytes_validType() { + assertArrayEquals("text/plain".getBytes(StandardCharsets.UTF_8), ContentType.TEXT_PLAIN.getBytes()); + assertArrayEquals("application/json".getBytes(StandardCharsets.UTF_8), ContentType.JSON.getBytes()); + assertArrayEquals("image/png".getBytes(StandardCharsets.UTF_8), ContentType.IMAGE_PNG.getBytes()); + } + + @Test + void getBytes_instancesAreNotNull() { + for (ContentType ct : ContentType.values()) { + assertNotNull(ct.getBytes()); + assertTrue(ct.getBytes().length > 0); + } + } +} diff --git a/src/test/java/dev/relism/http/HttpMethodTest.java b/src/test/java/dev/relism/http/HttpMethodTest.java new file mode 100644 index 0000000..c65b891 --- /dev/null +++ b/src/test/java/dev/relism/http/HttpMethodTest.java @@ -0,0 +1,69 @@ +package dev.relism.http; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpMethodTest { + + // --- helpers --- + + private static HttpMethod parse(String raw) { + byte[] b = raw.getBytes(StandardCharsets.UTF_8); + return HttpMethod.fromBytes(b, 0, b.length); + } + + private static HttpMethod parse(String raw, int off, int len) { + byte[] b = raw.getBytes(StandardCharsets.UTF_8); + return HttpMethod.fromBytes(b, off, len); + } + + // --- fromBytes --- + + @Test + void fromBytes_validMethods() { + assertEquals(HttpMethod.GET, parse("GET")); + assertEquals(HttpMethod.POST, parse("POST")); + assertEquals(HttpMethod.PUT, parse("PUT")); + assertEquals(HttpMethod.DELETE, parse("DELETE")); + assertEquals(HttpMethod.PATCH, parse("PATCH")); + assertEquals(HttpMethod.OPTIONS, parse("OPTIONS")); + assertEquals(HttpMethod.HEAD, parse("HEAD")); + assertEquals(HttpMethod.TRACE, parse("TRACE")); + assertEquals(HttpMethod.CONNECT, parse("CONNECT")); + assertEquals(HttpMethod.PURGE, parse("PURGE")); + } + + @Test + void fromBytes_withOffsetAndLength() { + assertEquals(HttpMethod.POST, parse("XXXPOSTYYY", 3, 4)); + assertEquals(HttpMethod.GET, parse(" GET ", 1, 3)); + } + + @Test + void fromBytes_invalidMethods_returnsNull() { + assertNull(parse("INVALID")); + assertNull(parse("GE")); // Too short + assertNull(parse("GETT")); // Too long + assertNull(parse("posT")); // Case sensitive + assertNull(parse("")); // Empty + } + + // --- bytes --- + + @Test + void getBytes_matchesName() { + assertArrayEquals("GET".getBytes(StandardCharsets.UTF_8), HttpMethod.GET.getBytes()); + assertArrayEquals("POST".getBytes(StandardCharsets.UTF_8), HttpMethod.POST.getBytes()); + } + + // --- toString --- + + @Test + void toString_matchesName() { + assertEquals("GET", HttpMethod.GET.toString()); + assertEquals("DELETE", HttpMethod.DELETE.toString()); + } +} diff --git a/src/test/java/dev/relism/http/HttpStatusTest.java b/src/test/java/dev/relism/http/HttpStatusTest.java new file mode 100644 index 0000000..191cf90 --- /dev/null +++ b/src/test/java/dev/relism/http/HttpStatusTest.java @@ -0,0 +1,34 @@ +package dev.relism.http; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpStatusTest { + + // --- bytesForCode --- + + @Test + void bytesForCode_validCodes() { + assertArrayEquals("200 OK".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(200)); + assertArrayEquals("404 Not Found".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(404)); + assertArrayEquals("500 Internal Server Error".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(500)); + } + + @Test + void bytesForCode_unknownCode_returnsNull() { + assertNull(HttpStatus.bytesForCode(999)); + assertNull(HttpStatus.bytesForCode(0)); + assertNull(HttpStatus.bytesForCode(2000)); + } + + @Test + void bytesForCode_allEnumsPresentInIndex() { + // Let's just double check a handful of other representations to ensure full mapping + assertArrayEquals("100 Continue".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(100)); + assertArrayEquals("301 Moved Permanently".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(301)); + assertArrayEquals("400 Bad Request".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(400)); + } +} diff --git a/src/test/java/dev/relism/models/HeaderMapTest.java b/src/test/java/dev/relism/models/HeaderMapTest.java new file mode 100644 index 0000000..cfd0607 --- /dev/null +++ b/src/test/java/dev/relism/models/HeaderMapTest.java @@ -0,0 +1,105 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class HeaderMapTest { + + // --- helpers --- + + private static HeaderMap parse(String raw, String... headers) { + StringBuilder sb = new StringBuilder(); + for (String h : headers) sb.append(h).append("\r\n"); + byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8); + HeaderMap map = new HeaderMap(buffer); + + int current = 0; + for (int i = 0; i < headers.length; i++) { + int colon = sb.indexOf(":", current); + int lineEnd = sb.indexOf("\r\n", current); + int valueStart = colon + 1; + while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++; + + map.add(current, colon - current, valueStart, lineEnd - valueStart); + current = lineEnd + 2; + } + return map; + } + + // --- getFirst --- + + @Test + void getFirst_existingHeader() { + HeaderMap map = parse("", "Host: localhost", "Accept: text/plain"); + assertEquals("localhost", map.getFirst("Host")); + assertEquals("text/plain", map.getFirst("Accept")); + } + + @Test + void getFirst_caseInsensitive() { + HeaderMap map = parse("", "ConteNT-tYPe: application/json"); + assertEquals("application/json", map.getFirst("content-type")); + assertEquals("application/json", map.getFirst("CONTENT-TYPE")); + } + + @Test + void getFirst_missingHeader_returnsNull() { + HeaderMap map = parse("", "Host: localhost"); + assertNull(map.getFirst("Accept")); + } + + // --- getAll --- + + @Test + void getAll_multipleValuesByName() { + HeaderMap map = parse("", "Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2"); + assertEquals(List.of("a=1", "b=2"), map.getAll("Cookie")); + } + + @Test + void getAll_missingHeader_returnsEmptyList() { + HeaderMap map = parse("", "Host: localhost"); + assertTrue(map.getAll("Cookie").isEmpty()); + } + + @Test + void getAll_returnsAllHeaders() { + HeaderMap map = parse("", "A: 1", "B: 2"); + assertEquals(List.of("1", "2"), map.getAll()); + } + + // --- getView --- + + @Test + void getView_returnsZeroCopyView() { + HeaderMap map = parse("", "Host: localhost"); + ByteView view = map.getView("Host"); + assertNotNull(view); + assertEquals(9, view.length()); + assertEquals('l', view.byteAt(0)); + assertEquals('t', view.byteAt(8)); + } + + @Test + void getView_missingHeader_returnsNull() { + HeaderMap map = parse("", "Host: localhost"); + assertNull(map.getView("Accept")); + } + + // --- limit --- + + @Test + void maxHeadersLimit() { + byte[] buffer = new byte[100]; + HeaderMap map = new HeaderMap(buffer); + for (int i = 0; i < 32; i++) { + map.add(0, 1, 1, 1); + } + assertThrows(IllegalStateException.class, () -> map.add(0, 1, 1, 1)); + } +} diff --git a/src/test/java/dev/relism/models/PathParamsTest.java b/src/test/java/dev/relism/models/PathParamsTest.java new file mode 100644 index 0000000..f316473 --- /dev/null +++ b/src/test/java/dev/relism/models/PathParamsTest.java @@ -0,0 +1,67 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class PathParamsTest { + + // --- helpers --- + + private static PathParams of(String path, String... paramsPairs) { + byte[] bytes = path.getBytes(StandardCharsets.UTF_8); + ByteView view = new ByteView() { + public int length() { return bytes.length; } + public byte byteAt(int idx) { return bytes[idx]; } + }; + + String[] names = new String[paramsPairs.length / 2]; + int[] starts = new int[paramsPairs.length / 2]; + int[] lens = new int[paramsPairs.length / 2]; + + for (int i = 0; i + 1 < paramsPairs.length; i += 2) { + names[i / 2] = paramsPairs[i]; + String val = paramsPairs[i + 1]; + starts[i / 2] = path.indexOf(val); + lens[i / 2] = val.length(); + } + + return new PathParams(view, names, starts, lens); + } + + // --- get --- + + @Test + void get_existingParam() { + PathParams params = of("/users/123/posts/456", "userId", "123", "postId", "456"); + assertEquals("123", params.get("userId")); + assertEquals("456", params.get("postId")); + } + + @Test + void get_missingParam_returnsNull() { + PathParams params = of("/users/123", "userId", "123"); + assertNull(params.get("unknown")); + } + + // --- view --- + + @Test + void view_existingParamZeroCopy() { + PathParams params = of("/users/123", "userId", "123"); + ByteView view = params.view("userId"); + assertNotNull(view); + assertEquals(3, view.length()); + assertEquals('1', view.byteAt(0)); + assertEquals('3', view.byteAt(2)); + } + + @Test + void view_missingParam_returnsNull() { + PathParams params = of("/users/123", "userId", "123"); + assertNull(params.view("unknown")); + } +} diff --git a/src/test/java/dev/relism/models/QueryParamsTest.java b/src/test/java/dev/relism/models/QueryParamsTest.java new file mode 100644 index 0000000..027dd92 --- /dev/null +++ b/src/test/java/dev/relism/models/QueryParamsTest.java @@ -0,0 +1,72 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class QueryParamsTest { + + // --- helpers --- + + private static QueryParams of(String raw) { + byte[] bytes = raw.getBytes(StandardCharsets.UTF_8); + ByteView view = new ByteView() { + public int length() { return bytes.length; } + public byte byteAt(int idx) { return bytes[idx]; } + }; + return new QueryParams(view); + } + + // --- get --- + + @Test + void get_singleParam() { + assertEquals("hello", of("name=hello").get("name")); + } + + @Test + void get_firstOfMultiple() { + assertEquals("1", of("a=1&b=2&c=3").get("a")); + assertEquals("2", of("a=1&b=2&c=3").get("b")); + assertEquals("3", of("a=1&b=2&c=3").get("c")); + } + + @Test + void get_missingKey_returnsNull() { + assertNull(of("a=1&b=2").get("z")); + } + + @Test + void get_emptyValue() { + assertEquals("", of("key=").get("key")); + } + + @Test + void get_multiValue_returnsFirst() { + assertEquals("a", of("tag=a&tag=b&tag=c").get("tag")); + } + + // --- getAll --- + + @Test + void getAll_multiValue() { + assertEquals(List.of("a", "b", "c"), of("tag=a&tag=b&tag=c").getAll("tag")); + } + + @Test + void getAll_missingKey_returnsEmpty() { + assertTrue(of("a=1").getAll("z").isEmpty()); + } + + // --- EMPTY --- + + @Test + void empty_returnsNull() { + assertNull(QueryParams.EMPTY.get("anything")); + assertTrue(QueryParams.EMPTY.getAll("anything").isEmpty()); + } +} diff --git a/src/test/java/dev/relism/models/RequestLineTest.java b/src/test/java/dev/relism/models/RequestLineTest.java new file mode 100644 index 0000000..642f471 --- /dev/null +++ b/src/test/java/dev/relism/models/RequestLineTest.java @@ -0,0 +1,42 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import dev.relism.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class RequestLineTest { + + // --- helpers --- + + private static ByteView viewOf(String s) { + if (s == null) return null; + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + return new ByteView() { + public int length() { return bytes.length; } + public byte byteAt(int idx) { return bytes[idx]; } + }; + } + + // --- initialization --- + + @Test + void constructionAndGetters() { + ByteView path = viewOf("/api"); + ByteView query = viewOf("q=1"); + ByteView proto = viewOf("HTTP/1.1"); + HeaderMap headers = new HeaderMap(new byte[0]); + + RequestLine rl = new RequestLine(HttpMethod.GET, path, query, proto, headers); + + assertEquals(HttpMethod.GET, rl.getMethod()); + assertEquals(path, rl.getPath()); + assertEquals(query, rl.getQuery()); + assertEquals(proto, rl.getProtocol()); + assertEquals(headers, rl.getHeaders()); + assertNotNull(rl.toString()); + } +} diff --git a/src/test/java/dev/relism/models/RequestTest.java b/src/test/java/dev/relism/models/RequestTest.java new file mode 100644 index 0000000..993a72a --- /dev/null +++ b/src/test/java/dev/relism/models/RequestTest.java @@ -0,0 +1,88 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import dev.relism.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RequestTest { + + // --- helpers --- + + private static ByteView viewOf(String s) { + if (s == null) return null; + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + return new ByteView() { + public int length() { return bytes.length; } + public byte byteAt(int idx) { return bytes[idx]; } + }; + } + + // --- creation --- + + @Test + void request_creationAndGetters() { + HeaderMap headers = new HeaderMap(new byte[0]); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/path"), viewOf("q=1"), viewOf("HTTP/1.1"), headers); + byte[] body = "body".getBytes(StandardCharsets.UTF_8); + + Request r = new Request(line, body); + + assertEquals(line, r.getRequestLine()); + assertArrayEquals(body, r.getBody()); + assertNotNull(r.toString()); + } + + // --- delegates --- + + @Test + void headers_delegatesToRequestLine() { + byte[] buffer = "Host: localhost\r\n".getBytes(StandardCharsets.UTF_8); + HeaderMap headers = new HeaderMap(buffer); + headers.add(0, 4, 6, 9); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), headers); + Request r = new Request(line, new byte[0]); + + assertEquals("localhost", r.getHeader("Host")); + assertEquals(List.of("localhost"), r.getHeaders("Host")); + assertEquals(List.of("localhost"), r.getHeaders()); + } + + // --- pathParams --- + + @Test + void pathParam_lazyGet() { + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap(new byte[0])); + Request r = new Request(line, new byte[0]); + + assertNull(r.getPathParam("id")); + + r.setPathParams(new PathParams(viewOf("/123"), new String[]{"id"}, new int[]{1}, new int[]{3})); + assertEquals("123", r.getPathParam("id")); + } + + // --- queryParams --- + + @Test + void queryParam_lazyGet_fromQueryString() { + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new HeaderMap(new byte[0])); + Request r = new Request(line, new byte[0]); + + assertEquals("1", r.getQueryParam("a")); + assertEquals("2", r.getQueryParam("b")); // First value + assertEquals(List.of("2", "3"), r.getQueryParams("b")); + } + + @Test + void queryParam_lazyGet_nullQueryString() { + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap(new byte[0])); + Request r = new Request(line, new byte[0]); + + assertNull(r.getQueryParam("a")); + assertTrue(r.getQueryParams("a").isEmpty()); + } +} diff --git a/src/test/java/dev/relism/models/ResponseTest.java b/src/test/java/dev/relism/models/ResponseTest.java new file mode 100644 index 0000000..88ae9de --- /dev/null +++ b/src/test/java/dev/relism/models/ResponseTest.java @@ -0,0 +1,71 @@ +package dev.relism.models; + +import dev.relism.http.ContentType; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ResponseTest { + + // --- creation --- + + @Test + void constructor_withByteArray() { + byte[] body = "bytes".getBytes(StandardCharsets.UTF_8); + Response r = new Response(200, body, ContentType.BINARY); + + assertEquals(200, r.getStatusCode()); + assertArrayEquals(body, r.getBody()); + assertArrayEquals("application/octet-stream".getBytes(StandardCharsets.UTF_8), r.getContentType()); + assertNotNull(r.toString()); + } + + @Test + void constructor_withStringText() { + Response r = new Response(404, "Not Found Text", ContentType.TEXT_PLAIN); + + assertEquals(404, r.getStatusCode()); + assertArrayEquals("Not Found Text".getBytes(StandardCharsets.UTF_8), r.getBody()); + assertArrayEquals(ContentType.TEXT_PLAIN.getBytes(), r.getContentType()); + } + + // --- setters --- + + @Test + void setContentType_byEnum() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + r.setContentType(ContentType.JSON); + assertArrayEquals(ContentType.JSON.getBytes(), r.getContentType()); + } + + @Test + void setContentType_byString() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + r.setContentType("application/custom"); + assertArrayEquals("application/custom".getBytes(StandardCharsets.UTF_8), r.getContentType()); + } + + @Test + void setBody_withByteArray() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + byte[] newBody = "new".getBytes(StandardCharsets.UTF_8); + r.setBody(newBody); + assertArrayEquals(newBody, r.getBody()); + } + + @Test + void setBody_withObjectConvertedToString() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + r.setBody(12345); // auto boxes to Integer, toString called + assertArrayEquals("12345".getBytes(StandardCharsets.UTF_8), r.getBody()); + } + + @Test + void setStatusCode() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + r.setStatusCode(201); + assertEquals(201, r.getStatusCode()); + } +} diff --git a/src/test/java/dev/relism/models/SimpleHandlerTest.java b/src/test/java/dev/relism/models/SimpleHandlerTest.java new file mode 100644 index 0000000..84dede8 --- /dev/null +++ b/src/test/java/dev/relism/models/SimpleHandlerTest.java @@ -0,0 +1,19 @@ +package dev.relism.models; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SimpleHandlerTest { + + // --- execution --- + + @Test + void handle_invokesFunctionalHandler() throws Exception { + SimpleHandler.FunctionalHandler func = (req, res) -> "Hello"; + SimpleHandler handler = new SimpleHandler(func); + + Object result = handler.handle(null, null); + assertEquals("Hello", result); + } +} diff --git a/src/test/java/dev/relism/routing/AbstractRouterTest.java b/src/test/java/dev/relism/routing/AbstractRouterTest.java new file mode 100644 index 0000000..0821722 --- /dev/null +++ b/src/test/java/dev/relism/routing/AbstractRouterTest.java @@ -0,0 +1,133 @@ +package dev.relism.routing; + +import dev.relism.http.ContentType; +import dev.relism.http.HttpMethod; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.models.Response; +import dev.relism.models.SimpleHandler; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class AbstractRouterTest { + + // A dummy router for testing base functionality + static class DummyRouter extends AbstractRouter { + RequestHandler lastAddedHandler; + HttpMethod lastAddedMethod; + String lastAddedPath; + + @Override + public RequestHandler route(Request request) { + return null; // Not testing routing logic here + } + + @Override + protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { + this.lastAddedMethod = method; + this.lastAddedPath = path; + this.lastAddedHandler = handler; + return this; + } + } + + @Route(method = "POST", path = "/profile") + static class ProfileHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + static class UnannotatedHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + // --- namespace --- + + @Test + void setNamespace_updatesStringAndBytes() { + DummyRouter router = new DummyRouter(); + assertEquals("/", router.getNamespace()); + + router.setNamespace("/api"); + assertEquals("/api", router.getNamespace()); + assertArrayEquals("/api".getBytes(StandardCharsets.UTF_8), router.getNamespaceBytes()); + } + + // --- helpers --- + + @Test + void getPostPutDelete_delegatesToAddRouteWithSanitizedPath() { + DummyRouter router = new DummyRouter(); + SimpleHandler.FunctionalHandler func = (req, res) -> "OK"; + + router.get("users/", func); + assertEquals(HttpMethod.GET, router.lastAddedMethod); + assertEquals("/users", router.lastAddedPath); + assertTrue(router.lastAddedHandler instanceof SimpleHandler); + + router.post("/items", func); + assertEquals(HttpMethod.POST, router.lastAddedMethod); + + router.put("update", func); + assertEquals(HttpMethod.PUT, router.lastAddedMethod); + + router.delete("//delete//", func); + assertEquals(HttpMethod.DELETE, router.lastAddedMethod); + assertEquals("/delete", router.lastAddedPath); + } + + // --- register --- + + @Test + void register_annotatedHandler_addsRoute() { + DummyRouter router = new DummyRouter(); + ProfileHandler handler = new ProfileHandler(); + + router.register(handler); + + assertEquals(HttpMethod.POST, router.lastAddedMethod); + assertEquals("/profile", router.lastAddedPath); + assertEquals(handler, router.lastAddedHandler); + } + + @Test + void register_unannotatedHandler_doesNothing() { + DummyRouter router = new DummyRouter(); + router.register(new UnannotatedHandler()); + assertNull(router.lastAddedMethod); // Nothing added + } + + // --- default handlers --- + + @Test + void defaultNotFoundHandler_returns404Html() throws Exception { + DummyRouter router = new DummyRouter(); + Response res = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + + assertNotNull(router.getNotFoundHandler()); + + SimpleHandler.FunctionalHandler custom = (req, resp) -> "Custom 404"; + router.onNotFound(custom); + + assertEquals("Custom 404", router.getNotFoundHandler().handle(null, res)); + } + + @Test + void defaultExceptionHandler_canBeOverridden() throws Exception { + DummyRouter router = new DummyRouter(); + assertNotNull(router.getExceptionHandler()); + + AbstractRouter.ExceptionHandler custom = (ex, req, res) -> "Caught"; + router.onException(custom); + + assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null)); + } +} diff --git a/src/test/java/dev/relism/routing/GlobalRouterTest.java b/src/test/java/dev/relism/routing/GlobalRouterTest.java new file mode 100644 index 0000000..61c3501 --- /dev/null +++ b/src/test/java/dev/relism/routing/GlobalRouterTest.java @@ -0,0 +1,111 @@ +package dev.relism.routing; + +import dev.relism.fpr.core.ByteView; +import dev.relism.http.HttpMethod; +import dev.relism.models.HeaderMap; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.models.RequestLine; +import dev.relism.models.Response; +import dev.relism.models.SimpleHandler; +import dev.relism.routing.routers.fastpathrouter.FastPathViews; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class GlobalRouterTest { + + // --- mock --- + + static class MockSubRouter extends AbstractRouter { + RequestHandler matchedHandler; + + MockSubRouter(RequestHandler handler) { + this.matchedHandler = handler; + } + + @Override + public RequestHandler route(Request request) { + return matchedHandler; + } + + @Override + protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { + return this; + } + } + + private Request mockRequest(String path) { + byte[] bytes = path.getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length); + + RequestLine line = new RequestLine( + HttpMethod.GET, pathView, null, + new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8), + new HeaderMap(new byte[0]) + ); + return new Request(line, new byte[0]); + } + + // --- mount & route --- + + @Test + void route_delegatesToSubRouterBasedOnLongestPrefix() { + GlobalRouter global = new GlobalRouter(); + + RequestHandler hApi = new SimpleHandler((req, res) -> "api"); + RequestHandler hApiV1 = new SimpleHandler((req, res) -> "apiv1"); + + global.mount("/api", new MockSubRouter(hApi)); + global.mount("/api/v1", new MockSubRouter(hApiV1)); // longer prefix + + // Path matches /api/v1 -> Should pick hApiV1 because it's longer and sorted first + RequestHandler resolved = global.route(mockRequest("/api/v1/users")); + assertEquals(hApiV1, resolved); + + // Path matches /api but not /api/v1 + RequestHandler resolved2 = global.route(mockRequest("/api/v2/users")); + assertEquals(hApi, resolved2); + } + + @Test + void route_fallsBackToInternalRouter() throws Exception { + GlobalRouter global = new GlobalRouter(); + RequestHandler internalHandler = new SimpleHandler((req, res) -> "internal"); + + global.get("/hello", (req, res) -> "internal"); + // We know it routes to internal. Let's send a request. + RequestHandler resolved = global.route(mockRequest("/hello")); + assertNotNull(resolved); + // It's the compiled FastPathRouter handler, let's verify it works + assertEquals("internal", resolved.handle(null, null)); + } + + @Test + void route_noMatch_returnsNotFoundHandler() { + GlobalRouter global = new GlobalRouter(); + // Nothing registered. Should return the global notFoundHandler. + RequestHandler resolved = global.route(mockRequest("/unknown")); + assertEquals(global.getNotFoundHandler(), resolved); + } + + // --- resolveExceptionHandler --- + + @Test + void resolveExceptionHandler_returnsScopedHandler() { + GlobalRouter global = new GlobalRouter(); + MockSubRouter sub = new MockSubRouter(null); + AbstractRouter.ExceptionHandler customSubHandler = (ex, req, res) -> "sub error"; + sub.onException(customSubHandler); + + global.mount("/api", sub); + + // Under sub-namespace + assertEquals(customSubHandler, global.resolveExceptionHandler(mockRequest("/api/fail"))); + + // Outside sub-namespace (global) + assertEquals(global.getExceptionHandler(), global.resolveExceptionHandler(mockRequest("/other"))); + } +} diff --git a/src/test/java/dev/relism/routing/PathUtilsTest.java b/src/test/java/dev/relism/routing/PathUtilsTest.java new file mode 100644 index 0000000..177a7a3 --- /dev/null +++ b/src/test/java/dev/relism/routing/PathUtilsTest.java @@ -0,0 +1,63 @@ +package dev.relism.routing; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PathUtilsTest { + + // --- sanitize --- + + @Test + void sanitize_nullOrBlank_returnsRoot() { + assertEquals("/", PathUtils.sanitize(null)); + assertEquals("/", PathUtils.sanitize("")); + assertEquals("/", PathUtils.sanitize(" ")); + assertEquals("/", PathUtils.sanitize("/")); + } + + @Test + void sanitize_trimsWhitespaceAndEnsuresLeadingSlash() { + assertEquals("/users", PathUtils.sanitize(" users ")); + assertEquals("/api", PathUtils.sanitize("api")); + } + + @Test + void sanitize_removesTrailingSlash() { + assertEquals("/users", PathUtils.sanitize("/users/")); + assertEquals("/api/v1", PathUtils.sanitize("/api/v1/")); + } + + @Test + void sanitize_collapsesMultipleSlashes() { + assertEquals("/a/b/c", PathUtils.sanitize("//a///b//c/")); + } + + // --- join --- + + @Test + void join_withRootBase_returnsSanitizedPath() { + assertEquals("/users", PathUtils.join("/", "/users/")); + assertEquals("/users", PathUtils.join("/", "users")); + } + + @Test + void join_withRootPath_returnsSanitizedBase() { + assertEquals("/api", PathUtils.join("/api/", "/")); + assertEquals("/api", PathUtils.join("api", "")); + } + + @Test + void join_preventsDoubleNamespace() { + // Path already starts with base + assertEquals("/api/users", PathUtils.join("/api", "/api/users")); + assertEquals("/api/users", PathUtils.join("/api/", "/api/users/")); + } + + @Test + void join_concatenatesProperly() { + assertEquals("/api/users", PathUtils.join("/api", "users")); + assertEquals("/api/users", PathUtils.join("/api", "/users")); + assertEquals("/api/users", PathUtils.join("api", "users")); + } +} diff --git a/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java b/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java new file mode 100644 index 0000000..bd6ee24 --- /dev/null +++ b/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java @@ -0,0 +1,76 @@ +package dev.relism.routing.routers.fastpathrouter; + +import dev.relism.http.HttpMethod; +import dev.relism.models.HeaderMap; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.models.RequestLine; +import dev.relism.models.SimpleHandler; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class FastPathRouterImplTest { + + // --- helpers --- + + private Request mockRequest(HttpMethod method, String path) { + byte[] bytes = path.getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length); + + RequestLine line = new RequestLine( + method, pathView, null, + new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8), + new HeaderMap(new byte[0]) + ); + return new Request(line, new byte[0]); + } + + // --- route --- + + @Test + void route_lazyCompilationAndMatch() { + FastPathRouterImpl router = new FastPathRouterImpl(); + + router.get("/a", (req, res) -> "A"); + router.post("/b", (req, res) -> "B"); + + RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a")); + assertNotNull(res1); + assertEquals("A", res1.handle(null, null)); + + RequestHandler res2 = router.route(mockRequest(HttpMethod.POST, "/b")); + assertNotNull(res2); + assertEquals("B", res2.handle(null, null)); + } + + @Test + void route_noMatch_returnsNull() { + FastPathRouterImpl router = new FastPathRouterImpl(); + router.get("/a", (req, res) -> "A"); + + assertNull(router.route(mockRequest(HttpMethod.GET, "/b"))); + // Wrong method + assertNull(router.route(mockRequest(HttpMethod.POST, "/a"))); + } + + @Test + void route_extractsPathParams() { + FastPathRouterImpl router = new FastPathRouterImpl(); + + router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract"); + + Request request = mockRequest(HttpMethod.GET, "/users/123/items/456"); + RequestHandler handler = router.route(request); + + assertNotNull(handler); + assertEquals("Extract", handler.handle(request, null)); + + // Verify path params were injected + assertNotNull(request.getPathParams()); + assertEquals("123", request.getPathParam("id")); + assertEquals("456", request.getPathParam("itemId")); + } +} diff --git a/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java b/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java new file mode 100644 index 0000000..1c04683 --- /dev/null +++ b/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java @@ -0,0 +1,64 @@ +package dev.relism.routing.routers.fastpathrouter; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class FastPathViewsTest { + + private static final byte[] SHARED_BUFFER = "GET /api/users?id=1 HTTP/1.1".getBytes(StandardCharsets.UTF_8); + + // --- RequestByteView --- + + @Test + void requestByteView_readsCorrectSlice() { + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10); + assertEquals(10, view.length()); + assertEquals('/', view.byteAt(0)); + assertEquals('s', view.byteAt(9)); + assertEquals("/api/users", view.toString()); + } + + @Test + void requestByteView_outOfBounds_throwsException() { + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10); + assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(10)); + } + + // --- MethodPathByteView --- + + @Test + void methodPathByteView_combinesViewsCorrectly() { + byte[] methodBytes = "POST".getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10); // "/api/users" + + FastPathViews.MethodPathByteView composite = new FastPathViews.MethodPathByteView(); + composite.reset(methodBytes, pathView); + + assertEquals(14, composite.length()); + assertEquals('P', composite.byteAt(0)); + assertEquals('T', composite.byteAt(3)); + assertEquals('/', composite.byteAt(4)); + assertEquals('s', composite.byteAt(13)); + } + + // --- SocketByteView & StringByteView --- + + @Test + void socketByteView_wrapsByteArray() { + byte[] data = "Hello".getBytes(StandardCharsets.UTF_8); + FastPathViews.SocketByteView view = new FastPathViews.SocketByteView(data); + assertEquals(5, view.length()); + assertEquals('H', view.byteAt(0)); + } + + @Test + void stringByteView_wrapsString() { + FastPathViews.StringByteView view = new FastPathViews.StringByteView("Hello"); + assertEquals(5, view.length()); + assertEquals('o', view.byteAt(4)); + } +} diff --git a/src/test/java/dev/relism/template/ByteTemplateTest.java b/src/test/java/dev/relism/template/ByteTemplateTest.java new file mode 100644 index 0000000..12cbcfa --- /dev/null +++ b/src/test/java/dev/relism/template/ByteTemplateTest.java @@ -0,0 +1,58 @@ +package dev.relism.template; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ByteTemplateTest { + + // --- render --- + + @Test + void render_singlePlaceholder() { + ByteTemplate tpl = new ByteTemplate("Hello {{name}}!"); + byte[] result = tpl.render("name", "World"); + assertEquals("Hello World!", new String(result, StandardCharsets.UTF_8)); + } + + @Test + void render_multiplePlaceholders() { + ByteTemplate tpl = new ByteTemplate("{{greeting}} {{name}}, welcome to {{place}}"); + byte[] result = tpl.render( + "greeting", "Hi", + "name", "Alice", + "place", "Wonderland" + ); + assertEquals("Hi Alice, welcome to Wonderland", new String(result, StandardCharsets.UTF_8)); + } + + @Test + void render_repeatedPlaceholder() { + ByteTemplate tpl = new ByteTemplate("{{var}} == {{var}}"); + byte[] result = tpl.render("var", "test"); + assertEquals("test == test", new String(result, StandardCharsets.UTF_8)); + } + + @Test + void render_unmatchedPlaceholder_leavesEmptySpace() { + ByteTemplate tpl = new ByteTemplate("A{{foo}}B"); + byte[] result = tpl.render("bar", "baz"); // foo is missing + assertEquals("AB", new String(result, StandardCharsets.UTF_8)); + } + + @Test + void render_noPlaceholders_returnsIdenticalOutput() { + ByteTemplate tpl = new ByteTemplate("Static Content Only"); + byte[] result = tpl.render("ignored", "value"); + assertEquals("Static Content Only", new String(result, StandardCharsets.UTF_8)); + } + + @Test + void render_adjacentPlaceholders() { + ByteTemplate tpl = new ByteTemplate("A{{v1}}{{v2}}B"); + byte[] result = tpl.render("v1", "1", "v2", "2"); + assertEquals("A12B", new String(result, StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/dev/relism/template/ErrorPagesTest.java b/src/test/java/dev/relism/template/ErrorPagesTest.java new file mode 100644 index 0000000..6d12a1b --- /dev/null +++ b/src/test/java/dev/relism/template/ErrorPagesTest.java @@ -0,0 +1,59 @@ +package dev.relism.template; + +import dev.relism.http.HttpMethod; +import dev.relism.models.HeaderMap; +import dev.relism.models.Request; +import dev.relism.models.RequestLine; +import dev.relism.routing.routers.fastpathrouter.FastPathViews; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ErrorPagesTest { + + // --- helpers --- + + private Request mockRequest() { + String path = "/api/test"; + byte[] bytes = path.getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length); + + String protocol = "HTTP/1.1"; + byte[] protoBytes = protocol.getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView protoView = new FastPathViews.RequestByteView(protoBytes, 0, protoBytes.length); + + RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new HeaderMap(new byte[0])); + return new Request(line, new byte[0]); + } + + // --- templates --- + + @Test + void renderNotFound_generatesHtml() { + Request req = mockRequest(); + byte[] html = ErrorPages.renderNotFound(req); + String result = new String(html, StandardCharsets.UTF_8); + + assertTrue(result.contains("404")); + assertTrue(result.contains("No route matched this request.")); + assertTrue(result.contains("/api/test")); // Injected path + assertTrue(result.contains("GET")); // Injected method + assertTrue(result.contains("footer-logo")); // Baked-in logo + } + + @Test + void renderException_generatesHtml() { + Request req = mockRequest(); + Exception ex = new IllegalArgumentException("Invalid state in test application"); + byte[] html = ErrorPages.renderException(req, ex); + String result = new String(html, StandardCharsets.UTF_8); + + assertTrue(result.contains("500")); + assertTrue(result.contains("/api/test")); + assertTrue(result.contains("IllegalArgumentException")); + assertTrue(result.contains("Invalid state in test application")); + assertTrue(result.contains("ErrorPagesTest.java")); // Stacktrace inclusion + } +}