Experiments for playing with atproto in Gleam.
21

Configure Feed

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

:construction: Initial go at an NSID parser.

Hayleigh Thompson (Jul 15, 2026, 10:42 PM +0200) c48abf3d 52c75380

+320
+234
src/at/nsid.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import gleam/list 4 + import gleam/string 5 + 6 + // TYPES ----------------------------------------------------------------------- 7 + 8 + /// 9 + /// 10 + pub opaque type Nsid { 11 + Nsid(authority: List(String), name: String) 12 + } 13 + 14 + // CONSTRUCTORS ---------------------------------------------------------------- 15 + 16 + /// 17 + /// 18 + pub fn from_string(input: String) -> Result(Nsid, Nil) { 19 + do_from_string(input, input, 0, 0, [], 0, 0) 20 + } 21 + 22 + fn do_from_string( 23 + input: String, 24 + remaining: String, 25 + // 26 + slice_offset: Int, 27 + slice_length: Int, 28 + // 29 + authority_segments: List(String), 30 + authority_count: Int, 31 + authority_length: Int, 32 + ) -> Result(Nsid, Nil) { 33 + case remaining { 34 + // An NSID must have at least three segments, so at least two authority 35 + // segments and the name segment. 36 + // 37 + // The entire authority part must not exceed 253 characters, *including* 38 + // the dot separators between segments. 39 + // 40 + // The name segment must be at least 1 character long and no more than 63 41 + // characters. 42 + // 43 + // The entire NSID must not exceed 317 characters, but we don't need to 44 + // check this explicitly because the previous two checks already guarantee 45 + // this. 46 + "" 47 + if authority_count < 2 48 + || authority_length > 253 49 + || slice_length < 1 50 + || slice_length > 63 51 + -> Error(Nil) 52 + 53 + "" -> 54 + // The name segment must not start with a digit. 55 + case string.slice(input, slice_offset, slice_length) { 56 + "1" <> _ 57 + | "2" <> _ 58 + | "3" <> _ 59 + | "4" <> _ 60 + | "5" <> _ 61 + | "6" <> _ 62 + | "7" <> _ 63 + | "8" <> _ 64 + | "9" <> _ -> Error(Nil) 65 + 66 + name -> 67 + // The name segment must not contain a hyphen. 68 + case string.contains(name, "-") { 69 + True -> Error(Nil) 70 + False -> 71 + Ok(Nsid(authority: list.reverse(authority_segments), name:)) 72 + } 73 + } 74 + 75 + // Each segment must be at least 1 character long and no more than 63 characters. 76 + "." <> _ if slice_length < 1 || slice_length > 63 -> Error(Nil) 77 + 78 + "." <> remaining if authority_length == 0 -> { 79 + let slice = 80 + input 81 + |> string.slice(slice_offset, slice_length) 82 + |> string.lowercase 83 + let slice_length = slice_length + 1 84 + 85 + do_from_string( 86 + input, 87 + remaining, 88 + slice_length, 89 + 0, 90 + [slice], 91 + 1, 92 + slice_length, 93 + ) 94 + } 95 + 96 + "." <> remaining -> { 97 + let slice = 98 + input 99 + |> string.slice(slice_offset, slice_length) 100 + |> string.lowercase 101 + let slice_length = slice_length + 1 102 + 103 + do_from_string( 104 + input, 105 + remaining, 106 + slice_offset + slice_length, 107 + 0, 108 + [slice, ..authority_segments], 109 + authority_count + 1, 110 + authority_length + slice_length, 111 + ) 112 + } 113 + 114 + // An authority segment cannot start or end with a hyphen. 115 + "-." <> _ -> Error(Nil) 116 + "-" if slice_length == 0 -> Error(Nil) 117 + 118 + // The first authority segment cannot start with a digit. 119 + "0" <> _ 120 + | "1" <> _ 121 + | "2" <> _ 122 + | "3" <> _ 123 + | "4" <> _ 124 + | "5" <> _ 125 + | "6" <> _ 126 + | "7" <> _ 127 + | "8" <> _ 128 + | "9" <> _ 129 + if slice_length == 0 && authority_length == 0 130 + -> Error(Nil) 131 + 132 + "-" <> remaining 133 + | // [0-9] 134 + "0" <> remaining 135 + | "1" <> remaining 136 + | "2" <> remaining 137 + | "3" <> remaining 138 + | "4" <> remaining 139 + | "5" <> remaining 140 + | "6" <> remaining 141 + | "7" <> remaining 142 + | "8" <> remaining 143 + | "9" <> remaining 144 + | // [a-z] 145 + "a" <> remaining 146 + | "b" <> remaining 147 + | "c" <> remaining 148 + | "d" <> remaining 149 + | "e" <> remaining 150 + | "f" <> remaining 151 + | "h" <> remaining 152 + | "g" <> remaining 153 + | "i" <> remaining 154 + | "j" <> remaining 155 + | "k" <> remaining 156 + | "l" <> remaining 157 + | "m" <> remaining 158 + | "n" <> remaining 159 + | "o" <> remaining 160 + | "p" <> remaining 161 + | "q" <> remaining 162 + | "r" <> remaining 163 + | "s" <> remaining 164 + | "t" <> remaining 165 + | "u" <> remaining 166 + | "v" <> remaining 167 + | "w" <> remaining 168 + | "x" <> remaining 169 + | "y" <> remaining 170 + | "z" <> remaining 171 + | // [A-Z] 172 + "A" <> remaining 173 + | "B" <> remaining 174 + | "C" <> remaining 175 + | "D" <> remaining 176 + | "E" <> remaining 177 + | "F" <> remaining 178 + | "G" <> remaining 179 + | "H" <> remaining 180 + | "I" <> remaining 181 + | "J" <> remaining 182 + | "K" <> remaining 183 + | "L" <> remaining 184 + | "M" <> remaining 185 + | "N" <> remaining 186 + | "O" <> remaining 187 + | "P" <> remaining 188 + | "Q" <> remaining 189 + | "R" <> remaining 190 + | "S" <> remaining 191 + | "T" <> remaining 192 + | "U" <> remaining 193 + | "V" <> remaining 194 + | "Y" <> remaining 195 + | "W" <> remaining 196 + | "X" <> remaining 197 + | "Z" <> remaining -> 198 + do_from_string( 199 + input, 200 + remaining, 201 + slice_offset, 202 + slice_length + 1, 203 + authority_segments, 204 + authority_count, 205 + authority_length, 206 + ) 207 + 208 + _ -> Error(Nil) 209 + } 210 + } 211 + 212 + // QUERIES --------------------------------------------------------------------- 213 + 214 + /// 215 + /// 216 + pub fn authority(nsid: Nsid) -> List(String) { 217 + nsid.authority 218 + } 219 + 220 + /// 221 + /// 222 + pub fn name(nsid: Nsid) -> String { 223 + nsid.name 224 + } 225 + 226 + // CONVERSIONS ----------------------------------------------------------------- 227 + 228 + /// 229 + /// 230 + pub fn to_string(nsid: Nsid) -> String { 231 + use out, segment <- list.fold_right(nsid.authority, nsid.name) 232 + 233 + segment <> "." <> out 234 + }
+86
test/at/nsid_test.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import at/nsid 4 + import gleam/list 5 + 6 + // FIXTURES -------------------------------------------------------------------- 7 + 8 + const valid = [ 9 + "com.ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.foo", 10 + "com.example.ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo", 11 + // 12 + // There's currently an open issue on the atproto interop repo about this test 13 + // case, as it does not currently respect the 253 character limit for the authority 14 + // part: https://github.com/bluesky-social/atproto-interop-tests/issues/15 15 + // 16 + // "com.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.foo", 17 + // 18 + "com.example.fooBar", 19 + "com.example.fooBarV2", 20 + "net.users.bob.ping", 21 + "a.b.c", 22 + "m.xn--masekowski-d0b.pl", 23 + "one.two.three", 24 + "one.two.three.four-and.FiVe", 25 + "one.2.three", 26 + "a-0.b-1.c", 27 + "a0.b1.cc", 28 + "cn.8.lex.stuff", 29 + "test.12345.record", 30 + "a01.thing.record", 31 + "a.0.c", 32 + "xn--fiqs8s.xn--fiqa61au8b7zsevnm8ak20mc4a87e.record.two", 33 + "a0.b1.c3", 34 + "com.example.f00", 35 + "onion.expyuzz4wqqyqhjn.spec.getThing", 36 + "onion.g2zyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.lex.deleteThing", 37 + "org.4chan.lex.getThing", 38 + "cn.8.lex.stuff", 39 + "onion.2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.lex.deleteThing", 40 + ] 41 + 42 + const invalid = [ 43 + "com.oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.foo", 44 + "com.example.oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo", 45 + "com.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.middle.foo", 46 + "com.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.x", 47 + "com.example.foo.*", 48 + "com.example.foo.blah*", 49 + "com.example.foo.*blah", 50 + "com.exa💩ple.thing", 51 + "a-0.b-1.c-3", 52 + "a-0.b-1.c-o", 53 + "1.0.0.127.record", 54 + "0two.example.foo", 55 + "example.com", 56 + "com.example", 57 + "a.", 58 + ".one.two.three", 59 + "one.two.three ", 60 + "one.two..three", 61 + "one .two.three", 62 + " one.two.three", 63 + "com.exa💩ple.thing", 64 + "com.atproto.feed.p@st", 65 + "com.atproto.feed.p_st", 66 + "com.atproto.feed.p*st", 67 + "com.atproto.feed.po#t", 68 + "com.atproto.feed.p!ot", 69 + "com.example-.foo", 70 + "com.example.fooBar.2", 71 + ] 72 + 73 + // TESTS ----------------------------------------------------------------------- 74 + 75 + pub fn atproto_interop_valid_nsid_test() { 76 + use input <- list.each(valid) 77 + let assert Ok(nsid) = nsid.from_string(input) as input 78 + 79 + assert nsid.to_string(nsid) == input 80 + } 81 + 82 + pub fn atproto_interop_invalid_nsid_test() { 83 + use input <- list.each(invalid) 84 + 85 + assert nsid.from_string(input) == Error(Nil) as input 86 + }