A set of utilities for working with the AT Protocol in Elixir.

feat: mix task for generating lexicon files from JSON

ovyerus.com bdc2040f 010cbea4

verified
+111 -394
+2
CHANGELOG.md
··· 13 13 - `Atex.Lexicon` module that provides the `deflexicon` macro, taking in a JSON 14 14 Lexicon definition and converts it into a series of schemas for each 15 15 definition within it. 16 + - `mix atex.lexicons` for converting lexicon JSON files into modules using 17 + `deflexicon` easily. 16 18 17 19 ## [0.3.0] - 2025-06-29 18 20
+1 -1
README.md
··· 10 10 - [x] XRPC client 11 11 - [x] DID & handle resolution service with a cache 12 12 - [x] Macro for converting a Lexicon definition into a runtime-validation schema 13 - - [ ] Codegen to convert a directory of lexicons 13 + - [x] Codegen to convert a directory of lexicons 14 14 - [ ] Extended XRPC client with support for validated inputs/outputs 15 15 - [ ] Oauth stuff 16 16
+1 -1
lib/atex/lexicon.ex
··· 58 58 def_to_schema(nsid, def_name, record) 59 59 end 60 60 61 - # TODO: add `$type` field. It's just a string though. 61 + # TODO: need to spit out an extra 'branded' type with `$type` field, for use in union refs. 62 62 defp def_to_schema( 63 63 nsid, 64 64 def_name,
-2
lib/atex/lexicon/validators/integer.ex
··· 1 1 defmodule Atex.Lexicon.Validators.Integer do 2 - alias Atex.Lexicon.Validators 3 - 4 2 @type option() :: 5 3 {:minimum, integer()} 6 4 | {:maximum, integer()}
+8 -2
lib/atex/nsid.ex
··· 11 11 # maybe stuff for fetching the repo that belongs to an authority 12 12 13 13 @spec to_atom(String.t()) :: atom() 14 - def to_atom(nsid) do 14 + def to_atom(nsid, fully_qualify \\ true) do 15 15 nsid 16 16 |> String.split(".") 17 17 |> Enum.map(&String.capitalize/1) 18 - |> then(&["Elixir" | &1]) 18 + |> then(fn parts -> 19 + if fully_qualify do 20 + ["Elixir" | parts] 21 + else 22 + parts 23 + end 24 + end) 19 25 |> Enum.join(".") 20 26 |> String.to_atom() 21 27 end
-115
lib/atproto/sh/comet/v0/actor/profile.ex
··· 1 - defmodule Sh.Comet.V0.Actor.Profile do 2 - use Atex.Lexicon 3 - 4 - deflexicon(%{ 5 - "defs" => %{ 6 - "main" => %{ 7 - "description" => "A user's Comet profile.", 8 - "key" => "literal:self", 9 - "record" => %{ 10 - "properties" => %{ 11 - "avatar" => %{ 12 - "accept" => ["image/png", "image/jpeg"], 13 - "description" => 14 - "Small image to be displayed next to posts from account. AKA, 'profile picture'", 15 - "maxSize" => 1_000_000, 16 - "type" => "blob" 17 - }, 18 - "banner" => %{ 19 - "accept" => ["image/png", "image/jpeg"], 20 - "description" => "Larger horizontal image to display behind profile view.", 21 - "maxSize" => 1_000_000, 22 - "type" => "blob" 23 - }, 24 - "createdAt" => %{"format" => "datetime", "type" => "string"}, 25 - "description" => %{ 26 - "description" => "Free-form profile description text.", 27 - "maxGraphemes" => 256, 28 - "maxLength" => 2560, 29 - "type" => "string" 30 - }, 31 - "descriptionFacets" => %{ 32 - "description" => "Annotations of the user's description.", 33 - "ref" => "sh.comet.v0.richtext.facet", 34 - "type" => "ref" 35 - }, 36 - "displayName" => %{ 37 - "maxGraphemes" => 64, 38 - "maxLength" => 640, 39 - "type" => "string" 40 - }, 41 - "featuredItems" => %{ 42 - "description" => "Pinned items to be shown first on the user's profile.", 43 - "items" => %{"format" => "at-uri", "type" => "string"}, 44 - "maxLength" => 5, 45 - "type" => "array" 46 - } 47 - }, 48 - "type" => "object" 49 - }, 50 - "type" => "record" 51 - }, 52 - "view" => %{ 53 - "properties" => %{ 54 - "avatar" => %{"format" => "uri", "type" => "string"}, 55 - "createdAt" => %{"format" => "datetime", "type" => "string"}, 56 - "did" => %{"format" => "did", "type" => "string"}, 57 - "displayName" => %{ 58 - "maxGraphemes" => 64, 59 - "maxLength" => 640, 60 - "type" => "string" 61 - }, 62 - "handle" => %{"format" => "handle", "type" => "string"}, 63 - "indexedAt" => %{"format" => "datetime", "type" => "string"}, 64 - "viewer" => %{"ref" => "#viewerState", "type" => "ref"} 65 - }, 66 - "required" => ["did", "handle"], 67 - "type" => "object" 68 - }, 69 - "viewFull" => %{ 70 - "properties" => %{ 71 - "avatar" => %{"format" => "uri", "type" => "string"}, 72 - "banner" => %{"format" => "uri", "type" => "string"}, 73 - "createdAt" => %{"format" => "datetime", "type" => "string"}, 74 - "description" => %{ 75 - "maxGraphemes" => 256, 76 - "maxLength" => 2560, 77 - "type" => "string" 78 - }, 79 - "descriptionFacets" => %{ 80 - "ref" => "sh.comet.v0.richtext.facet", 81 - "type" => "ref" 82 - }, 83 - "did" => %{"format" => "did", "type" => "string"}, 84 - "displayName" => %{ 85 - "maxGraphemes" => 64, 86 - "maxLength" => 640, 87 - "type" => "string" 88 - }, 89 - "featuredItems" => %{ 90 - "items" => %{"format" => "at-uri", "type" => "string"}, 91 - "maxLength" => 5, 92 - "type" => "array" 93 - }, 94 - "followersCount" => %{"type" => "integer"}, 95 - "followsCount" => %{"type" => "integer"}, 96 - "handle" => %{"format" => "handle", "type" => "string"}, 97 - "indexedAt" => %{"format" => "datetime", "type" => "string"}, 98 - "playlistsCount" => %{"type" => "integer"}, 99 - "tracksCount" => %{"type" => "integer"}, 100 - "viewer" => %{"ref" => "#viewerState", "type" => "ref"} 101 - }, 102 - "required" => ["did", "handle"], 103 - "type" => "object" 104 - }, 105 - "viewerState" => %{ 106 - "description" => 107 - "Metadata about the requesting account's relationship with the user. TODO: determine if we create our own graph or inherit bsky's.", 108 - "properties" => %{}, 109 - "type" => "object" 110 - } 111 - }, 112 - "id" => "sh.comet.v0.actor.profile", 113 - "lexicon" => 1 114 - }) 115 - end
-44
lib/atproto/sh/comet/v0/feed/defs.ex
··· 1 - defmodule Sh.Comet.V0.Feed.Defs do 2 - use Atex.Lexicon 3 - 4 - deflexicon(%{ 5 - "defs" => %{ 6 - "buyLink" => %{ 7 - "description" => "Indicate the link leads to a purchase page for the track.", 8 - "type" => "token" 9 - }, 10 - "downloadLink" => %{ 11 - "description" => "Indicate the link leads to a free download for the track.", 12 - "type" => "token" 13 - }, 14 - "link" => %{ 15 - "description" => 16 - "Link for the track. Usually to acquire it in some way, e.g. via free download or purchase. | TODO: multiple links?", 17 - "properties" => %{ 18 - "type" => %{ 19 - "knownValues" => [ 20 - "sh.comet.v0.feed.defs#downloadLink", 21 - "sh.comet.v0.feed.defs#buyLink" 22 - ], 23 - "type" => "string" 24 - }, 25 - "value" => %{"format" => "uri", "type" => "string"} 26 - }, 27 - "required" => ["type", "value"], 28 - "type" => "object" 29 - }, 30 - "viewerState" => %{ 31 - "description" => 32 - "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", 33 - "properties" => %{ 34 - "featured" => %{"type" => "boolean"}, 35 - "like" => %{"format" => "at-uri", "type" => "string"}, 36 - "repost" => %{"format" => "at-uri", "type" => "string"} 37 - }, 38 - "type" => "object" 39 - } 40 - }, 41 - "id" => "sh.comet.v0.feed.defs", 42 - "lexicon" => 1 43 - }) 44 - end
-45
lib/atproto/sh/comet/v0/feed/getActorTracks.ex
··· 1 - defmodule Sh.Comet.V0.Feed.GetActorTracks do 2 - use Atex.Lexicon 3 - 4 - deflexicon(%{ 5 - "defs" => %{ 6 - "main" => %{ 7 - "description" => "Get a list of an actor's tracks.", 8 - "output" => %{ 9 - "encoding" => "application/json", 10 - "schema" => %{ 11 - "properties" => %{ 12 - "cursor" => %{"type" => "string"}, 13 - "tracks" => %{ 14 - "items" => %{ 15 - "ref" => "sh.comet.v0.feed.track#view", 16 - "type" => "ref" 17 - }, 18 - "type" => "array" 19 - } 20 - }, 21 - "required" => ["tracks"], 22 - "type" => "object" 23 - } 24 - }, 25 - "parameters" => %{ 26 - "properties" => %{ 27 - "actor" => %{"format" => "at-identifier", "type" => "string"}, 28 - "cursor" => %{"type" => "string"}, 29 - "limit" => %{ 30 - "default" => 50, 31 - "maximum" => 100, 32 - "minimum" => 1, 33 - "type" => "integer" 34 - } 35 - }, 36 - "required" => ["actor"], 37 - "type" => "params" 38 - }, 39 - "type" => "query" 40 - } 41 - }, 42 - "id" => "sh.comet.v0.feed.getActorTracks", 43 - "lexicon" => 1 44 - }) 45 - end
-114
lib/atproto/sh/comet/v0/feed/track.ex
··· 1 - defmodule Sh.Comet.V0.Feed.Track do 2 - use Atex.Lexicon 3 - 4 - deflexicon(%{ 5 - "defs" => %{ 6 - "main" => %{ 7 - "description" => 8 - "A Comet audio track. TODO: should probably have some sort of pre-calculated waveform, or have a query to get one from a blob?", 9 - "key" => "tid", 10 - "record" => %{ 11 - "properties" => %{ 12 - "audio" => %{ 13 - "accept" => ["audio/ogg"], 14 - "description" => 15 - "Audio of the track, ideally encoded as 96k Opus. Limited to 100mb.", 16 - "maxSize" => 100_000_000, 17 - "type" => "blob" 18 - }, 19 - "createdAt" => %{ 20 - "description" => "Timestamp for when the track entry was originally created.", 21 - "format" => "datetime", 22 - "type" => "string" 23 - }, 24 - "description" => %{ 25 - "description" => "Description of the track.", 26 - "maxGraphemes" => 2000, 27 - "maxLength" => 20000, 28 - "type" => "string" 29 - }, 30 - "descriptionFacets" => %{ 31 - "description" => "Annotations of the track's description.", 32 - "ref" => "sh.comet.v0.richtext.facet", 33 - "type" => "ref" 34 - }, 35 - "explicit" => %{ 36 - "description" => 37 - "Whether the track contains explicit content that may objectionable to some people, usually swearing or adult themes.", 38 - "type" => "boolean" 39 - }, 40 - "image" => %{ 41 - "accept" => ["image/png", "image/jpeg"], 42 - "description" => "Image to be displayed representing the track.", 43 - "maxSize" => 1_000_000, 44 - "type" => "blob" 45 - }, 46 - "link" => %{"ref" => "sh.comet.v0.feed.defs#link", "type" => "ref"}, 47 - "releasedAt" => %{ 48 - "description" => 49 - "Timestamp for when the track was released. If in the future, may be used to implement pre-savable tracks.", 50 - "format" => "datetime", 51 - "type" => "string" 52 - }, 53 - "tags" => %{ 54 - "description" => "Hashtags for the track, usually for genres.", 55 - "items" => %{ 56 - "maxGraphemes" => 64, 57 - "maxLength" => 640, 58 - "type" => "string" 59 - }, 60 - "maxLength" => 8, 61 - "type" => "array" 62 - }, 63 - "title" => %{ 64 - "description" => 65 - "Title of the track. Usually shouldn't include the creator's name.", 66 - "maxGraphemes" => 256, 67 - "maxLength" => 2560, 68 - "minLength" => 1, 69 - "type" => "string" 70 - } 71 - }, 72 - "required" => ["audio", "title", "createdAt"], 73 - "type" => "object" 74 - }, 75 - "type" => "record" 76 - }, 77 - "view" => %{ 78 - "properties" => %{ 79 - "audio" => %{ 80 - "description" => 81 - "URL pointing to where the audio data for the track can be fetched. May be re-encoded from the original blob.", 82 - "format" => "uri", 83 - "type" => "string" 84 - }, 85 - "author" => %{ 86 - "ref" => "sh.comet.v0.actor.profile#viewFull", 87 - "type" => "ref" 88 - }, 89 - "cid" => %{"format" => "cid", "type" => "string"}, 90 - "commentCount" => %{"type" => "integer"}, 91 - "image" => %{ 92 - "description" => "URL pointing to where the image for the track can be fetched.", 93 - "format" => "uri", 94 - "type" => "string" 95 - }, 96 - "indexedAt" => %{"format" => "datetime", "type" => "string"}, 97 - "likeCount" => %{"type" => "integer"}, 98 - "playCount" => %{"type" => "integer"}, 99 - "record" => %{"ref" => "#main", "type" => "ref"}, 100 - "repostCount" => %{"type" => "integer"}, 101 - "uri" => %{"format" => "at-uri", "type" => "string"}, 102 - "viewer" => %{ 103 - "ref" => "sh.comet.v0.feed.defs#viewerState", 104 - "type" => "ref" 105 - } 106 - }, 107 - "required" => ["uri", "cid", "author", "audio", "record", "indexedAt"], 108 - "type" => "object" 109 - } 110 - }, 111 - "id" => "sh.comet.v0.feed.track", 112 - "lexicon" => 1 113 - }) 114 - end
-70
lib/atproto/sh/comet/v0/richtext/facet.ex
··· 1 - defmodule Sh.Comet.V0.Richtext.Facet do 2 - use Atex.Lexicon 3 - 4 - deflexicon(%{ 5 - "defs" => %{ 6 - "byteSlice" => %{ 7 - "description" => 8 - "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", 9 - "properties" => %{ 10 - "byteEnd" => %{"minimum" => 0, "type" => "integer"}, 11 - "byteStart" => %{"minimum" => 0, "type" => "integer"} 12 - }, 13 - "required" => ["byteStart", "byteEnd"], 14 - "type" => "object" 15 - }, 16 - "link" => %{ 17 - "description" => 18 - "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", 19 - "properties" => %{"uri" => %{"format" => "uri", "type" => "string"}}, 20 - "required" => ["uri"], 21 - "type" => "object" 22 - }, 23 - "main" => %{ 24 - "description" => "Annotation of a sub-string within rich text.", 25 - "properties" => %{ 26 - "features" => %{ 27 - "items" => %{ 28 - "refs" => ["#mention", "#link", "#tag"], 29 - "type" => "union" 30 - }, 31 - "type" => "array" 32 - }, 33 - "index" => %{"ref" => "#byteSlice", "type" => "ref"} 34 - }, 35 - "required" => ["index", "features"], 36 - "type" => "object" 37 - }, 38 - "mention" => %{ 39 - "description" => 40 - "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", 41 - "properties" => %{"did" => %{"format" => "did", "type" => "string"}}, 42 - "required" => ["did"], 43 - "type" => "object" 44 - }, 45 - "tag" => %{ 46 - "description" => 47 - "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').", 48 - "properties" => %{ 49 - "tag" => %{"maxGraphemes" => 64, "maxLength" => 640, "type" => "string"} 50 - }, 51 - "required" => ["tag"], 52 - "type" => "object" 53 - }, 54 - "timestamp" => %{ 55 - "description" => 56 - "Facet feature for a timestamp in a track. The text usually is in the format of 'hh:mm:ss' with the hour section being omitted if unnecessary.", 57 - "properties" => %{ 58 - "timestamp" => %{ 59 - "description" => "Reference time, in seconds.", 60 - "minimum" => 0, 61 - "type" => "integer" 62 - } 63 - }, 64 - "type" => "object" 65 - } 66 - }, 67 - "id" => "sh.comet.v0.richtext.facet", 68 - "lexicon" => 1 69 - }) 70 - end
+94
lib/mix/tasks/atex.lexicons.ex
··· 1 + defmodule Mix.Tasks.Atex.Lexicons do 2 + @moduledoc """ 3 + Generate Elixir modules from AT Protocol lexicons, which can then be used to 4 + validate data at runtime. 5 + 6 + AT Protocol lexicons are JSON files that define parts of the AT Protocol data 7 + model. This task processes these lexicon files and generates corresponding 8 + Elixir modules. 9 + 10 + ## Usage 11 + 12 + mix atex.lexicons [OPTIONS] [PATHS] 13 + 14 + ## Arguments 15 + 16 + - `PATHS` - List of lexicon files to process. Also supports standard glob 17 + syntax for reading many lexicons at once. 18 + 19 + ## Options 20 + 21 + - `-o`/`--output` - Output directory for generated modules (default: 22 + `lib/atproto`) 23 + 24 + ## Examples 25 + 26 + Process all JSON files in the lexicons directory: 27 + 28 + mix atex.lexicons lexicons/**/*.json 29 + 30 + Process specific lexicon files: 31 + 32 + mix atex.lexicons lexicons/com/atproto/repo/*.json lexicons/app/bsky/actor/profile.json 33 + 34 + Generate modules to a custom output directory: 35 + 36 + mix atex.lexicons lexicons/**/*.json --output lib/my_atproto 37 + """ 38 + @shortdoc "Generate Elixir modules from AT Protocol lexicons." 39 + 40 + use Mix.Task 41 + require EEx 42 + 43 + @switches [output: :string] 44 + @aliases [o: :output] 45 + @template_path Path.expand("../../../priv/templates/lexicon.eex", __DIR__) 46 + 47 + @impl Mix.Task 48 + def run(args) do 49 + {options, globs} = OptionParser.parse!(args, switches: @switches, aliases: @aliases) 50 + 51 + output = Keyword.get(options, :output, "lib/atproto") 52 + paths = Enum.flat_map(globs, &Path.wildcard/1) 53 + 54 + if length(paths) == 0 do 55 + Mix.shell().error("No valid search paths have been provided, aborting.") 56 + else 57 + Mix.shell().info("Generating modules for lexicons into #{output}") 58 + 59 + Enum.each(paths, fn path -> 60 + Mix.shell().info("- #{path}") 61 + generate(path, output) 62 + end) 63 + end 64 + end 65 + 66 + # TODO: validate schema? 67 + defp generate(input, output) do 68 + lexicon = 69 + input 70 + |> File.read!() 71 + |> JSON.decode!() 72 + 73 + if not is_binary(lexicon["id"]) do 74 + raise ArgumentError, message: "Malformed lexicon: does not have an `id` field." 75 + end 76 + 77 + code = lexicon |> template() |> Code.format_string!() |> Enum.join("") 78 + 79 + file_path = 80 + lexicon["id"] 81 + |> String.split(".") 82 + |> Enum.join("/") 83 + |> then(&(&1 <> ".ex")) 84 + |> then(&Path.join(output, &1)) 85 + 86 + file_path 87 + |> Path.dirname() 88 + |> File.mkdir_p!() 89 + 90 + File.write!(file_path, code) 91 + end 92 + 93 + EEx.function_from_file(:defp, :template, @template_path, [:lexicon]) 94 + end
+5
priv/templates/lexicon.eex
··· 1 + defmodule <%= Atex.NSID.to_atom(lexicon["id"], false) %> do 2 + use Atex.Lexicon 3 + 4 + deflexicon(<%= inspect(lexicon, limit: :infinity, pretty: true, printable_limit: :infinity) %>) 5 + end