+4
-1
.formatter.exs
+4
-1
.formatter.exs
+7
-1
CHANGELOG.md
+7
-1
CHANGELOG.md
···
6
6
and this project adheres to
7
7
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
9
-
<!-- ## [Unreleased] -->
9
+
## [Unreleased]
10
+
11
+
### Added
12
+
13
+
- `Atex.Lexicon` module that provides the `deflexicon` macro, taking in a JSON
14
+
Lexicon definition and converts it into a series of schemas for each
15
+
definition within it.
10
16
11
17
## [0.3.0] - 2025-06-29
12
18
+309
lib/atex/lexicon.ex
+309
lib/atex/lexicon.ex
···
1
+
defmodule Atex.Lexicon do
2
+
@moduledoc """
3
+
Provide `deflexicon` macro for defining a module with types and schemas from an entire lexicon definition.
4
+
5
+
Should it also define structs, with functions to convert from input case to snake case?
6
+
"""
7
+
8
+
alias Atex.Lexicon.Validators
9
+
10
+
defmacro __using__(_opts) do
11
+
quote do
12
+
import Atex.Lexicon
13
+
import Atex.Lexicon.Validators
14
+
import Peri
15
+
end
16
+
end
17
+
18
+
defmacro deflexicon(lexicon) do
19
+
# Better way to get the real map, without having to eval? (custom function to compose one from quoted?)
20
+
lexicon =
21
+
lexicon
22
+
|> Code.eval_quoted()
23
+
|> elem(0)
24
+
|> then(&Recase.Enumerable.atomize_keys/1)
25
+
|> then(&Atex.Lexicon.Schema.lexicon!/1)
26
+
27
+
# TODO: support returning typedefs
28
+
defs =
29
+
lexicon.defs
30
+
|> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end)
31
+
|> Enum.map(fn {schema_key, quoted_schema} ->
32
+
quote do
33
+
defschema unquote(schema_key), unquote(quoted_schema)
34
+
end
35
+
end)
36
+
37
+
quote do
38
+
def id, do: unquote(Atex.NSID.to_atom(lexicon.id))
39
+
40
+
unquote_splicing(defs)
41
+
end
42
+
end
43
+
44
+
# TODO: generate typedefs
45
+
@spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) ::
46
+
list({key :: atom(), quoted :: term()})
47
+
48
+
defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do
49
+
# TODO: record rkey format validator
50
+
def_to_schema(nsid, def_name, record)
51
+
end
52
+
53
+
defp def_to_schema(
54
+
nsid,
55
+
def_name,
56
+
%{
57
+
type: "object",
58
+
properties: properties,
59
+
required: required
60
+
} = def
61
+
) do
62
+
nullable = Map.get(def, :nullable, [])
63
+
64
+
properties
65
+
|> Enum.map(fn {key, field} ->
66
+
field_to_schema(field, nsid)
67
+
|> then(
68
+
&if key in nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1
69
+
)
70
+
|> then(&if key in required, do: quote(do: {:required, unquote(&1)}), else: &1)
71
+
|> then(&{key, &1})
72
+
end)
73
+
|> then(&{:%{}, [], &1})
74
+
|> then(&[{atomise(def_name), &1}])
75
+
end
76
+
77
+
# TODO: validating errors?
78
+
defp def_to_schema(nsid, _def_name, %{type: "query"} = def) do
79
+
params =
80
+
if def[:parameters] do
81
+
[schema] =
82
+
def_to_schema(nsid, "params", %{
83
+
type: "object",
84
+
required: def.parameters.required,
85
+
nullable: [],
86
+
properties: def.parameters.properties
87
+
})
88
+
89
+
schema
90
+
end
91
+
92
+
output =
93
+
if def.output && def.output.schema do
94
+
[schema] = def_to_schema(nsid, "output", def.output.schema)
95
+
schema
96
+
end
97
+
98
+
[params, output]
99
+
|> Enum.reject(&is_nil/1)
100
+
end
101
+
102
+
defp def_to_schema(nsid, _def_name, %{type: "procedure"} = def) do
103
+
# TODO: better keys for these
104
+
params =
105
+
if def[:parameters] do
106
+
[schema] =
107
+
def_to_schema(nsid, "params", %{
108
+
type: "object",
109
+
required: def.parameters.required,
110
+
properties: def.parameters.properties
111
+
})
112
+
113
+
schema
114
+
end
115
+
116
+
output =
117
+
if def[:output] && def.output.schema do
118
+
[schema] = def_to_schema(nsid, "output", def.output.schema)
119
+
schema
120
+
end
121
+
122
+
input =
123
+
if def[:input] && def.input.schema do
124
+
[schema] = def_to_schema(nsid, "output", def.input.schema)
125
+
schema
126
+
end
127
+
128
+
[params, output, input]
129
+
|> Enum.reject(&is_nil/1)
130
+
end
131
+
132
+
defp def_to_schema(nsid, _def_name, %{type: "subscription"} = def) do
133
+
params =
134
+
if def[:parameters] do
135
+
[schema] =
136
+
def_to_schema(nsid, "params", %{
137
+
type: "object",
138
+
required: def.parameters.required,
139
+
properties: def.parameters.properties
140
+
})
141
+
142
+
schema
143
+
end
144
+
145
+
message =
146
+
if def[:message] do
147
+
[schema] = def_to_schema(nsid, "message", def.message.schema)
148
+
schema
149
+
end
150
+
151
+
[params, message]
152
+
|> Enum.reject(&is_nil/1)
153
+
end
154
+
155
+
defp def_to_schema(_nsid, def_name, %{type: "token"}) do
156
+
# TODO: make it a validator that expects the nsid + key.
157
+
[{atomise(def_name), :string}]
158
+
end
159
+
160
+
defp def_to_schema(nsid, def_name, %{type: type} = def)
161
+
when type in [
162
+
"blob",
163
+
"array",
164
+
"boolean",
165
+
"integer",
166
+
"string",
167
+
"bytes",
168
+
"cid-link",
169
+
"unknown"
170
+
] do
171
+
[{atomise(def_name), field_to_schema(def, nsid)}]
172
+
end
173
+
174
+
@spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) :: Peri.schema_def()
175
+
defp field_to_schema(%{type: "string"} = field, _nsid) do
176
+
fixed_schema = const_or_enum(field)
177
+
178
+
if fixed_schema do
179
+
maybe_default(fixed_schema, field)
180
+
else
181
+
field
182
+
|> Map.take([
183
+
:format,
184
+
:maxLength,
185
+
:minLength,
186
+
:maxGraphemes,
187
+
:minGraphemes
188
+
])
189
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
190
+
|> then(&{:custom, {Validators.String, :validate, [&1]}})
191
+
|> maybe_default(field)
192
+
|> then(&Macro.escape/1)
193
+
end
194
+
end
195
+
196
+
defp field_to_schema(%{type: "boolean"} = field, _nsid) do
197
+
(const(field) || :boolean)
198
+
|> maybe_default(field)
199
+
|> then(&Macro.escape/1)
200
+
end
201
+
202
+
defp field_to_schema(%{type: "integer"} = field, _nsid) do
203
+
fixed_schema = const_or_enum(field)
204
+
205
+
if fixed_schema do
206
+
maybe_default(fixed_schema, field)
207
+
else
208
+
field
209
+
|> Map.take([:maximum, :minimum])
210
+
|> Keyword.new()
211
+
|> then(&{:custom, {Validators.Integer, [&1]}})
212
+
|> maybe_default(field)
213
+
end
214
+
|> then(&Macro.escape/1)
215
+
end
216
+
217
+
defp field_to_schema(%{type: "array", items: items} = field, nsid) do
218
+
inner_schema = field_to_schema(items, nsid)
219
+
220
+
field
221
+
|> Map.take([:maxLength, :minLength])
222
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
223
+
|> then(&Validators.array(inner_schema, &1))
224
+
|> then(&Macro.escape/1)
225
+
# Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet.
226
+
# There's probably a better way to do this lol.
227
+
|> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} ->
228
+
{inner_schema, _} = Code.eval_quoted(quoted_inner_schema)
229
+
{:custom, {:{}, c, [Validators.Array, :validate, [inner_schema | args]]}}
230
+
end)
231
+
end
232
+
233
+
defp field_to_schema(%{type: "blob"} = field, _nsid) do
234
+
field
235
+
|> Map.take([:accept, :maxSize])
236
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
237
+
|> Validators.blob()
238
+
|> then(&Macro.escape/1)
239
+
end
240
+
241
+
defp field_to_schema(%{type: "bytes"} = field, _nsid) do
242
+
field
243
+
|> Map.take([:maxLength, :minLength])
244
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
245
+
|> Validators.bytes()
246
+
|> then(&Macro.escape/1)
247
+
end
248
+
249
+
defp field_to_schema(%{type: "cid-link"}, _nsid) do
250
+
Validators.cid_link()
251
+
|> then(&Macro.escape/1)
252
+
end
253
+
254
+
# TODO: do i need to make sure these two deal with brands? Check objects in atp.tools
255
+
defp field_to_schema(%{type: "ref", ref: ref}, nsid) do
256
+
{nsid, fragment} =
257
+
nsid
258
+
|> Atex.NSID.expand_possible_fragment_shorthand(ref)
259
+
|> Atex.NSID.to_atom_with_fragment()
260
+
261
+
quote do
262
+
unquote(nsid).get_schema(unquote(fragment))
263
+
end
264
+
end
265
+
266
+
defp field_to_schema(%{type: "union", refs: refs}, nsid) do
267
+
# refs =
268
+
refs
269
+
|> Enum.map(fn ref ->
270
+
{nsid, fragment} =
271
+
nsid
272
+
|> Atex.NSID.expand_possible_fragment_shorthand(ref)
273
+
|> Atex.NSID.to_atom_with_fragment()
274
+
275
+
quote do
276
+
unquote(nsid).get_schema(unquote(fragment))
277
+
end
278
+
end)
279
+
|> then(
280
+
"e do
281
+
{:oneof, unquote(&1)}
282
+
end
283
+
)
284
+
end
285
+
286
+
# TODO: apparently should be a data object, not a primitive?
287
+
defp field_to_schema(%{type: "unknown"}, _nsid) do
288
+
:any
289
+
end
290
+
291
+
defp field_to_schema(_field_def, _nsid), do: nil
292
+
293
+
defp maybe_default(schema, field) do
294
+
if field[:default] != nil,
295
+
do: {schema, {:default, field.default}},
296
+
else: schema
297
+
end
298
+
299
+
defp const_or_enum(field), do: const(field) || enum(field)
300
+
301
+
defp const(%{const: value}), do: {:literal, value}
302
+
defp const(_), do: nil
303
+
304
+
defp enum(%{enum: values}), do: {:enum, values}
305
+
defp enum(_), do: nil
306
+
307
+
defp atomise(x) when is_atom(x), do: x
308
+
defp atomise(x) when is_binary(x), do: String.to_atom(x)
309
+
end
+265
lib/atex/lexicon/schema.ex
+265
lib/atex/lexicon/schema.ex
···
1
+
defmodule Atex.Lexicon.Schema do
2
+
import Peri
3
+
4
+
defschema :lexicon, %{
5
+
lexicon: {:required, {:literal, 1}},
6
+
id:
7
+
{:required,
8
+
{:string,
9
+
{:regex,
10
+
~r/^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z]{0,61}[a-zA-Z])?)$/}}},
11
+
revision: {:integer, {:gte, 0}},
12
+
description: :string,
13
+
defs: {
14
+
:required,
15
+
{:schema,
16
+
%{
17
+
main:
18
+
{:oneof,
19
+
[
20
+
get_schema(:record),
21
+
get_schema(:query),
22
+
get_schema(:procedure),
23
+
get_schema(:subscription),
24
+
get_schema(:user_types)
25
+
]}
26
+
}, {:additional_keys, get_schema(:user_types)}}
27
+
}
28
+
}
29
+
30
+
defschema :record, %{
31
+
type: {:required, {:literal, "record"}},
32
+
description: :string,
33
+
# TODO: constraint
34
+
key: {:required, :string},
35
+
record: {:required, get_schema(:object)}
36
+
}
37
+
38
+
defschema :query, %{
39
+
type: {:required, {:literal, "query"}},
40
+
description: :string,
41
+
parameters: get_schema(:parameters),
42
+
output: get_schema(:body),
43
+
errors: {:list, get_schema(:error)}
44
+
}
45
+
46
+
defschema :procedure, %{
47
+
type: {:required, {:literal, "procedure"}},
48
+
description: :string,
49
+
parameters: get_schema(:parameters),
50
+
input: get_schema(:body),
51
+
output: get_schema(:body),
52
+
errors: {:list, get_schema(:error)}
53
+
}
54
+
55
+
defschema :subscription, %{
56
+
type: {:required, {:literal, "subscription"}},
57
+
description: :string,
58
+
parameters: get_schema(:parameters),
59
+
message: %{
60
+
description: :string,
61
+
schema: {:oneof, [get_schema(:object), get_schema(:ref_variant)]}
62
+
},
63
+
errors: {:list, get_schema(:error)}
64
+
}
65
+
66
+
defschema :parameters, %{
67
+
type: {:required, {:literal, "params"}},
68
+
description: :string,
69
+
required: {{:list, :string}, {:default, []}},
70
+
properties:
71
+
{:required, {:map, {:either, {get_schema(:primitive), get_schema(:primitive_array)}}}}
72
+
}
73
+
74
+
defschema :body, %{
75
+
description: :string,
76
+
encoding: {:required, :string},
77
+
schema: {:oneof, [get_schema(:object), get_schema(:ref_variant)]}
78
+
}
79
+
80
+
defschema :error, %{
81
+
name: {:required, :string},
82
+
description: :string
83
+
}
84
+
85
+
defschema :user_types,
86
+
{:oneof,
87
+
[
88
+
get_schema(:blob),
89
+
get_schema(:array),
90
+
get_schema(:token),
91
+
get_schema(:object),
92
+
get_schema(:boolean),
93
+
get_schema(:integer),
94
+
get_schema(:string),
95
+
get_schema(:bytes),
96
+
get_schema(:cid_link),
97
+
get_schema(:unknown)
98
+
]}
99
+
100
+
# General types
101
+
102
+
@ref_value {:string,
103
+
{
104
+
:regex,
105
+
# TODO: minlength 1
106
+
~r/^(?=.)(?:[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z]{0,61}[a-zA-Z])?))?(?:#[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)?$/
107
+
}}
108
+
109
+
@positive_int {:integer, {:gte, 0}}
110
+
@nonzero_positive_int {:integer, {:gt, 0}}
111
+
112
+
defschema :ref_variant, {:oneof, [get_schema(:ref), get_schema(:ref_union)]}
113
+
114
+
defschema :ref, %{
115
+
type: {:required, {:literal, "ref"}},
116
+
description: :string,
117
+
ref: {:required, @ref_value}
118
+
}
119
+
120
+
defschema :ref_union, %{
121
+
type: {:required, {:literal, "union"}},
122
+
description: :string,
123
+
refs: {:required, {:list, @ref_value}}
124
+
}
125
+
126
+
defschema :array, %{
127
+
type: {:required, {:literal, "array"}},
128
+
description: :string,
129
+
items:
130
+
{:required,
131
+
{:oneof,
132
+
[get_schema(:primitive), get_schema(:ipld), get_schema(:blob), get_schema(:ref_variant)]}},
133
+
maxLength: @positive_int,
134
+
minLength: @positive_int
135
+
}
136
+
137
+
defschema :primitive_array, %{
138
+
type: {:required, {:literal, "array"}},
139
+
description: :string,
140
+
items: {:required, get_schema(:primitive)},
141
+
maxLength: @positive_int,
142
+
minLength: @positive_int
143
+
}
144
+
145
+
defschema :object, %{
146
+
type: {:required, {:literal, "object"}},
147
+
description: :string,
148
+
required: {{:list, :string}, {:default, []}},
149
+
nullable: {{:list, :string}, {:default, []}},
150
+
properties:
151
+
{:required,
152
+
{:map,
153
+
{:oneof,
154
+
[
155
+
get_schema(:ref_variant),
156
+
get_schema(:ipld),
157
+
get_schema(:array),
158
+
get_schema(:blob),
159
+
get_schema(:primitive)
160
+
]}}}
161
+
}
162
+
163
+
defschema :primitive,
164
+
{:oneof,
165
+
[
166
+
get_schema(:boolean),
167
+
get_schema(:integer),
168
+
get_schema(:string),
169
+
get_schema(:unknown)
170
+
]}
171
+
172
+
defschema :ipld, {:oneof, [get_schema(:bytes), get_schema(:cid_link)]}
173
+
174
+
defschema :blob, %{
175
+
type: {:required, {:literal, "blob"}},
176
+
description: :string,
177
+
accept: {:list, :string},
178
+
maxSize: @positive_int
179
+
}
180
+
181
+
defschema :boolean, %{
182
+
type: {:required, {:literal, "boolean"}},
183
+
description: :string,
184
+
default: :boolean,
185
+
const: :boolean
186
+
}
187
+
188
+
defschema :bytes, %{
189
+
type: {:required, {:literal, "bytes"}},
190
+
description: :string,
191
+
maxLength: @positive_int,
192
+
minLength: @positive_int
193
+
}
194
+
195
+
defschema :cid_link, %{
196
+
type: {:required, {:literal, "cid-link"}},
197
+
description: :string
198
+
}
199
+
200
+
@string_type {:required, {:literal, "string"}}
201
+
202
+
defschema :string,
203
+
{:either,
204
+
{
205
+
# Formatted
206
+
%{
207
+
type: @string_type,
208
+
format:
209
+
{:required,
210
+
{:enum,
211
+
[
212
+
"at-identifier",
213
+
"at-uri",
214
+
"cid",
215
+
"datetime",
216
+
"did",
217
+
"handle",
218
+
"language",
219
+
"nsid",
220
+
"record-key",
221
+
"tid",
222
+
"uri"
223
+
]}},
224
+
description: :string,
225
+
default: :string,
226
+
const: :string,
227
+
enum: {:list, :string},
228
+
knownValues: {:list, :string}
229
+
},
230
+
# Unformatted
231
+
%{
232
+
type: @string_type,
233
+
description: :string,
234
+
default: :string,
235
+
const: :string,
236
+
enum: {:list, :string},
237
+
knownValues: {:list, :string},
238
+
format: {:literal, nil},
239
+
maxLength: @nonzero_positive_int,
240
+
minLength: @nonzero_positive_int,
241
+
maxGraphemes: @nonzero_positive_int,
242
+
minGraphemes: @nonzero_positive_int
243
+
}
244
+
}}
245
+
246
+
defschema :integer, %{
247
+
type: {:required, {:literal, "integer"}},
248
+
description: :string,
249
+
default: @positive_int,
250
+
const: @positive_int,
251
+
enum: {:list, @positive_int},
252
+
maximum: @positive_int,
253
+
minimum: @positive_int
254
+
}
255
+
256
+
defschema :token, %{
257
+
type: {:required, {:literal, "token"}},
258
+
description: :string
259
+
}
260
+
261
+
defschema :unknown, %{
262
+
type: {:required, {:literal, "unknown"}},
263
+
description: :string
264
+
}
265
+
end
+26
-5
lib/atex/lexicon/validators.ex
+26
-5
lib/atex/lexicon/validators.ex
···
1
1
defmodule Atex.Lexicon.Validators do
2
2
alias Atex.Lexicon.Validators
3
3
4
-
@type blob_option() :: {:accept, list(String.t())} | {:max_size, integer()}
4
+
@type blob_option() :: {:accept, list(String.t())} | {:max_size, pos_integer()}
5
5
6
6
@type blob_t() ::
7
7
%{
8
8
"$type": String.t(),
9
-
req: %{"$link": String.t()},
9
+
ref: %{"$link": String.t()},
10
10
mimeType: String.t(),
11
11
size: integer()
12
12
}
13
-
| %{}
13
+
| %{
14
+
cid: String.t(),
15
+
mimeType: String.t()
16
+
}
14
17
15
18
@spec string(list(Validators.String.option())) :: Peri.custom_def()
16
19
def string(options \\ []), do: {:custom, {Validators.String, :validate, [options]}}
···
20
23
21
24
@spec array(Peri.schema_def(), list(Validators.Array.option())) :: Peri.custom_def()
22
25
def array(inner_type, options \\ []) do
23
-
{:ok, ^inner_type} = Peri.validate_schema(inner_type)
24
26
{:custom, {Validators.Array, :validate, [inner_type, options]}}
25
27
end
26
28
···
49
51
},
50
52
# Old deprecated blobs
51
53
%{
52
-
cid: {:reqiured, :string},
54
+
cid: {:required, :string},
53
55
mimeType: mime_type
54
56
}
55
57
}
58
+
}
59
+
end
60
+
61
+
@spec bytes(list(Validators.Bytes.option())) :: Peri.schema()
62
+
def bytes(options \\ []) do
63
+
options = Keyword.validate!(options, min_length: nil, max_length: nil)
64
+
65
+
%{
66
+
"$bytes":
67
+
{:required,
68
+
{{:custom, {Validators.Bytes, :validate, [options]}}, {:transform, &Base.decode64!/1}}}
69
+
}
70
+
end
71
+
72
+
# TODO: see what atcute validators expect
73
+
# TODO: cid validation?
74
+
def cid_link() do
75
+
%{
76
+
"$link": {:required, :string}
56
77
}
57
78
end
58
79
+32
lib/atex/lexicon/validators/bytes.ex
+32
lib/atex/lexicon/validators/bytes.ex
···
1
+
defmodule Atex.Lexicon.Validators.Bytes do
2
+
@type option() :: {:min_length, pos_integer()} | {:max_length, pos_integer()}
3
+
4
+
@option_keys [:min_length, :max_length]
5
+
6
+
@spec validate(term(), list(option())) :: Peri.validation_result()
7
+
def validate(value, options) when is_binary(value) do
8
+
case Base.decode64(value, padding: false) do
9
+
{:ok, bytes} ->
10
+
options
11
+
|> Keyword.validate!(min_length: nil, max_length: nil)
12
+
|> Stream.map(&validate_option(bytes, &1))
13
+
|> Enum.find(:ok, fn x -> x !== :ok end)
14
+
15
+
:error ->
16
+
{:error, "expected valid base64 encoded bytes", []}
17
+
end
18
+
end
19
+
20
+
def validate(value, _options),
21
+
do:
22
+
{:error, "expected valid base64 encoded bytes, received #{value}",
23
+
[expected: :bytes, actual: value]}
24
+
25
+
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
26
+
27
+
defp validate_option(value, {:min_length, expected}) when byte_size(value) < expected,
28
+
do: {:error, "should have a minimum byte length of #{expected}", [length: expected]}
29
+
30
+
defp validate_option(value, {:max_length, expected}) when byte_size(value) <= expected,
31
+
do: :ok
32
+
end
+2
-17
lib/atex/lexicon/validators/integer.ex
+2
-17
lib/atex/lexicon/validators/integer.ex
···
4
4
@type option() ::
5
5
{:minimum, integer()}
6
6
| {:maximum, integer()}
7
-
| {:enum, list(integer())}
8
-
| {:const, integer()}
9
7
10
-
@option_keys [:minimum, :maximum, :enum, :const]
8
+
@option_keys [:minimum, :maximum]
11
9
12
10
@spec validate(term(), list(option())) :: Peri.validation_result()
13
11
def validate(value, options) when is_integer(value) do
14
12
options
15
13
|> Keyword.validate!(
16
14
minimum: nil,
17
-
maximum: nil,
18
-
enum: nil,
19
-
const: nil
15
+
maximum: nil
20
16
)
21
17
|> Stream.map(&validate_option(value, &1))
22
18
|> Enum.find(:ok, fn x -> x != :ok end)
···
41
37
42
38
defp validate_option(value, {:maximum, expected}) when value > expected,
43
39
do: {:error, "", [value: expected]}
44
-
45
-
defp validate_option(value, {:enum, values}),
46
-
do:
47
-
Validators.boolean_validate(value in values, "should be one of the expected values",
48
-
enum: values
49
-
)
50
-
51
-
defp validate_option(value, {:const, expected}) when value == expected, do: :ok
52
-
53
-
defp validate_option(value, {:const, expected}),
54
-
do: {:error, "should match constant value", [actual: value, expected: expected]}
55
40
end
+2
-19
lib/atex/lexicon/validators/string.ex
+2
-19
lib/atex/lexicon/validators/string.ex
···
20
20
| {:max_length, non_neg_integer()}
21
21
| {:min_graphemes, non_neg_integer()}
22
22
| {:max_graphemes, non_neg_integer()}
23
-
| {:enum, list(String.t())}
24
-
| {:const, String.t()}
25
23
26
24
@option_keys [
27
25
:format,
28
26
:min_length,
29
27
:max_length,
30
28
:min_graphemes,
31
-
:max_graphemes,
32
-
:enum,
33
-
:const
29
+
:max_graphemes
34
30
]
35
31
36
32
@record_key_re ~r"^[a-zA-Z0-9.-_:~]$"
···
62
58
min_length: nil,
63
59
max_length: nil,
64
60
min_graphemes: nil,
65
-
max_graphemes: nil,
66
-
enum: nil,
67
-
const: nil
61
+
max_graphemes: nil
68
62
)
69
63
# Stream so we early exit at the first error.
70
64
|> Stream.map(&validate_option(value, &1))
···
168
162
"should have a maximum length of #{expected}",
169
163
length: expected
170
164
)
171
-
172
-
defp validate_option(value, {:enum, values}),
173
-
do:
174
-
Validators.boolean_validate(value in values, "should be one of the expected values",
175
-
enum: values
176
-
)
177
-
178
-
defp validate_option(value, {:const, expected}) when value == expected, do: :ok
179
-
180
-
defp validate_option(value, {:const, expected}),
181
-
do: {:error, "should match constant value", [actual: value, expected: expected]}
182
165
end
+29
lib/atex/nsid.ex
+29
lib/atex/nsid.ex
···
9
9
10
10
# TODO: methods for fetching the authority and name from a nsid.
11
11
# maybe stuff for fetching the repo that belongs to an authority
12
+
13
+
@spec to_atom(String.t()) :: atom()
14
+
def to_atom(nsid) do
15
+
nsid
16
+
|> String.split(".")
17
+
|> Enum.map(&String.capitalize/1)
18
+
|> then(&["Elixir" | &1])
19
+
|> Enum.join(".")
20
+
|> String.to_atom()
21
+
end
22
+
23
+
@spec to_atom_with_fragment(String.t()) :: {atom(), atom()}
24
+
def to_atom_with_fragment(nsid) do
25
+
if !String.contains?(nsid, "#") do
26
+
{to_atom(nsid), :main}
27
+
else
28
+
[nsid, fragment] = String.split(nsid, "#")
29
+
{to_atom(nsid), String.to_atom(fragment)}
30
+
end
31
+
end
32
+
33
+
@spec expand_possible_fragment_shorthand(String.t(), String.t()) :: String.t()
34
+
def expand_possible_fragment_shorthand(main_nsid, possible_fragment) do
35
+
if String.starts_with?(possible_fragment, "#") do
36
+
main_nsid <> possible_fragment
37
+
else
38
+
possible_fragment
39
+
end
40
+
end
12
41
end
+23
lib/atex/peri.ex
+23
lib/atex/peri.ex
···
14
14
end
15
15
16
16
defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]}
17
+
18
+
def validate_map(value, schema, extra_keys_schema) when is_map(value) and is_map(schema) do
19
+
extra_keys =
20
+
Enum.reduce(Map.keys(schema), MapSet.new(Map.keys(value)), fn key, acc ->
21
+
acc |> MapSet.delete(key) |> MapSet.delete(to_string(key))
22
+
end)
23
+
24
+
extra_data =
25
+
value
26
+
|> Enum.filter(fn {key, _} -> MapSet.member?(extra_keys, key) end)
27
+
|> Map.new()
28
+
29
+
with {:ok, schema_data} <- Peri.validate(schema, value),
30
+
{:ok, extra_data} <- Peri.validate(extra_keys_schema, extra_data) do
31
+
{:ok, Map.merge(schema_data, extra_data)}
32
+
else
33
+
{:error, %Peri.Error{} = err} -> {:error, [err]}
34
+
e -> e
35
+
end
36
+
end
37
+
38
+
def validate_map(value, _schema, _extra_keys_schema),
39
+
do: {:error, "must be a map", [value: value]}
17
40
end
+115
lib/atproto/sh/comet/v0/actor/profile.ex
+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
+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
+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
+189
lib/atproto/sh/comet/v0/feed/track.ex
+189
lib/atproto/sh/comet/v0/feed/track.ex
···
1
+
defmodule Sh.Comet.V0.Feed.Track do
2
+
@moduledoc """
3
+
The following `deflexicon` call should result in something similar to the following output:
4
+
5
+
import Peri
6
+
import Atex.Lexicon.Validators
7
+
8
+
@type main() :: %{}
9
+
10
+
"""
11
+
use Atex.Lexicon
12
+
# import Atex.Lexicon
13
+
# import Atex.Lexicon.Validators
14
+
# import Peri
15
+
16
+
# TODO: need an example with `nullable` fields to demonstrate how those are handled (and also the weird extra types in lexicon defs like union)
17
+
18
+
@type main() :: %{
19
+
required(:audio) => Atex.Lexicon.Validators.blob_t(),
20
+
required(:title) => String.t(),
21
+
required(:createdAt) => String.t(),
22
+
# TODO: check if peri replaces with `nil` or omits them completely.
23
+
optional(:description) => String.t(),
24
+
optional(:descriptionFacets) => Sh.Comet.V0.Richtext.Facet.main(),
25
+
optional(:explicit) => boolean(),
26
+
optional(:image) => Atex.Lexicon.Validators.blob_t(),
27
+
optional(:link) => Sh.Comet.V0.Feed.Defs.link(),
28
+
optional(:releasedAt) => String.t(),
29
+
optional(:tags) => list(String.t())
30
+
}
31
+
32
+
@type view() :: %{
33
+
required(:uri) => String.t(),
34
+
required(:cid) => String.t(),
35
+
required(:author) => Sh.Comet.V0.Actor.Profile.viewFull(),
36
+
required(:audio) => String.t(),
37
+
required(:record) => main(),
38
+
required(:indexedAt) => String.t(),
39
+
optional(:image) => String.t(),
40
+
optional(:commentCount) => integer(),
41
+
optional(:likeCount) => integer(),
42
+
optional(:playCount) => integer(),
43
+
optional(:repostCount) => integer(),
44
+
optional(:viewer) => Sh.Comet.V0.Feed.Defs.viewerState()
45
+
}
46
+
47
+
# Should probably be a separate validator for all rkey formats.
48
+
# defschema :main_rkey, string(format: :tid)
49
+
50
+
# defschema :main, %{
51
+
# audio: {:required, blob(accept: ["audio/ogg"], max_size: 100_000_000)},
52
+
# title: {:required, string(min_length: 1, max_length: 2560, max_graphemes: 256)},
53
+
# createdAt: {:required, string(format: :datetime)},
54
+
# description: string(max_length: 20000, max_graphemes: 2000),
55
+
# # This is `ref`
56
+
# descriptionFacets: Sh.Comet.V0.Richtext.Facet.get_schema(:main),
57
+
# explicit: :boolean,
58
+
# image: blob(accept: ["image/png", "image/jpeg"], max_size: 1_000_000),
59
+
# link: Sh.Comet.V0.Feed.Defs.get_schema(:link),
60
+
# releasedAt: string(format: :datetime),
61
+
# tags: array(string(max_graphemes: 64, max_length: 640), max_length: 8)
62
+
# }
63
+
64
+
# defschema :view, %{
65
+
# uri: {:required, string(format: :at_uri)},
66
+
# cid: {:required, string(format: :cid)},
67
+
# author: {:required, Sh.Comet.V0.Actor.Profile.get_schema(:viewFull)},
68
+
# audio: {:required, string(format: :uri)},
69
+
# record: {:required, get_schema(:main)},
70
+
# indexedAt: {:required, string(format: :datetime)},
71
+
# image: string(format: :uri),
72
+
# commentCount: :integer,
73
+
# likeCount: :integer,
74
+
# playCount: :integer,
75
+
# repostCount: :integer,
76
+
# viewer: Sh.Comet.V0.Feed.Defs.get_schema(:viewerState)
77
+
# }
78
+
79
+
deflexicon(%{
80
+
"defs" => %{
81
+
"main" => %{
82
+
"description" =>
83
+
"A Comet audio track. TODO: should probably have some sort of pre-calculated waveform, or have a query to get one from a blob?",
84
+
"key" => "tid",
85
+
"record" => %{
86
+
"properties" => %{
87
+
"audio" => %{
88
+
"accept" => ["audio/ogg"],
89
+
"description" =>
90
+
"Audio of the track, ideally encoded as 96k Opus. Limited to 100mb.",
91
+
"maxSize" => 100_000_000,
92
+
"type" => "blob"
93
+
},
94
+
"createdAt" => %{
95
+
"description" => "Timestamp for when the track entry was originally created.",
96
+
"format" => "datetime",
97
+
"type" => "string"
98
+
},
99
+
"description" => %{
100
+
"description" => "Description of the track.",
101
+
"maxGraphemes" => 2000,
102
+
"maxLength" => 20000,
103
+
"type" => "string"
104
+
},
105
+
"descriptionFacets" => %{
106
+
"description" => "Annotations of the track's description.",
107
+
"ref" => "sh.comet.v0.richtext.facet",
108
+
"type" => "ref"
109
+
},
110
+
"explicit" => %{
111
+
"description" =>
112
+
"Whether the track contains explicit content that may objectionable to some people, usually swearing or adult themes.",
113
+
"type" => "boolean"
114
+
},
115
+
"image" => %{
116
+
"accept" => ["image/png", "image/jpeg"],
117
+
"description" => "Image to be displayed representing the track.",
118
+
"maxSize" => 1_000_000,
119
+
"type" => "blob"
120
+
},
121
+
"link" => %{"ref" => "sh.comet.v0.feed.defs#link", "type" => "ref"},
122
+
"releasedAt" => %{
123
+
"description" =>
124
+
"Timestamp for when the track was released. If in the future, may be used to implement pre-savable tracks.",
125
+
"format" => "datetime",
126
+
"type" => "string"
127
+
},
128
+
"tags" => %{
129
+
"description" => "Hashtags for the track, usually for genres.",
130
+
"items" => %{
131
+
"maxGraphemes" => 64,
132
+
"maxLength" => 640,
133
+
"type" => "string"
134
+
},
135
+
"maxLength" => 8,
136
+
"type" => "array"
137
+
},
138
+
"title" => %{
139
+
"description" =>
140
+
"Title of the track. Usually shouldn't include the creator's name.",
141
+
"maxGraphemes" => 256,
142
+
"maxLength" => 2560,
143
+
"minLength" => 1,
144
+
"type" => "string"
145
+
}
146
+
},
147
+
"required" => ["audio", "title", "createdAt"],
148
+
"type" => "object"
149
+
},
150
+
"type" => "record"
151
+
},
152
+
"view" => %{
153
+
"properties" => %{
154
+
"audio" => %{
155
+
"description" =>
156
+
"URL pointing to where the audio data for the track can be fetched. May be re-encoded from the original blob.",
157
+
"format" => "uri",
158
+
"type" => "string"
159
+
},
160
+
"author" => %{
161
+
"ref" => "sh.comet.v0.actor.profile#viewFull",
162
+
"type" => "ref"
163
+
},
164
+
"cid" => %{"format" => "cid", "type" => "string"},
165
+
"commentCount" => %{"type" => "integer"},
166
+
"image" => %{
167
+
"description" => "URL pointing to where the image for the track can be fetched.",
168
+
"format" => "uri",
169
+
"type" => "string"
170
+
},
171
+
"indexedAt" => %{"format" => "datetime", "type" => "string"},
172
+
"likeCount" => %{"type" => "integer"},
173
+
"playCount" => %{"type" => "integer"},
174
+
"record" => %{"ref" => "#main", "type" => "ref"},
175
+
"repostCount" => %{"type" => "integer"},
176
+
"uri" => %{"format" => "at-uri", "type" => "string"},
177
+
"viewer" => %{
178
+
"ref" => "sh.comet.v0.feed.defs#viewerState",
179
+
"type" => "ref"
180
+
}
181
+
},
182
+
"required" => ["uri", "cid", "author", "audio", "record", "indexedAt"],
183
+
"type" => "object"
184
+
}
185
+
},
186
+
"id" => "sh.comet.v0.feed.track",
187
+
"lexicon" => 1
188
+
})
189
+
end
+70
lib/atproto/sh/comet/v0/richtext/facet.ex
+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
+1
-1
mix.exs
+1
-1
mix.exs
+1
-1
mix.lock
+1
-1
mix.lock
···
19
19
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
20
20
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
21
21
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
22
-
"peri": {:hex, :peri, "0.5.1", "2140fd94095282aea1435c98307f25dde42005d319abb9927179301c310619c1", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "c214590d3bdf9d0e5f6d36df1cc87d956b7625c9ba32ca786983ba6df1936be3"},
22
+
"peri": {:hex, :peri, "0.6.0", "0758aa037f862f7a3aa0823cb82195916f61a8071f6eaabcff02103558e61a70", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "b27f118f3317fbc357c4a04b3f3c98561efdd8865edd4ec0e24fd936c7ff36c8"},
23
23
"recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"},
24
24
"req": {:hex, :req, "0.5.12", "7ce85835867a114c28b6cfc2d8a412f86660290907315ceb173a00e587b853d2", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d65f3d0e7032eb245706554cb5240dbe7a07493154e2dd34e7bb65001aa6ef32"},
25
25
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},