An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization
24
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: xrpc router macros

+1344 -18
+17 -8
CHANGELOG.md
··· 10 10 11 11 ### Breaking Changes 12 12 13 - - The `Atex.IdentityResolver` config key has been replaced with a flat config option. 14 - Update your config from: 13 + - The `Atex.IdentityResolver` config key has been replaced with a flat config 14 + option. Update your config from: 15 15 16 16 ```elixir 17 17 config :atex, Atex.IdentityResolver, ··· 27 27 28 28 - `Atex.Config.IdentityResolver` has been renamed to `Atex.Config`. 29 29 - `Atex.IdentityResolver.DIDDocument` has been renamed to `Atex.DID.Document`. 30 - - Replace existing `Atex.DID.Document.new/1` method with the method previously named `from_json/1`. 30 + - Replace existing `Atex.DID.Document.new/1` method with the method previously 31 + named `from_json/1`. 31 32 32 33 ### Added 33 34 34 - - `Atex.Crypto` module for performing AT Protocol-related cryptographic operations. 35 - - `Atex.PLC` module for interacting with [a did:plc directory API](https://web.plc.directory/). 36 - - `Atex.ServiceAuth` module for validating [inter-service authentication tokens](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 35 + - `Atex.Crypto` module for performing AT Protocol-related cryptographic 36 + operations. 37 + - `Atex.PLC` module for interacting with 38 + [a did:plc directory API](https://web.plc.directory/). 39 + - `Atex.ServiceAuth` module for validating 40 + [inter-service authentication tokens](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 37 41 - Various improvements to `Atex.Did.Document` 38 - - Add `Atex.DID.Document.Service` and `Atex.DID.Document.VerificationMethod` sub-structs. 39 - - Add `to_json/1` methods and `JSON.Encoder` protocols for easy conversion to camelCase JSON. 42 + - Add `Atex.DID.Document.Service` and `Atex.DID.Document.VerificationMethod` 43 + sub-structs. 44 + - Add `to_json/1` methods and `JSON.Encoder` protocols for easy conversion to 45 + camelCase JSON. 46 + - `Atex.XRPC.Router` module with `query/3` and `procedure/3` macros for easily 47 + building XRPC server routes inside a `Plug.Router`, with built-in service auth 48 + validation and validation if passed the name of a module using `deflexicon`. 40 49 - `deflexicon` now emits `content_type/0` functions (on `Input` submodules for typed JSON bodies, 41 50 otherwise on the root module) for procedures. 42 51
+1 -1
README.md
··· 17 17 - [ ] Repository reading and manipulation (MST & CAR) 18 18 - [x] Service auth 19 19 - [x] PLC client 20 - - [ ] XRPC server router 20 + - [x] XRPC server router 21 21 22 22 Looking to use a data subscription service like the Firehose, [Jetstream], or [Tap]? Check out [Drinkup]. 23 23
+2 -1
config/runtime.exs
··· 10 10 key_id: "awooga" 11 11 12 12 config :atex, 13 - plc_directory_url: "https://plc.directory" 13 + plc_directory_url: "https://plc.directory", 14 + service_did: "did:web:setsuna.prawn-galaxy.ts.net"
+14 -3
examples/service_auth.ex
··· 1 1 defmodule ServiceAuthExample do 2 2 require Logger 3 3 use Plug.Router 4 + use Atex.XRPC.Router 4 5 5 6 plug :match 6 7 plug :dispatch ··· 37 38 |> send_resp(200, @did_doc) 38 39 end 39 40 40 - get "/xrpc/com.ovyerus.example" do 41 + query "com.example.test" do 41 42 IO.inspect(conn) 43 + conn |> send_resp(200, "test") 44 + end 42 45 43 - conn 44 - |> send_resp(200, "") 46 + # See `./service_auth` for module & lexicon definitions. 47 + query Com.Example.GetProfile do 48 + IO.inspect(conn.assigns, label: "getProfile") 49 + conn |> send_resp(200, "test") 50 + end 51 + 52 + # TODO: why did body not validate 53 + procedure Com.Example.CreatePost, require_auth: true do 54 + IO.inspect(conn.assigns, label: "createPost") 55 + conn |> send_resp(200, "test") 45 56 end 46 57 47 58 match _ do
+72
examples/service_auth/atproto/com/example/createPost.ex
··· 1 + defmodule Com.Example.CreatePost do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Creates a post record and returns its AT-URI and CID.", 9 + "errors" => [ 10 + %{ 11 + "description" => "The post body failed content validation.", 12 + "name" => "InvalidContent" 13 + } 14 + ], 15 + "input" => %{ 16 + "encoding" => "application/json", 17 + "schema" => %{"ref" => "#postInput", "type" => "ref"} 18 + }, 19 + "output" => %{ 20 + "encoding" => "application/json", 21 + "schema" => %{ 22 + "properties" => %{ 23 + "cid" => %{"format" => "cid", "type" => "string"}, 24 + "uri" => %{"format" => "at-uri", "type" => "string"} 25 + }, 26 + "required" => ["uri", "cid"], 27 + "type" => "object" 28 + } 29 + }, 30 + "parameters" => %{ 31 + "properties" => %{ 32 + "validate" => %{ 33 + "default" => true, 34 + "description" => "When false, skip Lexicon validation of the post body.", 35 + "type" => "boolean" 36 + } 37 + }, 38 + "type" => "params" 39 + }, 40 + "type" => "procedure" 41 + }, 42 + "postInput" => %{ 43 + "description" => "Input body for creating a post.", 44 + "properties" => %{ 45 + "createdAt" => %{ 46 + "description" => 47 + "Client-supplied creation timestamp. Defaults to server time if omitted.", 48 + "format" => "datetime", 49 + "type" => "string" 50 + }, 51 + "langs" => %{ 52 + "description" => "BCP-47 language tags describing the content language(s).", 53 + "items" => %{"format" => "language", "type" => "string"}, 54 + "maxLength" => 3, 55 + "type" => "array" 56 + }, 57 + "text" => %{ 58 + "description" => "The plain-text content of the post.", 59 + "maxGraphemes" => 300, 60 + "maxLength" => 3000, 61 + "type" => "string" 62 + } 63 + }, 64 + "required" => ["text"], 65 + "type" => "object" 66 + } 67 + }, 68 + "description" => "Create a new post in a user's repository.", 69 + "id" => "com.example.createPost", 70 + "lexicon" => 1 71 + }) 72 + end
+58
examples/service_auth/atproto/com/example/getProfile.ex
··· 1 + defmodule Com.Example.GetProfile do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Returns profile information for the specified account.", 9 + "errors" => [ 10 + %{ 11 + "description" => "No account exists for the given actor.", 12 + "name" => "AccountNotFound" 13 + } 14 + ], 15 + "output" => %{ 16 + "encoding" => "application/json", 17 + "schema" => %{"ref" => "#profileView", "type" => "ref"} 18 + }, 19 + "parameters" => %{ 20 + "properties" => %{ 21 + "actor" => %{ 22 + "description" => "The DID or handle of the account to fetch.", 23 + "format" => "at-identifier", 24 + "type" => "string" 25 + } 26 + }, 27 + "required" => ["actor"], 28 + "type" => "params" 29 + }, 30 + "type" => "query" 31 + }, 32 + "profileView" => %{ 33 + "description" => "A public view of a user profile.", 34 + "properties" => %{ 35 + "avatar" => %{"format" => "uri", "type" => "string"}, 36 + "createdAt" => %{"format" => "datetime", "type" => "string"}, 37 + "description" => %{ 38 + "maxGraphemes" => 256, 39 + "maxLength" => 2560, 40 + "type" => "string" 41 + }, 42 + "did" => %{"format" => "did", "type" => "string"}, 43 + "displayName" => %{ 44 + "maxGraphemes" => 64, 45 + "maxLength" => 640, 46 + "type" => "string" 47 + }, 48 + "handle" => %{"format" => "handle", "type" => "string"} 49 + }, 50 + "required" => ["did", "handle"], 51 + "type" => "object" 52 + } 53 + }, 54 + "description" => "Fetch a user profile by DID or handle.", 55 + "id" => "com.example.getProfile", 56 + "lexicon" => 1 57 + }) 58 + end
+47
examples/service_auth/atproto/com/example/uploadBlob.ex
··· 1 + defmodule Com.Example.UploadBlob do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "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.", 10 + "errors" => [ 11 + %{ 12 + "description" => 13 + "The Content-Type of the uploaded data is not an accepted image MIME type.", 14 + "name" => "InvalidMimeType" 15 + }, 16 + %{ 17 + "description" => "The uploaded blob exceeds the maximum permitted size.", 18 + "name" => "BlobTooLarge" 19 + } 20 + ], 21 + "input" => %{ 22 + "description" => 23 + "Raw binary content of the blob. Supported MIME types: image/jpeg, image/png, image/gif, image/webp.", 24 + "encoding" => "*/*" 25 + }, 26 + "output" => %{ 27 + "encoding" => "application/json", 28 + "schema" => %{ 29 + "properties" => %{ 30 + "blob" => %{ 31 + "accept" => ["image/*"], 32 + "maxSize" => 1_000_000, 33 + "type" => "blob" 34 + } 35 + }, 36 + "required" => ["blob"], 37 + "type" => "object" 38 + } 39 + }, 40 + "type" => "procedure" 41 + } 42 + }, 43 + "description" => "Upload a binary blob (e.g. an image) and receive a blob reference.", 44 + "id" => "com.example.uploadBlob", 45 + "lexicon" => 1 46 + }) 47 + end
+78
examples/service_auth/lexicon/com.example.createPost.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.createPost", 4 + "description": "Create a new post in a user's repository.", 5 + "defs": { 6 + "main": { 7 + "type": "procedure", 8 + "description": "Creates a post record and returns its AT-URI and CID.", 9 + "parameters": { 10 + "type": "params", 11 + "properties": { 12 + "validate": { 13 + "type": "boolean", 14 + "default": true, 15 + "description": "When false, skip Lexicon validation of the post body." 16 + } 17 + } 18 + }, 19 + "input": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "ref", 23 + "ref": "#postInput" 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["uri", "cid"], 31 + "properties": { 32 + "uri": { 33 + "type": "string", 34 + "format": "at-uri" 35 + }, 36 + "cid": { 37 + "type": "string", 38 + "format": "cid" 39 + } 40 + } 41 + } 42 + }, 43 + "errors": [ 44 + { 45 + "name": "InvalidContent", 46 + "description": "The post body failed content validation." 47 + } 48 + ] 49 + }, 50 + "postInput": { 51 + "type": "object", 52 + "description": "Input body for creating a post.", 53 + "required": ["text"], 54 + "properties": { 55 + "text": { 56 + "type": "string", 57 + "maxGraphemes": 300, 58 + "maxLength": 3000, 59 + "description": "The plain-text content of the post." 60 + }, 61 + "langs": { 62 + "type": "array", 63 + "items": { 64 + "type": "string", 65 + "format": "language" 66 + }, 67 + "maxLength": 3, 68 + "description": "BCP-47 language tags describing the content language(s)." 69 + }, 70 + "createdAt": { 71 + "type": "string", 72 + "format": "datetime", 73 + "description": "Client-supplied creation timestamp. Defaults to server time if omitted." 74 + } 75 + } 76 + } 77 + } 78 + }
+68
examples/service_auth/lexicon/com.example.getProfile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.getProfile", 4 + "description": "Fetch a user profile by DID or handle.", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "description": "Returns profile information for the specified account.", 9 + "parameters": { 10 + "type": "params", 11 + "required": ["actor"], 12 + "properties": { 13 + "actor": { 14 + "type": "string", 15 + "format": "at-identifier", 16 + "description": "The DID or handle of the account to fetch." 17 + } 18 + } 19 + }, 20 + "output": { 21 + "encoding": "application/json", 22 + "schema": { 23 + "type": "ref", 24 + "ref": "#profileView" 25 + } 26 + }, 27 + "errors": [ 28 + { 29 + "name": "AccountNotFound", 30 + "description": "No account exists for the given actor." 31 + } 32 + ] 33 + }, 34 + "profileView": { 35 + "type": "object", 36 + "description": "A public view of a user profile.", 37 + "required": ["did", "handle"], 38 + "properties": { 39 + "did": { 40 + "type": "string", 41 + "format": "did" 42 + }, 43 + "handle": { 44 + "type": "string", 45 + "format": "handle" 46 + }, 47 + "displayName": { 48 + "type": "string", 49 + "maxGraphemes": 64, 50 + "maxLength": 640 51 + }, 52 + "description": { 53 + "type": "string", 54 + "maxGraphemes": 256, 55 + "maxLength": 2560 56 + }, 57 + "avatar": { 58 + "type": "string", 59 + "format": "uri" 60 + }, 61 + "createdAt": { 62 + "type": "string", 63 + "format": "datetime" 64 + } 65 + } 66 + } 67 + } 68 + }
+39
examples/service_auth/lexicon/com.example.uploadBlob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.example.uploadBlob", 4 + "description": "Upload a binary blob (e.g. an image) and receive a blob reference.", 5 + "defs": { 6 + "main": { 7 + "type": "procedure", 8 + "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.", 9 + "input": { 10 + "encoding": "*/*", 11 + "description": "Raw binary content of the blob. Supported MIME types: image/jpeg, image/png, image/gif, image/webp." 12 + }, 13 + "output": { 14 + "encoding": "application/json", 15 + "schema": { 16 + "type": "object", 17 + "required": ["blob"], 18 + "properties": { 19 + "blob": { 20 + "type": "blob", 21 + "accept": ["image/*"], 22 + "maxSize": 1000000 23 + } 24 + } 25 + } 26 + }, 27 + "errors": [ 28 + { 29 + "name": "InvalidMimeType", 30 + "description": "The Content-Type of the uploaded data is not an accepted image MIME type." 31 + }, 32 + { 33 + "name": "BlobTooLarge", 34 + "description": "The uploaded blob exceeds the maximum permitted size." 35 + } 36 + ] 37 + } 38 + } 39 + }
+13 -1
lib/atex/config.ex
··· 7 7 The following keys are supported under `config :atex`: 8 8 9 9 config :atex, 10 - plc_directory_url: "https://plc.directory" 10 + plc_directory_url: "https://plc.directory", 11 + service_did: "did:web:my-service.example" 11 12 12 13 - `:plc_directory_url` - Base URL for the did:plc directory server. 13 14 Defaults to `"https://plc.directory"`. 15 + - `:service_did` - The DID of this service, used as the expected `aud` claim 16 + when validating incoming inter-service auth JWTs via `Atex.XRPC.Router`. 17 + Required when using `Atex.XRPC.Router` with auth enabled. 14 18 """ 15 19 16 20 @doc """ ··· 22 26 @spec directory_url :: String.t() 23 27 def directory_url, 24 28 do: Application.get_env(:atex, :plc_directory_url, "https://plc.directory") 29 + 30 + @doc """ 31 + Returns the configured service DID to be used for validation service auth tokens. 32 + 33 + Reads `:service_did` from the `:atex application environment. 34 + """ 35 + @spec service_did :: String.t() | nil 36 + def service_did, do: Application.get_env(:atex, :service_did) 25 37 end
+2 -2
lib/atex/service_auth.ex
··· 57 57 def validate_conn(conn, opts \\ []) do 58 58 case get_req_header(conn, "authorization") do 59 59 ["Bearer " <> jwt] -> validate_jwt(jwt, opts) 60 - [_] -> :error 61 - _ -> :error 60 + [_] -> {:error, :no_header} 61 + _ -> {:error, :no_header} 62 62 end 63 63 end 64 64
-2
lib/atex/xrpc/login_client.ex
··· 114 114 @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) :: 115 115 {:ok, Req.Response.t(), t()} | {:error, any()} 116 116 defp handle_failure(client, response, request) do 117 - IO.inspect(response, label: "got failure") 118 - 119 117 if auth_error?(response.body) and client.refresh_token do 120 118 case refresh(client) do 121 119 {:ok, client} ->
+417
lib/atex/xrpc/router.ex
··· 1 + defmodule Atex.XRPC.Router do 2 + @moduledoc """ 3 + Routing utilities for building ATProto XRPC server endpoints. 4 + 5 + Provides the `query/3` and `procedure/3` macros that expand to 6 + `Plug.Router.get/3` and `Plug.Router.post/3` respectively, with built-in 7 + handling for: 8 + 9 + - NSID-prefixed route paths (`/xrpc/<nsid>`) 10 + - Service auth validation via `Atex.ServiceAuth` 11 + - Query param and body validation for lexicon modules generated with `Atex.Lexicon.deflexicon/1`. 12 + 13 + ## Usage 14 + 15 + defmodule MyAPI do 16 + use Plug.Router 17 + use Atex.XRPC.Router 18 + 19 + plug :match 20 + plug :dispatch 21 + 22 + # Matches GET /xrpc/com.example.getProfile 23 + query "com.example.getProfile" do 24 + send_resp(conn, 200, "ok") 25 + end 26 + 27 + # Matches POST /xrpc/com.example.createPost, enforces auth 28 + procedure Com.Example.CreatePost, require_auth: true do 29 + # conn.assigns[:params] and conn.assigns[:body] are populated 30 + # when the lexicon module defines Params/Input submodules 31 + send_resp(conn, 200, "created") 32 + end 33 + end 34 + 35 + ## Authentication 36 + 37 + Authentication uses `Atex.ServiceAuth.validate_conn/2`. The audience (`aud`) 38 + is read from `conn.private[:xrpc_aud]`, which is populated automatically by 39 + `Atex.XRPC.Router.AudPlug` that reads `:service_did` from app config. To 40 + disable automatic plug injection: 41 + 42 + use Atex.XRPC.Router, plug_aud: false 43 + 44 + When `require_auth: true` is passed to a route macro, a missing or invalid 45 + token halts with a `401` response. Otherwise auth is attempted softly - 46 + on success the decoded JWT is placed at `conn.assigns[:current_jwt]`, on 47 + failure the conn is left untouched. 48 + 49 + ## Validation 50 + 51 + When a lexicon module atom is passed, the macro checks at compile time whether 52 + `<Module>.Params` and/or `<Module>.Input` exist. If they do, their 53 + `from_json/1` is called at request time: 54 + 55 + - Valid params → `conn.assigns[:params]` 56 + - Valid body → `conn.assigns[:body]` 57 + - Either failing → halts with a `400` response 58 + """ 59 + 60 + @doc false 61 + defmacro __using__(opts \\ []) do 62 + plug_aud = Keyword.get(opts, :plug_aud, true) 63 + 64 + quote do 65 + import Atex.XRPC.Router, only: [query: 2, query: 3, procedure: 2, procedure: 3] 66 + 67 + if unquote(plug_aud) do 68 + plug Atex.XRPC.Router.AudPlug 69 + end 70 + end 71 + end 72 + 73 + @doc """ 74 + Defines a GET route for an XRPC query. 75 + 76 + The first argument is either: 77 + 78 + - A plain string NSID (e.g. `"com.example.getProfile"`) - validated at 79 + compile time. 80 + - A lexicon module atom (e.g. `Com.Example.GetProfile`) - the NSID is 81 + fetched from `module.id()` at compile time. 82 + 83 + ## Options 84 + 85 + - `:require_auth` - when `true`, requests without a valid service auth token 86 + are rejected with a `401`. Defaults to `false`. 87 + 88 + ## Assigns 89 + 90 + - `:current_jwt` - the decoded `JOSE.JWT` struct, set on successful auth. 91 + - `:params` - validated params struct, set when the lexicon module 92 + defines a `Params` submodule. 93 + 94 + ## Examples 95 + 96 + query "com.example.getTimeline", require_auth: true do 97 + send_resp(conn, 200, "ok") 98 + end 99 + 100 + query Com.Example.GetTimeline do 101 + send_resp(conn, 200, "ok") 102 + end 103 + """ 104 + defmacro query(nsid_or_module, opts \\ [], do: block) do 105 + {nsid, params_module} = resolve_nsid_and_submodule(nsid_or_module, :Params, __CALLER__) 106 + require_auth = Keyword.get(opts, :require_auth, false) 107 + path = "/xrpc/#{nsid}" 108 + 109 + auth_block = build_auth_block(nsid, require_auth) 110 + params_block = if params_module, do: build_params_block(params_module), else: [] 111 + 112 + quote do 113 + get unquote(path) do 114 + var!(conn) = Plug.Conn.fetch_query_params(var!(conn)) 115 + unquote_splicing(auth_block) 116 + unquote_splicing(params_block) 117 + 118 + if var!(conn).halted do 119 + var!(conn) 120 + else 121 + unquote(block) 122 + end 123 + end 124 + end 125 + end 126 + 127 + @doc """ 128 + Defines a POST route for an XRPC procedure. 129 + 130 + The first argument is either: 131 + 132 + - A plain string NSID (e.g. `"com.example.createPost"`) - validated at 133 + compile time. 134 + - A lexicon module atom (e.g. `Com.Example.CreatePost`) - the NSID is 135 + fetched from `module.id()` at compile time. 136 + 137 + ## Options 138 + 139 + - `:require_auth` - when `true`, requests without a valid service auth token 140 + are rejected with a `401`. Defaults to `false`. 141 + 142 + ## Assigns 143 + 144 + - `:current_jwt` - the decoded `JOSE.JWT` struct, set on successful auth. 145 + - `:params` - validated params struct, set when the lexicon module 146 + defines a `Params` submodule. 147 + - `:body` - validated input struct, set when the lexicon module 148 + defines an `Input` submodule. 149 + 150 + ## Non-JSON payloads 151 + 152 + If a lexicon procedure defines an `input` with an encoding without an `object` 153 + schema, this will simply validate the incoming `Content-Type` header against the 154 + requested encoding. Nothing happens on success, you will need to read `conn`'s 155 + body as usual and do extra validation yourself, as clients may lie about their content. 156 + Wildcards are handled correctly as per the atproto documentation. 157 + 158 + ## Examples 159 + 160 + procedure "com.example.createPost", require_auth: true do 161 + send_resp(conn, 200, "created") 162 + end 163 + 164 + procedure Com.Example.CreatePost, require_auth: true do 165 + # conn.assigns[:body] contains the validated Input struct 166 + send_resp(conn, 200, "created") 167 + end 168 + """ 169 + defmacro procedure(nsid_or_module, opts \\ [], do: block) do 170 + {nsid, input_module} = resolve_nsid_and_submodule(nsid_or_module, :Input, __CALLER__) 171 + {_nsid, params_module} = resolve_nsid_and_submodule(nsid_or_module, :Params, __CALLER__) 172 + raw_input_module = resolve_raw_input_module(nsid_or_module, input_module, __CALLER__) 173 + require_auth = Keyword.get(opts, :require_auth, false) 174 + path = "/xrpc/#{nsid}" 175 + 176 + auth_block = build_auth_block(nsid, require_auth) 177 + params_block = if params_module, do: build_params_block(params_module), else: [] 178 + 179 + body_block = 180 + cond do 181 + input_module -> build_body_block(input_module) 182 + raw_input_module -> build_raw_body_block(raw_input_module) 183 + true -> [] 184 + end 185 + 186 + quote do 187 + post unquote(path) do 188 + var!(conn) = Plug.Conn.fetch_query_params(var!(conn)) 189 + unquote_splicing(auth_block) 190 + unquote_splicing(params_block) 191 + unquote_splicing(body_block) 192 + 193 + if var!(conn).halted do 194 + var!(conn) 195 + else 196 + unquote(block) 197 + end 198 + end 199 + end 200 + end 201 + 202 + # --------------------------------------------------------------------------- 203 + # Private helpers (compile-time) 204 + # --------------------------------------------------------------------------- 205 + 206 + # Returns the root lexicon module if it represents a raw-input procedure 207 + # (i.e. it exports `content_type/0` but has no `Input` submodule with 208 + # `from_json/1`). Returns `nil` in all other cases, including when 209 + # `nsid_or_module` is a plain string NSID. 210 + @spec resolve_raw_input_module(term(), module() | nil, Macro.Env.t()) :: module() | nil 211 + defp resolve_raw_input_module(nsid_or_module, input_module, env) do 212 + with nil <- input_module, 213 + {:__aliases__, _, _} = ast <- nsid_or_module do 214 + module = Macro.expand(ast, env) 215 + 216 + if Code.ensure_loaded?(module) and function_exported?(module, :content_type, 0) do 217 + module 218 + end 219 + else 220 + _ -> nil 221 + end 222 + end 223 + 224 + # Returns {nsid_string, submodule_atom_or_nil}. 225 + # `submodule` is e.g. :Params or :Input. 226 + @spec resolve_nsid_and_submodule(term(), atom(), Macro.Env.t()) :: 227 + {String.t(), module() | nil} 228 + defp resolve_nsid_and_submodule(nsid_or_module, submodule_suffix, env) do 229 + case nsid_or_module do 230 + nsid when is_binary(nsid) -> 231 + unless Atex.NSID.match?(nsid) do 232 + raise CompileError, 233 + file: env.file, 234 + line: env.line, 235 + description: "invalid NSID: #{inspect(nsid)}" 236 + end 237 + 238 + {nsid, nil} 239 + 240 + {:__aliases__, _, _} = ast -> 241 + module = Macro.expand(ast, env) 242 + 243 + unless Code.ensure_loaded?(module) and function_exported?(module, :id, 0) do 244 + raise CompileError, 245 + file: env.file, 246 + line: env.line, 247 + description: 248 + "#{inspect(module)} does not define id/0 - " <> 249 + "only lexicon modules generated by deflexicon are supported" 250 + end 251 + 252 + nsid = module.id() 253 + 254 + unless Atex.NSID.match?(nsid) do 255 + raise CompileError, 256 + file: env.file, 257 + line: env.line, 258 + description: "#{inspect(module)}.id() returned an invalid NSID: #{inspect(nsid)}" 259 + end 260 + 261 + candidate = Module.concat(module, submodule_suffix) 262 + 263 + sub = 264 + if Code.ensure_loaded?(candidate) and function_exported?(candidate, :from_json, 1) do 265 + candidate 266 + end 267 + 268 + {nsid, sub} 269 + end 270 + end 271 + 272 + # Emits a list of quoted expressions that perform auth (soft + optional strict). 273 + # Uses var!(conn) to pierce macro hygiene and reference the `conn` variable 274 + # introduced by Plug.Router.get/post in the caller's context. 275 + @spec build_auth_block(String.t(), boolean()) :: [Macro.t()] 276 + defp build_auth_block(nsid, require_auth) do 277 + soft_auth = 278 + quote do 279 + var!(conn) = 280 + case Atex.ServiceAuth.validate_conn(var!(conn), 281 + aud: var!(conn).private[:xrpc_aud], 282 + lxm: unquote(nsid) 283 + ) do 284 + {:ok, jwt} -> Plug.Conn.assign(var!(conn), :current_jwt, jwt) 285 + _err -> var!(conn) 286 + end 287 + end 288 + 289 + strict_auth = 290 + if require_auth do 291 + quote do 292 + var!(conn) = 293 + if is_nil(var!(conn).assigns[:current_jwt]) do 294 + var!(conn) 295 + |> Plug.Conn.put_resp_content_type("application/json") 296 + |> Plug.Conn.send_resp( 297 + 401, 298 + Jason.encode!(%{ 299 + "error" => "AuthRequired", 300 + "message" => "Authentication required" 301 + }) 302 + ) 303 + |> Plug.Conn.halt() 304 + else 305 + var!(conn) 306 + end 307 + end 308 + end 309 + 310 + [soft_auth | List.wrap(strict_auth)] 311 + end 312 + 313 + # Emits a quoted expression that validates query params via `module.from_json/1`. 314 + # Skips if the conn is already halted by a previous step. 315 + @spec build_params_block(module()) :: [Macro.t()] 316 + defp build_params_block(params_module) do 317 + [ 318 + quote do 319 + var!(conn) = 320 + if var!(conn).halted do 321 + var!(conn) 322 + else 323 + case unquote(params_module).from_json(var!(conn).query_params) do 324 + {:ok, params} -> 325 + Plug.Conn.assign(var!(conn), :params, params) 326 + 327 + {:error, reason} -> 328 + var!(conn) 329 + |> Plug.Conn.put_resp_content_type("application/json") 330 + |> Plug.Conn.send_resp( 331 + 400, 332 + Jason.encode!(%{ 333 + "error" => "InvalidRequest", 334 + "message" => "Invalid query parameters: #{inspect(reason)}" 335 + }) 336 + ) 337 + |> Plug.Conn.halt() 338 + end 339 + end 340 + end 341 + ] 342 + end 343 + 344 + # Emits a quoted expression that validates the request body via `module.from_json/1`. 345 + # Skips if the conn is already halted by a previous step. 346 + @spec build_body_block(module()) :: [Macro.t()] 347 + defp build_body_block(input_module) do 348 + [ 349 + quote do 350 + var!(conn) = 351 + if var!(conn).halted do 352 + var!(conn) 353 + else 354 + case unquote(input_module).from_json(var!(conn).body_params) do 355 + {:ok, body} -> 356 + Plug.Conn.assign(var!(conn), :body, body) 357 + 358 + {:error, reason} -> 359 + var!(conn) 360 + |> Plug.Conn.put_resp_content_type("application/json") 361 + |> Plug.Conn.send_resp( 362 + 400, 363 + Jason.encode!(%{ 364 + "error" => "InvalidRequest", 365 + "message" => "Invalid request body: #{inspect(reason)}" 366 + }) 367 + ) 368 + |> Plug.Conn.halt() 369 + end 370 + end 371 + end 372 + ] 373 + end 374 + 375 + # Emits a quoted expression that validates the incoming Content-Type header 376 + # against the MIME type declared in the lexicon for a raw (non-JSON) input 377 + # procedure. On success, the raw body is placed at `conn.assigns[:body]`. 378 + # Skips if the conn is already halted by a previous step. 379 + @spec build_raw_body_block(module()) :: [Macro.t()] 380 + defp build_raw_body_block(raw_module) do 381 + [ 382 + quote do 383 + var!(conn) = 384 + if var!(conn).halted do 385 + var!(conn) 386 + else 387 + declared = unquote(raw_module).content_type() 388 + 389 + parsed_content_type = 390 + var!(conn) 391 + |> Plug.Conn.get_req_header("content-type") 392 + |> List.first("") 393 + |> Plug.Conn.Utils.content_type() 394 + 395 + with {:ok, type, subtype, _params} <- parsed_content_type, 396 + actual <- "#{type}/#{subtype}", 397 + true <- 398 + declared == "*/*" or actual == declared or 399 + (String.ends_with?(declared, "/*") and 400 + String.starts_with?(actual, String.trim_trailing(declared, "*"))) do 401 + var!(conn) 402 + else 403 + var!(conn) 404 + |> Plug.Conn.put_resp_content_type("application/json") 405 + |> Plug.Conn.send_resp( 406 + 415, 407 + JSON.encode!(%{ 408 + "error" => "InvalidRequest", 409 + message: "Unsupported media type: expected #{declared}" 410 + }) 411 + ) 412 + end 413 + end 414 + end 415 + ] 416 + end 417 + end
+38
lib/atex/xrpc/router/aud_plug.ex
··· 1 + defmodule Atex.XRPC.Router.AudPlug do 2 + @moduledoc """ 3 + Plug that populates `conn.private[:xrpc_aud]` from the `:service_did` app config. 4 + 5 + Injected automatically when using `Atex.XRPC.Router` (unless `plug_aud: false` 6 + is passed to `use`). Raises at runtime if `:service_did` is not configured, 7 + since auth validation requires a non-nil audience. 8 + 9 + ## Configuration 10 + 11 + config :atex, service_did: "did:web:my-service.example" 12 + """ 13 + 14 + import Plug.Conn 15 + 16 + @behaviour Plug 17 + 18 + @impl Plug 19 + def init(opts), do: opts 20 + 21 + @impl Plug 22 + def call(conn, _opts) do 23 + aud = 24 + Atex.Config.service_did() || 25 + raise """ 26 + Atex.XRPC.Router.AudPlug: :service_did is not configured. 27 + Add the following to your config: 28 + 29 + config :atex, service_did: "did:web:my-service.example" 30 + 31 + Or disable automatic aud injection with: 32 + 33 + use Atex.XRPC.Router, plug_aud: false 34 + """ 35 + 36 + put_private(conn, :xrpc_aud, aud) 37 + end 38 + end
+38
test/atex/lexicon_test.exs
··· 109 109 end 110 110 111 111 # --------------------------------------------------------------------------- 112 + # Tests: raw-input procedure (encoding only, no schema) 113 + # --------------------------------------------------------------------------- 114 + 115 + describe "procedure with raw input (encoding only)" do 116 + test "does not generate an Input submodule" do 117 + refute Code.ensure_loaded?(Lexicon.Test.UploadBlob.Input) 118 + end 119 + 120 + test "root module exports content_type/0" do 121 + assert function_exported?(Lexicon.Test.UploadBlob, :content_type, 0) 122 + end 123 + 124 + test "content_type/0 returns the declared encoding" do 125 + assert Lexicon.Test.UploadBlob.content_type() == "image/jpeg" 126 + end 127 + 128 + test "root module has raw_input field in struct" do 129 + assert Map.has_key?(%Lexicon.Test.UploadBlob{}, :raw_input) 130 + end 131 + end 132 + 133 + describe "procedure with wildcard raw input encoding" do 134 + test "content_type/0 returns */*" do 135 + assert Lexicon.Test.UploadAny.content_type() == "*/*" 136 + end 137 + end 138 + 139 + describe "procedure with JSON input schema" do 140 + test "Input submodule exports content_type/0" do 141 + assert function_exported?(Lexicon.Test.CreatePost.Input, :content_type, 0) 142 + end 143 + 144 + test "Input.content_type/0 returns the declared encoding" do 145 + assert Lexicon.Test.CreatePost.Input.content_type() == "application/json" 146 + end 147 + end 148 + 149 + # --------------------------------------------------------------------------- 112 150 # Tests: union-typed query output (cross-NSID refs) 113 151 # --------------------------------------------------------------------------- 114 152
+400
test/atex/xrpc/router_test.exs
··· 1 + defmodule Atex.XRPC.RouterTest do 2 + use ExUnit.Case, async: true 3 + 4 + import Plug.Test 5 + import Plug.Conn 6 + 7 + # --------------------------------------------------------------------------- 8 + # Stub lexicon modules used by macro-expansion tests. 9 + # These live outside of the test module so they are available at compile time 10 + # when the inline router modules below are defined. 11 + # --------------------------------------------------------------------------- 12 + 13 + defmodule StubLexicon do 14 + @moduledoc false 15 + def id, do: "com.example.stubQuery" 16 + 17 + defmodule Params do 18 + @moduledoc false 19 + def from_json(%{"name" => name}) when is_binary(name), do: {:ok, %{name: name}} 20 + def from_json(_), do: {:error, "name is required and must be a string"} 21 + end 22 + end 23 + 24 + defmodule StubProcedureLexicon do 25 + @moduledoc false 26 + def id, do: "com.example.stubProcedure" 27 + 28 + defmodule Params do 29 + @moduledoc false 30 + # Query params arrive as strings; version is optional. 31 + def from_json(%{"version" => v}) when is_binary(v) or is_integer(v), 32 + do: {:ok, %{version: v}} 33 + 34 + def from_json(_), do: {:ok, %{}} 35 + end 36 + 37 + defmodule Input do 38 + @moduledoc false 39 + def from_json(%{"text" => t}) when is_binary(t), do: {:ok, %{text: t}} 40 + def from_json(_), do: {:error, "text is required"} 41 + end 42 + end 43 + 44 + defmodule StubNoParamsLexicon do 45 + @moduledoc false 46 + def id, do: "com.example.noParams" 47 + end 48 + 49 + # --------------------------------------------------------------------------- 50 + # Router fixtures 51 + # --------------------------------------------------------------------------- 52 + 53 + defmodule StringNSIDRouter do 54 + use Plug.Router 55 + use Atex.XRPC.Router, plug_aud: false 56 + 57 + plug :match 58 + plug :dispatch 59 + 60 + query "com.example.stringQuery" do 61 + send_resp(conn, 200, "query-ok") 62 + end 63 + 64 + procedure "com.example.stringProcedure" do 65 + send_resp(conn, 200, "procedure-ok") 66 + end 67 + 68 + match _ do 69 + send_resp(conn, 404, "not found") 70 + end 71 + end 72 + 73 + defmodule ModuleRouter do 74 + use Plug.Router 75 + use Atex.XRPC.Router, plug_aud: false 76 + 77 + plug Plug.Parsers, 78 + parsers: [:json], 79 + pass: ["application/json"], 80 + json_decoder: Jason 81 + 82 + plug :match 83 + plug :dispatch 84 + 85 + query Atex.XRPC.RouterTest.StubLexicon do 86 + send_resp(conn, 200, Jason.encode!(conn.assigns[:params])) 87 + end 88 + 89 + procedure Atex.XRPC.RouterTest.StubProcedureLexicon do 90 + result = %{ 91 + params: conn.assigns[:params], 92 + body: conn.assigns[:body] 93 + } 94 + 95 + send_resp(conn, 200, Jason.encode!(result)) 96 + end 97 + 98 + query Atex.XRPC.RouterTest.StubNoParamsLexicon do 99 + has_params = Map.has_key?(conn.assigns, :params) 100 + send_resp(conn, 200, if(has_params, do: "has-params", else: "no-params")) 101 + end 102 + 103 + match _ do 104 + send_resp(conn, 404, "not found") 105 + end 106 + end 107 + 108 + defmodule RequireAuthRouter do 109 + use Plug.Router 110 + use Atex.XRPC.Router, plug_aud: false 111 + 112 + plug :match 113 + plug :dispatch 114 + 115 + query "com.example.authed", require_auth: true do 116 + send_resp(conn, 200, "authed-ok") 117 + end 118 + 119 + query "com.example.softAuth" do 120 + has_jwt = Map.has_key?(conn.assigns, :current_jwt) 121 + send_resp(conn, 200, if(has_jwt, do: "has-jwt", else: "no-jwt")) 122 + end 123 + 124 + match _ do 125 + send_resp(conn, 404, "not found") 126 + end 127 + end 128 + 129 + # --------------------------------------------------------------------------- 130 + # Helpers 131 + # --------------------------------------------------------------------------- 132 + 133 + defp call(router, method, path, opts \\ []) do 134 + headers = Keyword.get(opts, :headers, []) 135 + body = Keyword.get(opts, :body, "") 136 + query_string = Keyword.get(opts, :query_string, "") 137 + 138 + conn = 139 + method 140 + |> conn(path <> if(query_string != "", do: "?#{query_string}", else: ""), body) 141 + |> Map.put(:req_headers, headers) 142 + 143 + conn = 144 + if aud = Keyword.get(opts, :xrpc_aud) do 145 + put_private(conn, :xrpc_aud, aud) 146 + else 147 + conn 148 + end 149 + 150 + router.call(conn, router.init([])) 151 + end 152 + 153 + defp json_body(conn) do 154 + Jason.decode!(conn.resp_body) 155 + end 156 + 157 + # --------------------------------------------------------------------------- 158 + # Tests: string NSID routing 159 + # --------------------------------------------------------------------------- 160 + 161 + describe "query with string NSID" do 162 + test "routes GET /xrpc/<nsid>" do 163 + conn = call(StringNSIDRouter, :get, "/xrpc/com.example.stringQuery") 164 + assert conn.status == 200 165 + assert conn.resp_body == "query-ok" 166 + end 167 + 168 + test "does not match POST" do 169 + conn = call(StringNSIDRouter, :post, "/xrpc/com.example.stringQuery") 170 + assert conn.status == 404 171 + end 172 + 173 + test "does not match unrelated paths" do 174 + conn = call(StringNSIDRouter, :get, "/xrpc/com.example.other") 175 + assert conn.status == 404 176 + end 177 + end 178 + 179 + describe "procedure with string NSID" do 180 + test "routes POST /xrpc/<nsid>" do 181 + conn = call(StringNSIDRouter, :post, "/xrpc/com.example.stringProcedure") 182 + assert conn.status == 200 183 + assert conn.resp_body == "procedure-ok" 184 + end 185 + 186 + test "does not match GET" do 187 + conn = call(StringNSIDRouter, :get, "/xrpc/com.example.stringProcedure") 188 + assert conn.status == 404 189 + end 190 + end 191 + 192 + # --------------------------------------------------------------------------- 193 + # Tests: module atom routing and param/body validation 194 + # --------------------------------------------------------------------------- 195 + 196 + describe "query with lexicon module (has Params)" do 197 + test "validates and assigns params on success" do 198 + conn = call(ModuleRouter, :get, "/xrpc/com.example.stubQuery", query_string: "name=alice") 199 + assert conn.status == 200 200 + assert Jason.decode!(conn.resp_body) == %{"name" => "alice"} 201 + end 202 + 203 + test "halts with 400 on invalid params" do 204 + conn = call(ModuleRouter, :get, "/xrpc/com.example.stubQuery") 205 + assert conn.status == 400 206 + body = json_body(conn) 207 + assert body["error"] == "InvalidRequest" 208 + assert is_binary(body["message"]) 209 + end 210 + end 211 + 212 + describe "query with lexicon module (no Params submodule)" do 213 + test "does not assign :params" do 214 + conn = call(ModuleRouter, :get, "/xrpc/com.example.noParams") 215 + assert conn.status == 200 216 + assert conn.resp_body == "no-params" 217 + end 218 + end 219 + 220 + describe "procedure with lexicon module (has Params and Input)" do 221 + test "validates and assigns body on success" do 222 + conn = 223 + call(ModuleRouter, :post, "/xrpc/com.example.stubProcedure", 224 + body: Jason.encode!(%{"text" => "hello"}), 225 + headers: [{"content-type", "application/json"}] 226 + ) 227 + 228 + assert conn.status == 200 229 + result = Jason.decode!(conn.resp_body) 230 + assert result["body"] == %{"text" => "hello"} 231 + end 232 + 233 + test "assigns params when query string present" do 234 + conn = 235 + call(ModuleRouter, :post, "/xrpc/com.example.stubProcedure", 236 + query_string: "version=1", 237 + body: Jason.encode!(%{"text" => "hello"}), 238 + headers: [{"content-type", "application/json"}] 239 + ) 240 + 241 + assert conn.status == 200 242 + result = Jason.decode!(conn.resp_body) 243 + assert result["params"] == %{"version" => "1"} 244 + end 245 + 246 + test "halts with 400 on invalid body" do 247 + conn = 248 + call(ModuleRouter, :post, "/xrpc/com.example.stubProcedure", 249 + body: Jason.encode!(%{"wrong" => "field"}), 250 + headers: [{"content-type", "application/json"}] 251 + ) 252 + 253 + assert conn.status == 400 254 + body = json_body(conn) 255 + assert body["error"] == "InvalidRequest" 256 + end 257 + end 258 + 259 + # --------------------------------------------------------------------------- 260 + # Tests: auth behaviour 261 + # --------------------------------------------------------------------------- 262 + 263 + describe "require_auth: true" do 264 + test "returns 401 when no Authorization header is present" do 265 + conn = 266 + call(RequireAuthRouter, :get, "/xrpc/com.example.authed", xrpc_aud: "did:web:example.com") 267 + 268 + assert conn.status == 401 269 + body = json_body(conn) 270 + assert body["error"] == "AuthRequired" 271 + end 272 + 273 + test "returns 401 when Authorization header is malformed" do 274 + conn = 275 + call(RequireAuthRouter, :get, "/xrpc/com.example.authed", 276 + headers: [{"authorization", "NotBearer bad"}], 277 + xrpc_aud: "did:web:example.com" 278 + ) 279 + 280 + assert conn.status == 401 281 + body = json_body(conn) 282 + assert body["error"] == "AuthRequired" 283 + end 284 + 285 + test "halts and does not run the block when auth fails" do 286 + conn = 287 + call(RequireAuthRouter, :get, "/xrpc/com.example.authed", xrpc_aud: "did:web:example.com") 288 + 289 + assert conn.halted 290 + end 291 + end 292 + 293 + describe "soft auth (require_auth: false, default)" do 294 + test "does not assign :current_jwt when no Authorization header" do 295 + conn = 296 + call(RequireAuthRouter, :get, "/xrpc/com.example.softAuth", 297 + xrpc_aud: "did:web:example.com" 298 + ) 299 + 300 + assert conn.status == 200 301 + assert conn.resp_body == "no-jwt" 302 + end 303 + 304 + test "does not halt when no Authorization header" do 305 + conn = 306 + call(RequireAuthRouter, :get, "/xrpc/com.example.softAuth", 307 + xrpc_aud: "did:web:example.com" 308 + ) 309 + 310 + refute conn.halted 311 + end 312 + end 313 + 314 + # --------------------------------------------------------------------------- 315 + # Tests: AudPlug 316 + # --------------------------------------------------------------------------- 317 + 318 + describe "Atex.XRPC.Router.AudPlug" do 319 + test "raises at runtime when :service_did is not configured" do 320 + original = Application.get_env(:atex, :service_did) 321 + 322 + try do 323 + Application.delete_env(:atex, :service_did) 324 + 325 + assert_raise RuntimeError, ~r/:service_did is not configured/, fn -> 326 + conn(:get, "/") 327 + |> Atex.XRPC.Router.AudPlug.call([]) 328 + end 329 + after 330 + if original do 331 + Application.put_env(:atex, :service_did, original) 332 + end 333 + end 334 + end 335 + 336 + test "puts :service_did into conn.private[:xrpc_aud]" do 337 + original = Application.get_env(:atex, :service_did) 338 + 339 + try do 340 + Application.put_env(:atex, :service_did, "did:web:test.example") 341 + 342 + conn = 343 + conn(:get, "/") 344 + |> Atex.XRPC.Router.AudPlug.call([]) 345 + 346 + assert conn.private[:xrpc_aud] == "did:web:test.example" 347 + after 348 + if original do 349 + Application.put_env(:atex, :service_did, original) 350 + else 351 + Application.delete_env(:atex, :service_did) 352 + end 353 + end 354 + end 355 + end 356 + 357 + # --------------------------------------------------------------------------- 358 + # Tests: compile-time NSID validation 359 + # --------------------------------------------------------------------------- 360 + 361 + describe "invalid NSID string" do 362 + test "raises CompileError at macro expansion" do 363 + assert_raise CompileError, ~r/invalid NSID/, fn -> 364 + Code.compile_string(""" 365 + defmodule BadNSIDRouter do 366 + use Plug.Router 367 + use Atex.XRPC.Router, plug_aud: false 368 + plug :match 369 + plug :dispatch 370 + query "not-a-valid-nsid" do 371 + send_resp(conn, 200, "") 372 + end 373 + end 374 + """) 375 + end 376 + end 377 + end 378 + 379 + describe "module without id/0" do 380 + test "raises CompileError at macro expansion" do 381 + assert_raise CompileError, ~r/does not define id\/0/, fn -> 382 + Code.compile_string(""" 383 + defmodule NoIdModule do 384 + def something, do: :ok 385 + end 386 + 387 + defmodule BadModuleRouter do 388 + use Plug.Router 389 + use Atex.XRPC.Router, plug_aud: false 390 + plug :match 391 + plug :dispatch 392 + query NoIdModule do 393 + send_resp(conn, 200, "") 394 + end 395 + end 396 + """) 397 + end 398 + end 399 + end 400 + end
+40
test/support/lexicon_fixtures.ex
··· 169 169 }) 170 170 end 171 171 172 + # Procedure with a raw (non-JSON) input - encoding only, no schema. 173 + # NSID "lexicon.test.uploadBlob" -> Lexicon.Test.UploadBlob 174 + defmodule Lexicon.Test.UploadBlob do 175 + @moduledoc false 176 + use Atex.Lexicon 177 + 178 + deflexicon(%{ 179 + "lexicon" => 1, 180 + "id" => "lexicon.test.uploadBlob", 181 + "defs" => %{ 182 + "main" => %{ 183 + "type" => "procedure", 184 + "input" => %{ 185 + "encoding" => "image/jpeg" 186 + } 187 + } 188 + } 189 + }) 190 + end 191 + 192 + # Procedure with a wildcard raw input encoding. 193 + # NSID "lexicon.test.uploadAny" -> Lexicon.Test.UploadAny 194 + defmodule Lexicon.Test.UploadAny do 195 + @moduledoc false 196 + use Atex.Lexicon 197 + 198 + deflexicon(%{ 199 + "lexicon" => 1, 200 + "id" => "lexicon.test.uploadAny", 201 + "defs" => %{ 202 + "main" => %{ 203 + "type" => "procedure", 204 + "input" => %{ 205 + "encoding" => "*/*" 206 + } 207 + } 208 + } 209 + }) 210 + end 211 + 172 212 # Query whose output.schema is a `union` of two cross-NSID refs. 173 213 # NSID "lexicon.test.getUnion" -> Lexicon.Test.GetUnion 174 214 defmodule Lexicon.Test.GetUnion do