···10101111### Breaking Changes
12121313-- The `Atex.IdentityResolver` config key has been replaced with a flat config option.
1414- Update your config from:
1313+- The `Atex.IdentityResolver` config key has been replaced with a flat config
1414+ option. Update your config from:
15151616 ```elixir
1717 config :atex, Atex.IdentityResolver,
···27272828- `Atex.Config.IdentityResolver` has been renamed to `Atex.Config`.
2929- `Atex.IdentityResolver.DIDDocument` has been renamed to `Atex.DID.Document`.
3030-- Replace existing `Atex.DID.Document.new/1` method with the method previously named `from_json/1`.
3030+- Replace existing `Atex.DID.Document.new/1` method with the method previously
3131+ named `from_json/1`.
31323233### Added
33343434-- `Atex.Crypto` module for performing AT Protocol-related cryptographic operations.
3535-- `Atex.PLC` module for interacting with [a did:plc directory API](https://web.plc.directory/).
3636-- `Atex.ServiceAuth` module for validating [inter-service authentication tokens](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
3535+- `Atex.Crypto` module for performing AT Protocol-related cryptographic
3636+ operations.
3737+- `Atex.PLC` module for interacting with
3838+ [a did:plc directory API](https://web.plc.directory/).
3939+- `Atex.ServiceAuth` module for validating
4040+ [inter-service authentication tokens](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
3741- Various improvements to `Atex.Did.Document`
3838- - Add `Atex.DID.Document.Service` and `Atex.DID.Document.VerificationMethod` sub-structs.
3939- - Add `to_json/1` methods and `JSON.Encoder` protocols for easy conversion to camelCase JSON.
4242+ - Add `Atex.DID.Document.Service` and `Atex.DID.Document.VerificationMethod`
4343+ sub-structs.
4444+ - Add `to_json/1` methods and `JSON.Encoder` protocols for easy conversion to
4545+ camelCase JSON.
4646+- `Atex.XRPC.Router` module with `query/3` and `procedure/3` macros for easily
4747+ building XRPC server routes inside a `Plug.Router`, with built-in service auth
4848+ validation and validation if passed the name of a module using `deflexicon`.
4049- `deflexicon` now emits `content_type/0` functions (on `Input` submodules for typed JSON bodies,
4150 otherwise on the root module) for procedures.
4251
+1-1
README.md
···1717- [ ] Repository reading and manipulation (MST & CAR)
1818- [x] Service auth
1919- [x] PLC client
2020-- [ ] XRPC server router
2020+- [x] XRPC server router
21212222Looking to use a data subscription service like the Firehose, [Jetstream], or [Tap]? Check out [Drinkup].
2323
···11+{
22+ "lexicon": 1,
33+ "id": "com.example.uploadBlob",
44+ "description": "Upload a binary blob (e.g. an image) and receive a blob reference.",
55+ "defs": {
66+ "main": {
77+ "type": "procedure",
88+ "description": "Accepts a raw binary body and stores it as a blob. The Content-Type header must be set to the MIME type of the uploaded data.",
99+ "input": {
1010+ "encoding": "*/*",
1111+ "description": "Raw binary content of the blob. Supported MIME types: image/jpeg, image/png, image/gif, image/webp."
1212+ },
1313+ "output": {
1414+ "encoding": "application/json",
1515+ "schema": {
1616+ "type": "object",
1717+ "required": ["blob"],
1818+ "properties": {
1919+ "blob": {
2020+ "type": "blob",
2121+ "accept": ["image/*"],
2222+ "maxSize": 1000000
2323+ }
2424+ }
2525+ }
2626+ },
2727+ "errors": [
2828+ {
2929+ "name": "InvalidMimeType",
3030+ "description": "The Content-Type of the uploaded data is not an accepted image MIME type."
3131+ },
3232+ {
3333+ "name": "BlobTooLarge",
3434+ "description": "The uploaded blob exceeds the maximum permitted size."
3535+ }
3636+ ]
3737+ }
3838+ }
3939+}
+13-1
lib/atex/config.ex
···77 The following keys are supported under `config :atex`:
8899 config :atex,
1010- plc_directory_url: "https://plc.directory"
1010+ plc_directory_url: "https://plc.directory",
1111+ service_did: "did:web:my-service.example"
11121213 - `:plc_directory_url` - Base URL for the did:plc directory server.
1314 Defaults to `"https://plc.directory"`.
1515+ - `:service_did` - The DID of this service, used as the expected `aud` claim
1616+ when validating incoming inter-service auth JWTs via `Atex.XRPC.Router`.
1717+ Required when using `Atex.XRPC.Router` with auth enabled.
1418 """
15191620 @doc """
···2226 @spec directory_url :: String.t()
2327 def directory_url,
2428 do: Application.get_env(:atex, :plc_directory_url, "https://plc.directory")
2929+3030+ @doc """
3131+ Returns the configured service DID to be used for validation service auth tokens.
3232+3333+ Reads `:service_did` from the `:atex application environment.
3434+ """
3535+ @spec service_did :: String.t() | nil
3636+ def service_did, do: Application.get_env(:atex, :service_did)
2537end
+2-2
lib/atex/service_auth.ex
···5757 def validate_conn(conn, opts \\ []) do
5858 case get_req_header(conn, "authorization") do
5959 ["Bearer " <> jwt] -> validate_jwt(jwt, opts)
6060- [_] -> :error
6161- _ -> :error
6060+ [_] -> {:error, :no_header}
6161+ _ -> {:error, :no_header}
6262 end
6363 end
6464
-2
lib/atex/xrpc/login_client.ex
···114114 @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) ::
115115 {:ok, Req.Response.t(), t()} | {:error, any()}
116116 defp handle_failure(client, response, request) do
117117- IO.inspect(response, label: "got failure")
118118-119117 if auth_error?(response.body) and client.refresh_token do
120118 case refresh(client) do
121119 {:ok, client} ->
+417
lib/atex/xrpc/router.ex
···11+defmodule Atex.XRPC.Router do
22+ @moduledoc """
33+ Routing utilities for building ATProto XRPC server endpoints.
44+55+ Provides the `query/3` and `procedure/3` macros that expand to
66+ `Plug.Router.get/3` and `Plug.Router.post/3` respectively, with built-in
77+ handling for:
88+99+ - NSID-prefixed route paths (`/xrpc/<nsid>`)
1010+ - Service auth validation via `Atex.ServiceAuth`
1111+ - Query param and body validation for lexicon modules generated with `Atex.Lexicon.deflexicon/1`.
1212+1313+ ## Usage
1414+1515+ defmodule MyAPI do
1616+ use Plug.Router
1717+ use Atex.XRPC.Router
1818+1919+ plug :match
2020+ plug :dispatch
2121+2222+ # Matches GET /xrpc/com.example.getProfile
2323+ query "com.example.getProfile" do
2424+ send_resp(conn, 200, "ok")
2525+ end
2626+2727+ # Matches POST /xrpc/com.example.createPost, enforces auth
2828+ procedure Com.Example.CreatePost, require_auth: true do
2929+ # conn.assigns[:params] and conn.assigns[:body] are populated
3030+ # when the lexicon module defines Params/Input submodules
3131+ send_resp(conn, 200, "created")
3232+ end
3333+ end
3434+3535+ ## Authentication
3636+3737+ Authentication uses `Atex.ServiceAuth.validate_conn/2`. The audience (`aud`)
3838+ is read from `conn.private[:xrpc_aud]`, which is populated automatically by
3939+ `Atex.XRPC.Router.AudPlug` that reads `:service_did` from app config. To
4040+ disable automatic plug injection:
4141+4242+ use Atex.XRPC.Router, plug_aud: false
4343+4444+ When `require_auth: true` is passed to a route macro, a missing or invalid
4545+ token halts with a `401` response. Otherwise auth is attempted softly -
4646+ on success the decoded JWT is placed at `conn.assigns[:current_jwt]`, on
4747+ failure the conn is left untouched.
4848+4949+ ## Validation
5050+5151+ When a lexicon module atom is passed, the macro checks at compile time whether
5252+ `<Module>.Params` and/or `<Module>.Input` exist. If they do, their
5353+ `from_json/1` is called at request time:
5454+5555+ - Valid params → `conn.assigns[:params]`
5656+ - Valid body → `conn.assigns[:body]`
5757+ - Either failing → halts with a `400` response
5858+ """
5959+6060+ @doc false
6161+ defmacro __using__(opts \\ []) do
6262+ plug_aud = Keyword.get(opts, :plug_aud, true)
6363+6464+ quote do
6565+ import Atex.XRPC.Router, only: [query: 2, query: 3, procedure: 2, procedure: 3]
6666+6767+ if unquote(plug_aud) do
6868+ plug Atex.XRPC.Router.AudPlug
6969+ end
7070+ end
7171+ end
7272+7373+ @doc """
7474+ Defines a GET route for an XRPC query.
7575+7676+ The first argument is either:
7777+7878+ - A plain string NSID (e.g. `"com.example.getProfile"`) - validated at
7979+ compile time.
8080+ - A lexicon module atom (e.g. `Com.Example.GetProfile`) - the NSID is
8181+ fetched from `module.id()` at compile time.
8282+8383+ ## Options
8484+8585+ - `:require_auth` - when `true`, requests without a valid service auth token
8686+ are rejected with a `401`. Defaults to `false`.
8787+8888+ ## Assigns
8989+9090+ - `:current_jwt` - the decoded `JOSE.JWT` struct, set on successful auth.
9191+ - `:params` - validated params struct, set when the lexicon module
9292+ defines a `Params` submodule.
9393+9494+ ## Examples
9595+9696+ query "com.example.getTimeline", require_auth: true do
9797+ send_resp(conn, 200, "ok")
9898+ end
9999+100100+ query Com.Example.GetTimeline do
101101+ send_resp(conn, 200, "ok")
102102+ end
103103+ """
104104+ defmacro query(nsid_or_module, opts \\ [], do: block) do
105105+ {nsid, params_module} = resolve_nsid_and_submodule(nsid_or_module, :Params, __CALLER__)
106106+ require_auth = Keyword.get(opts, :require_auth, false)
107107+ path = "/xrpc/#{nsid}"
108108+109109+ auth_block = build_auth_block(nsid, require_auth)
110110+ params_block = if params_module, do: build_params_block(params_module), else: []
111111+112112+ quote do
113113+ get unquote(path) do
114114+ var!(conn) = Plug.Conn.fetch_query_params(var!(conn))
115115+ unquote_splicing(auth_block)
116116+ unquote_splicing(params_block)
117117+118118+ if var!(conn).halted do
119119+ var!(conn)
120120+ else
121121+ unquote(block)
122122+ end
123123+ end
124124+ end
125125+ end
126126+127127+ @doc """
128128+ Defines a POST route for an XRPC procedure.
129129+130130+ The first argument is either:
131131+132132+ - A plain string NSID (e.g. `"com.example.createPost"`) - validated at
133133+ compile time.
134134+ - A lexicon module atom (e.g. `Com.Example.CreatePost`) - the NSID is
135135+ fetched from `module.id()` at compile time.
136136+137137+ ## Options
138138+139139+ - `:require_auth` - when `true`, requests without a valid service auth token
140140+ are rejected with a `401`. Defaults to `false`.
141141+142142+ ## Assigns
143143+144144+ - `:current_jwt` - the decoded `JOSE.JWT` struct, set on successful auth.
145145+ - `:params` - validated params struct, set when the lexicon module
146146+ defines a `Params` submodule.
147147+ - `:body` - validated input struct, set when the lexicon module
148148+ defines an `Input` submodule.
149149+150150+ ## Non-JSON payloads
151151+152152+ If a lexicon procedure defines an `input` with an encoding without an `object`
153153+ schema, this will simply validate the incoming `Content-Type` header against the
154154+ requested encoding. Nothing happens on success, you will need to read `conn`'s
155155+ body as usual and do extra validation yourself, as clients may lie about their content.
156156+ Wildcards are handled correctly as per the atproto documentation.
157157+158158+ ## Examples
159159+160160+ procedure "com.example.createPost", require_auth: true do
161161+ send_resp(conn, 200, "created")
162162+ end
163163+164164+ procedure Com.Example.CreatePost, require_auth: true do
165165+ # conn.assigns[:body] contains the validated Input struct
166166+ send_resp(conn, 200, "created")
167167+ end
168168+ """
169169+ defmacro procedure(nsid_or_module, opts \\ [], do: block) do
170170+ {nsid, input_module} = resolve_nsid_and_submodule(nsid_or_module, :Input, __CALLER__)
171171+ {_nsid, params_module} = resolve_nsid_and_submodule(nsid_or_module, :Params, __CALLER__)
172172+ raw_input_module = resolve_raw_input_module(nsid_or_module, input_module, __CALLER__)
173173+ require_auth = Keyword.get(opts, :require_auth, false)
174174+ path = "/xrpc/#{nsid}"
175175+176176+ auth_block = build_auth_block(nsid, require_auth)
177177+ params_block = if params_module, do: build_params_block(params_module), else: []
178178+179179+ body_block =
180180+ cond do
181181+ input_module -> build_body_block(input_module)
182182+ raw_input_module -> build_raw_body_block(raw_input_module)
183183+ true -> []
184184+ end
185185+186186+ quote do
187187+ post unquote(path) do
188188+ var!(conn) = Plug.Conn.fetch_query_params(var!(conn))
189189+ unquote_splicing(auth_block)
190190+ unquote_splicing(params_block)
191191+ unquote_splicing(body_block)
192192+193193+ if var!(conn).halted do
194194+ var!(conn)
195195+ else
196196+ unquote(block)
197197+ end
198198+ end
199199+ end
200200+ end
201201+202202+ # ---------------------------------------------------------------------------
203203+ # Private helpers (compile-time)
204204+ # ---------------------------------------------------------------------------
205205+206206+ # Returns the root lexicon module if it represents a raw-input procedure
207207+ # (i.e. it exports `content_type/0` but has no `Input` submodule with
208208+ # `from_json/1`). Returns `nil` in all other cases, including when
209209+ # `nsid_or_module` is a plain string NSID.
210210+ @spec resolve_raw_input_module(term(), module() | nil, Macro.Env.t()) :: module() | nil
211211+ defp resolve_raw_input_module(nsid_or_module, input_module, env) do
212212+ with nil <- input_module,
213213+ {:__aliases__, _, _} = ast <- nsid_or_module do
214214+ module = Macro.expand(ast, env)
215215+216216+ if Code.ensure_loaded?(module) and function_exported?(module, :content_type, 0) do
217217+ module
218218+ end
219219+ else
220220+ _ -> nil
221221+ end
222222+ end
223223+224224+ # Returns {nsid_string, submodule_atom_or_nil}.
225225+ # `submodule` is e.g. :Params or :Input.
226226+ @spec resolve_nsid_and_submodule(term(), atom(), Macro.Env.t()) ::
227227+ {String.t(), module() | nil}
228228+ defp resolve_nsid_and_submodule(nsid_or_module, submodule_suffix, env) do
229229+ case nsid_or_module do
230230+ nsid when is_binary(nsid) ->
231231+ unless Atex.NSID.match?(nsid) do
232232+ raise CompileError,
233233+ file: env.file,
234234+ line: env.line,
235235+ description: "invalid NSID: #{inspect(nsid)}"
236236+ end
237237+238238+ {nsid, nil}
239239+240240+ {:__aliases__, _, _} = ast ->
241241+ module = Macro.expand(ast, env)
242242+243243+ unless Code.ensure_loaded?(module) and function_exported?(module, :id, 0) do
244244+ raise CompileError,
245245+ file: env.file,
246246+ line: env.line,
247247+ description:
248248+ "#{inspect(module)} does not define id/0 - " <>
249249+ "only lexicon modules generated by deflexicon are supported"
250250+ end
251251+252252+ nsid = module.id()
253253+254254+ unless Atex.NSID.match?(nsid) do
255255+ raise CompileError,
256256+ file: env.file,
257257+ line: env.line,
258258+ description: "#{inspect(module)}.id() returned an invalid NSID: #{inspect(nsid)}"
259259+ end
260260+261261+ candidate = Module.concat(module, submodule_suffix)
262262+263263+ sub =
264264+ if Code.ensure_loaded?(candidate) and function_exported?(candidate, :from_json, 1) do
265265+ candidate
266266+ end
267267+268268+ {nsid, sub}
269269+ end
270270+ end
271271+272272+ # Emits a list of quoted expressions that perform auth (soft + optional strict).
273273+ # Uses var!(conn) to pierce macro hygiene and reference the `conn` variable
274274+ # introduced by Plug.Router.get/post in the caller's context.
275275+ @spec build_auth_block(String.t(), boolean()) :: [Macro.t()]
276276+ defp build_auth_block(nsid, require_auth) do
277277+ soft_auth =
278278+ quote do
279279+ var!(conn) =
280280+ case Atex.ServiceAuth.validate_conn(var!(conn),
281281+ aud: var!(conn).private[:xrpc_aud],
282282+ lxm: unquote(nsid)
283283+ ) do
284284+ {:ok, jwt} -> Plug.Conn.assign(var!(conn), :current_jwt, jwt)
285285+ _err -> var!(conn)
286286+ end
287287+ end
288288+289289+ strict_auth =
290290+ if require_auth do
291291+ quote do
292292+ var!(conn) =
293293+ if is_nil(var!(conn).assigns[:current_jwt]) do
294294+ var!(conn)
295295+ |> Plug.Conn.put_resp_content_type("application/json")
296296+ |> Plug.Conn.send_resp(
297297+ 401,
298298+ Jason.encode!(%{
299299+ "error" => "AuthRequired",
300300+ "message" => "Authentication required"
301301+ })
302302+ )
303303+ |> Plug.Conn.halt()
304304+ else
305305+ var!(conn)
306306+ end
307307+ end
308308+ end
309309+310310+ [soft_auth | List.wrap(strict_auth)]
311311+ end
312312+313313+ # Emits a quoted expression that validates query params via `module.from_json/1`.
314314+ # Skips if the conn is already halted by a previous step.
315315+ @spec build_params_block(module()) :: [Macro.t()]
316316+ defp build_params_block(params_module) do
317317+ [
318318+ quote do
319319+ var!(conn) =
320320+ if var!(conn).halted do
321321+ var!(conn)
322322+ else
323323+ case unquote(params_module).from_json(var!(conn).query_params) do
324324+ {:ok, params} ->
325325+ Plug.Conn.assign(var!(conn), :params, params)
326326+327327+ {:error, reason} ->
328328+ var!(conn)
329329+ |> Plug.Conn.put_resp_content_type("application/json")
330330+ |> Plug.Conn.send_resp(
331331+ 400,
332332+ Jason.encode!(%{
333333+ "error" => "InvalidRequest",
334334+ "message" => "Invalid query parameters: #{inspect(reason)}"
335335+ })
336336+ )
337337+ |> Plug.Conn.halt()
338338+ end
339339+ end
340340+ end
341341+ ]
342342+ end
343343+344344+ # Emits a quoted expression that validates the request body via `module.from_json/1`.
345345+ # Skips if the conn is already halted by a previous step.
346346+ @spec build_body_block(module()) :: [Macro.t()]
347347+ defp build_body_block(input_module) do
348348+ [
349349+ quote do
350350+ var!(conn) =
351351+ if var!(conn).halted do
352352+ var!(conn)
353353+ else
354354+ case unquote(input_module).from_json(var!(conn).body_params) do
355355+ {:ok, body} ->
356356+ Plug.Conn.assign(var!(conn), :body, body)
357357+358358+ {:error, reason} ->
359359+ var!(conn)
360360+ |> Plug.Conn.put_resp_content_type("application/json")
361361+ |> Plug.Conn.send_resp(
362362+ 400,
363363+ Jason.encode!(%{
364364+ "error" => "InvalidRequest",
365365+ "message" => "Invalid request body: #{inspect(reason)}"
366366+ })
367367+ )
368368+ |> Plug.Conn.halt()
369369+ end
370370+ end
371371+ end
372372+ ]
373373+ end
374374+375375+ # Emits a quoted expression that validates the incoming Content-Type header
376376+ # against the MIME type declared in the lexicon for a raw (non-JSON) input
377377+ # procedure. On success, the raw body is placed at `conn.assigns[:body]`.
378378+ # Skips if the conn is already halted by a previous step.
379379+ @spec build_raw_body_block(module()) :: [Macro.t()]
380380+ defp build_raw_body_block(raw_module) do
381381+ [
382382+ quote do
383383+ var!(conn) =
384384+ if var!(conn).halted do
385385+ var!(conn)
386386+ else
387387+ declared = unquote(raw_module).content_type()
388388+389389+ parsed_content_type =
390390+ var!(conn)
391391+ |> Plug.Conn.get_req_header("content-type")
392392+ |> List.first("")
393393+ |> Plug.Conn.Utils.content_type()
394394+395395+ with {:ok, type, subtype, _params} <- parsed_content_type,
396396+ actual <- "#{type}/#{subtype}",
397397+ true <-
398398+ declared == "*/*" or actual == declared or
399399+ (String.ends_with?(declared, "/*") and
400400+ String.starts_with?(actual, String.trim_trailing(declared, "*"))) do
401401+ var!(conn)
402402+ else
403403+ var!(conn)
404404+ |> Plug.Conn.put_resp_content_type("application/json")
405405+ |> Plug.Conn.send_resp(
406406+ 415,
407407+ JSON.encode!(%{
408408+ "error" => "InvalidRequest",
409409+ message: "Unsupported media type: expected #{declared}"
410410+ })
411411+ )
412412+ end
413413+ end
414414+ end
415415+ ]
416416+ end
417417+end
+38
lib/atex/xrpc/router/aud_plug.ex
···11+defmodule Atex.XRPC.Router.AudPlug do
22+ @moduledoc """
33+ Plug that populates `conn.private[:xrpc_aud]` from the `:service_did` app config.
44+55+ Injected automatically when using `Atex.XRPC.Router` (unless `plug_aud: false`
66+ is passed to `use`). Raises at runtime if `:service_did` is not configured,
77+ since auth validation requires a non-nil audience.
88+99+ ## Configuration
1010+1111+ config :atex, service_did: "did:web:my-service.example"
1212+ """
1313+1414+ import Plug.Conn
1515+1616+ @behaviour Plug
1717+1818+ @impl Plug
1919+ def init(opts), do: opts
2020+2121+ @impl Plug
2222+ def call(conn, _opts) do
2323+ aud =
2424+ Atex.Config.service_did() ||
2525+ raise """
2626+ Atex.XRPC.Router.AudPlug: :service_did is not configured.
2727+ Add the following to your config:
2828+2929+ config :atex, service_did: "did:web:my-service.example"
3030+3131+ Or disable automatic aud injection with:
3232+3333+ use Atex.XRPC.Router, plug_aud: false
3434+ """
3535+3636+ put_private(conn, :xrpc_aud, aud)
3737+ end
3838+end
+38
test/atex/lexicon_test.exs
···109109 end
110110111111 # ---------------------------------------------------------------------------
112112+ # Tests: raw-input procedure (encoding only, no schema)
113113+ # ---------------------------------------------------------------------------
114114+115115+ describe "procedure with raw input (encoding only)" do
116116+ test "does not generate an Input submodule" do
117117+ refute Code.ensure_loaded?(Lexicon.Test.UploadBlob.Input)
118118+ end
119119+120120+ test "root module exports content_type/0" do
121121+ assert function_exported?(Lexicon.Test.UploadBlob, :content_type, 0)
122122+ end
123123+124124+ test "content_type/0 returns the declared encoding" do
125125+ assert Lexicon.Test.UploadBlob.content_type() == "image/jpeg"
126126+ end
127127+128128+ test "root module has raw_input field in struct" do
129129+ assert Map.has_key?(%Lexicon.Test.UploadBlob{}, :raw_input)
130130+ end
131131+ end
132132+133133+ describe "procedure with wildcard raw input encoding" do
134134+ test "content_type/0 returns */*" do
135135+ assert Lexicon.Test.UploadAny.content_type() == "*/*"
136136+ end
137137+ end
138138+139139+ describe "procedure with JSON input schema" do
140140+ test "Input submodule exports content_type/0" do
141141+ assert function_exported?(Lexicon.Test.CreatePost.Input, :content_type, 0)
142142+ end
143143+144144+ test "Input.content_type/0 returns the declared encoding" do
145145+ assert Lexicon.Test.CreatePost.Input.content_type() == "application/json"
146146+ end
147147+ end
148148+149149+ # ---------------------------------------------------------------------------
112150 # Tests: union-typed query output (cross-NSID refs)
113151 # ---------------------------------------------------------------------------
114152
+400
test/atex/xrpc/router_test.exs
···11+defmodule Atex.XRPC.RouterTest do
22+ use ExUnit.Case, async: true
33+44+ import Plug.Test
55+ import Plug.Conn
66+77+ # ---------------------------------------------------------------------------
88+ # Stub lexicon modules used by macro-expansion tests.
99+ # These live outside of the test module so they are available at compile time
1010+ # when the inline router modules below are defined.
1111+ # ---------------------------------------------------------------------------
1212+1313+ defmodule StubLexicon do
1414+ @moduledoc false
1515+ def id, do: "com.example.stubQuery"
1616+1717+ defmodule Params do
1818+ @moduledoc false
1919+ def from_json(%{"name" => name}) when is_binary(name), do: {:ok, %{name: name}}
2020+ def from_json(_), do: {:error, "name is required and must be a string"}
2121+ end
2222+ end
2323+2424+ defmodule StubProcedureLexicon do
2525+ @moduledoc false
2626+ def id, do: "com.example.stubProcedure"
2727+2828+ defmodule Params do
2929+ @moduledoc false
3030+ # Query params arrive as strings; version is optional.
3131+ def from_json(%{"version" => v}) when is_binary(v) or is_integer(v),
3232+ do: {:ok, %{version: v}}
3333+3434+ def from_json(_), do: {:ok, %{}}
3535+ end
3636+3737+ defmodule Input do
3838+ @moduledoc false
3939+ def from_json(%{"text" => t}) when is_binary(t), do: {:ok, %{text: t}}
4040+ def from_json(_), do: {:error, "text is required"}
4141+ end
4242+ end
4343+4444+ defmodule StubNoParamsLexicon do
4545+ @moduledoc false
4646+ def id, do: "com.example.noParams"
4747+ end
4848+4949+ # ---------------------------------------------------------------------------
5050+ # Router fixtures
5151+ # ---------------------------------------------------------------------------
5252+5353+ defmodule StringNSIDRouter do
5454+ use Plug.Router
5555+ use Atex.XRPC.Router, plug_aud: false
5656+5757+ plug :match
5858+ plug :dispatch
5959+6060+ query "com.example.stringQuery" do
6161+ send_resp(conn, 200, "query-ok")
6262+ end
6363+6464+ procedure "com.example.stringProcedure" do
6565+ send_resp(conn, 200, "procedure-ok")
6666+ end
6767+6868+ match _ do
6969+ send_resp(conn, 404, "not found")
7070+ end
7171+ end
7272+7373+ defmodule ModuleRouter do
7474+ use Plug.Router
7575+ use Atex.XRPC.Router, plug_aud: false
7676+7777+ plug Plug.Parsers,
7878+ parsers: [:json],
7979+ pass: ["application/json"],
8080+ json_decoder: Jason
8181+8282+ plug :match
8383+ plug :dispatch
8484+8585+ query Atex.XRPC.RouterTest.StubLexicon do
8686+ send_resp(conn, 200, Jason.encode!(conn.assigns[:params]))
8787+ end
8888+8989+ procedure Atex.XRPC.RouterTest.StubProcedureLexicon do
9090+ result = %{
9191+ params: conn.assigns[:params],
9292+ body: conn.assigns[:body]
9393+ }
9494+9595+ send_resp(conn, 200, Jason.encode!(result))
9696+ end
9797+9898+ query Atex.XRPC.RouterTest.StubNoParamsLexicon do
9999+ has_params = Map.has_key?(conn.assigns, :params)
100100+ send_resp(conn, 200, if(has_params, do: "has-params", else: "no-params"))
101101+ end
102102+103103+ match _ do
104104+ send_resp(conn, 404, "not found")
105105+ end
106106+ end
107107+108108+ defmodule RequireAuthRouter do
109109+ use Plug.Router
110110+ use Atex.XRPC.Router, plug_aud: false
111111+112112+ plug :match
113113+ plug :dispatch
114114+115115+ query "com.example.authed", require_auth: true do
116116+ send_resp(conn, 200, "authed-ok")
117117+ end
118118+119119+ query "com.example.softAuth" do
120120+ has_jwt = Map.has_key?(conn.assigns, :current_jwt)
121121+ send_resp(conn, 200, if(has_jwt, do: "has-jwt", else: "no-jwt"))
122122+ end
123123+124124+ match _ do
125125+ send_resp(conn, 404, "not found")
126126+ end
127127+ end
128128+129129+ # ---------------------------------------------------------------------------
130130+ # Helpers
131131+ # ---------------------------------------------------------------------------
132132+133133+ defp call(router, method, path, opts \\ []) do
134134+ headers = Keyword.get(opts, :headers, [])
135135+ body = Keyword.get(opts, :body, "")
136136+ query_string = Keyword.get(opts, :query_string, "")
137137+138138+ conn =
139139+ method
140140+ |> conn(path <> if(query_string != "", do: "?#{query_string}", else: ""), body)
141141+ |> Map.put(:req_headers, headers)
142142+143143+ conn =
144144+ if aud = Keyword.get(opts, :xrpc_aud) do
145145+ put_private(conn, :xrpc_aud, aud)
146146+ else
147147+ conn
148148+ end
149149+150150+ router.call(conn, router.init([]))
151151+ end
152152+153153+ defp json_body(conn) do
154154+ Jason.decode!(conn.resp_body)
155155+ end
156156+157157+ # ---------------------------------------------------------------------------
158158+ # Tests: string NSID routing
159159+ # ---------------------------------------------------------------------------
160160+161161+ describe "query with string NSID" do
162162+ test "routes GET /xrpc/<nsid>" do
163163+ conn = call(StringNSIDRouter, :get, "/xrpc/com.example.stringQuery")
164164+ assert conn.status == 200
165165+ assert conn.resp_body == "query-ok"
166166+ end
167167+168168+ test "does not match POST" do
169169+ conn = call(StringNSIDRouter, :post, "/xrpc/com.example.stringQuery")
170170+ assert conn.status == 404
171171+ end
172172+173173+ test "does not match unrelated paths" do
174174+ conn = call(StringNSIDRouter, :get, "/xrpc/com.example.other")
175175+ assert conn.status == 404
176176+ end
177177+ end
178178+179179+ describe "procedure with string NSID" do
180180+ test "routes POST /xrpc/<nsid>" do
181181+ conn = call(StringNSIDRouter, :post, "/xrpc/com.example.stringProcedure")
182182+ assert conn.status == 200
183183+ assert conn.resp_body == "procedure-ok"
184184+ end
185185+186186+ test "does not match GET" do
187187+ conn = call(StringNSIDRouter, :get, "/xrpc/com.example.stringProcedure")
188188+ assert conn.status == 404
189189+ end
190190+ end
191191+192192+ # ---------------------------------------------------------------------------
193193+ # Tests: module atom routing and param/body validation
194194+ # ---------------------------------------------------------------------------
195195+196196+ describe "query with lexicon module (has Params)" do
197197+ test "validates and assigns params on success" do
198198+ conn = call(ModuleRouter, :get, "/xrpc/com.example.stubQuery", query_string: "name=alice")
199199+ assert conn.status == 200
200200+ assert Jason.decode!(conn.resp_body) == %{"name" => "alice"}
201201+ end
202202+203203+ test "halts with 400 on invalid params" do
204204+ conn = call(ModuleRouter, :get, "/xrpc/com.example.stubQuery")
205205+ assert conn.status == 400
206206+ body = json_body(conn)
207207+ assert body["error"] == "InvalidRequest"
208208+ assert is_binary(body["message"])
209209+ end
210210+ end
211211+212212+ describe "query with lexicon module (no Params submodule)" do
213213+ test "does not assign :params" do
214214+ conn = call(ModuleRouter, :get, "/xrpc/com.example.noParams")
215215+ assert conn.status == 200
216216+ assert conn.resp_body == "no-params"
217217+ end
218218+ end
219219+220220+ describe "procedure with lexicon module (has Params and Input)" do
221221+ test "validates and assigns body on success" do
222222+ conn =
223223+ call(ModuleRouter, :post, "/xrpc/com.example.stubProcedure",
224224+ body: Jason.encode!(%{"text" => "hello"}),
225225+ headers: [{"content-type", "application/json"}]
226226+ )
227227+228228+ assert conn.status == 200
229229+ result = Jason.decode!(conn.resp_body)
230230+ assert result["body"] == %{"text" => "hello"}
231231+ end
232232+233233+ test "assigns params when query string present" do
234234+ conn =
235235+ call(ModuleRouter, :post, "/xrpc/com.example.stubProcedure",
236236+ query_string: "version=1",
237237+ body: Jason.encode!(%{"text" => "hello"}),
238238+ headers: [{"content-type", "application/json"}]
239239+ )
240240+241241+ assert conn.status == 200
242242+ result = Jason.decode!(conn.resp_body)
243243+ assert result["params"] == %{"version" => "1"}
244244+ end
245245+246246+ test "halts with 400 on invalid body" do
247247+ conn =
248248+ call(ModuleRouter, :post, "/xrpc/com.example.stubProcedure",
249249+ body: Jason.encode!(%{"wrong" => "field"}),
250250+ headers: [{"content-type", "application/json"}]
251251+ )
252252+253253+ assert conn.status == 400
254254+ body = json_body(conn)
255255+ assert body["error"] == "InvalidRequest"
256256+ end
257257+ end
258258+259259+ # ---------------------------------------------------------------------------
260260+ # Tests: auth behaviour
261261+ # ---------------------------------------------------------------------------
262262+263263+ describe "require_auth: true" do
264264+ test "returns 401 when no Authorization header is present" do
265265+ conn =
266266+ call(RequireAuthRouter, :get, "/xrpc/com.example.authed", xrpc_aud: "did:web:example.com")
267267+268268+ assert conn.status == 401
269269+ body = json_body(conn)
270270+ assert body["error"] == "AuthRequired"
271271+ end
272272+273273+ test "returns 401 when Authorization header is malformed" do
274274+ conn =
275275+ call(RequireAuthRouter, :get, "/xrpc/com.example.authed",
276276+ headers: [{"authorization", "NotBearer bad"}],
277277+ xrpc_aud: "did:web:example.com"
278278+ )
279279+280280+ assert conn.status == 401
281281+ body = json_body(conn)
282282+ assert body["error"] == "AuthRequired"
283283+ end
284284+285285+ test "halts and does not run the block when auth fails" do
286286+ conn =
287287+ call(RequireAuthRouter, :get, "/xrpc/com.example.authed", xrpc_aud: "did:web:example.com")
288288+289289+ assert conn.halted
290290+ end
291291+ end
292292+293293+ describe "soft auth (require_auth: false, default)" do
294294+ test "does not assign :current_jwt when no Authorization header" do
295295+ conn =
296296+ call(RequireAuthRouter, :get, "/xrpc/com.example.softAuth",
297297+ xrpc_aud: "did:web:example.com"
298298+ )
299299+300300+ assert conn.status == 200
301301+ assert conn.resp_body == "no-jwt"
302302+ end
303303+304304+ test "does not halt when no Authorization header" do
305305+ conn =
306306+ call(RequireAuthRouter, :get, "/xrpc/com.example.softAuth",
307307+ xrpc_aud: "did:web:example.com"
308308+ )
309309+310310+ refute conn.halted
311311+ end
312312+ end
313313+314314+ # ---------------------------------------------------------------------------
315315+ # Tests: AudPlug
316316+ # ---------------------------------------------------------------------------
317317+318318+ describe "Atex.XRPC.Router.AudPlug" do
319319+ test "raises at runtime when :service_did is not configured" do
320320+ original = Application.get_env(:atex, :service_did)
321321+322322+ try do
323323+ Application.delete_env(:atex, :service_did)
324324+325325+ assert_raise RuntimeError, ~r/:service_did is not configured/, fn ->
326326+ conn(:get, "/")
327327+ |> Atex.XRPC.Router.AudPlug.call([])
328328+ end
329329+ after
330330+ if original do
331331+ Application.put_env(:atex, :service_did, original)
332332+ end
333333+ end
334334+ end
335335+336336+ test "puts :service_did into conn.private[:xrpc_aud]" do
337337+ original = Application.get_env(:atex, :service_did)
338338+339339+ try do
340340+ Application.put_env(:atex, :service_did, "did:web:test.example")
341341+342342+ conn =
343343+ conn(:get, "/")
344344+ |> Atex.XRPC.Router.AudPlug.call([])
345345+346346+ assert conn.private[:xrpc_aud] == "did:web:test.example"
347347+ after
348348+ if original do
349349+ Application.put_env(:atex, :service_did, original)
350350+ else
351351+ Application.delete_env(:atex, :service_did)
352352+ end
353353+ end
354354+ end
355355+ end
356356+357357+ # ---------------------------------------------------------------------------
358358+ # Tests: compile-time NSID validation
359359+ # ---------------------------------------------------------------------------
360360+361361+ describe "invalid NSID string" do
362362+ test "raises CompileError at macro expansion" do
363363+ assert_raise CompileError, ~r/invalid NSID/, fn ->
364364+ Code.compile_string("""
365365+ defmodule BadNSIDRouter do
366366+ use Plug.Router
367367+ use Atex.XRPC.Router, plug_aud: false
368368+ plug :match
369369+ plug :dispatch
370370+ query "not-a-valid-nsid" do
371371+ send_resp(conn, 200, "")
372372+ end
373373+ end
374374+ """)
375375+ end
376376+ end
377377+ end
378378+379379+ describe "module without id/0" do
380380+ test "raises CompileError at macro expansion" do
381381+ assert_raise CompileError, ~r/does not define id\/0/, fn ->
382382+ Code.compile_string("""
383383+ defmodule NoIdModule do
384384+ def something, do: :ok
385385+ end
386386+387387+ defmodule BadModuleRouter do
388388+ use Plug.Router
389389+ use Atex.XRPC.Router, plug_aud: false
390390+ plug :match
391391+ plug :dispatch
392392+ query NoIdModule do
393393+ send_resp(conn, 200, "")
394394+ end
395395+ end
396396+ """)
397397+ end
398398+ end
399399+ end
400400+end
+40
test/support/lexicon_fixtures.ex
···169169 })
170170end
171171172172+# Procedure with a raw (non-JSON) input - encoding only, no schema.
173173+# NSID "lexicon.test.uploadBlob" -> Lexicon.Test.UploadBlob
174174+defmodule Lexicon.Test.UploadBlob do
175175+ @moduledoc false
176176+ use Atex.Lexicon
177177+178178+ deflexicon(%{
179179+ "lexicon" => 1,
180180+ "id" => "lexicon.test.uploadBlob",
181181+ "defs" => %{
182182+ "main" => %{
183183+ "type" => "procedure",
184184+ "input" => %{
185185+ "encoding" => "image/jpeg"
186186+ }
187187+ }
188188+ }
189189+ })
190190+end
191191+192192+# Procedure with a wildcard raw input encoding.
193193+# NSID "lexicon.test.uploadAny" -> Lexicon.Test.UploadAny
194194+defmodule Lexicon.Test.UploadAny do
195195+ @moduledoc false
196196+ use Atex.Lexicon
197197+198198+ deflexicon(%{
199199+ "lexicon" => 1,
200200+ "id" => "lexicon.test.uploadAny",
201201+ "defs" => %{
202202+ "main" => %{
203203+ "type" => "procedure",
204204+ "input" => %{
205205+ "encoding" => "*/*"
206206+ }
207207+ }
208208+ }
209209+ })
210210+end
211211+172212# Query whose output.schema is a `union` of two cross-NSID refs.
173213# NSID "lexicon.test.getUnion" -> Lexicon.Test.GetUnion
174214defmodule Lexicon.Test.GetUnion do