diff --git a/core/src/alicia/core/record_key.gleam b/core/src/alicia/core/record_key.gleam new file mode 100644 index 0000000..7e93c25 --- /dev/null +++ b/core/src/alicia/core/record_key.gleam @@ -0,0 +1,139 @@ +//// # Record Key +//// +//// Official Documentation: +//// [atproto.com/specs/record-key](https://atproto.com/specs/record-key) +//// +//// A record key, which the kids call an "rkey", +//// is the key of a single record within one collection of a repository. +//// It's used as a segment in an AT-URI and in a repo MST path. +//// +//// Independently of the `tid`, `nsid`, `literal:`, or `any` key types +//// that a lexicon schema can choose, there are general rules about an rkey: +//// it must look like `A-Za-z0-9.-_:~` and must be from 1 to 512 +//// bytes. The exact keys `.` and `..` are not allowed. +//// Rkeys are case-sensitive. + +import gleam/bool +import gleam/string + +/// A parsed, syntactically valid rkey. +pub opaque type RecordKey { + RecordKey(String) +} + +/// Every way in which `parse` function can reject an input. +pub type ParseError { + /// Empty string. + Empty + /// Beyond the 512-byte limit. + TooLong + /// Reserved key `.` or `..`. + DotSegment + /// Stinky byte which isn't from the allowed set `A-Za-z0-9.-_:~`. + InvalidCharacter +} + +/// The record key length limit. +pub const max_byte_length = 512 + +/// Constructing a rkey: +/// Checks the byte length, rejects the empty string and the reserved +/// `.` and `..` keys, then confirms every byte is in the allowed set. +/// +/// For ex: +/// - `parse("3jui7kd54zh2y")` -> `Ok(...)` +/// - `parse("self")` -> `Ok(...)` +/// - `parse(".")` -> `Error(DotSegment)` +/// - `parse("any space")` -> `Error(InvalidCharacter)` +pub fn parse(raw: String) -> Result(RecordKey, ParseError) { + use <- bool.guard(string.byte_size(raw) > max_byte_length, Error(TooLong)) + case raw { + "" -> Error(Empty) + "." | ".." -> Error(DotSegment) + _ -> + case is_record_key_body(raw) { + True -> Ok(RecordKey(raw)) + False -> Error(InvalidCharacter) + } + } +} + +fn is_record_key_body(remaining: String) -> Bool { + case remaining { + "" -> True + "-" <> rest + | "." <> rest + | "_" <> rest + | ":" <> rest + | "~" <> rest + | "0" <> rest + | "1" <> rest + | "2" <> rest + | "3" <> rest + | "4" <> rest + | "5" <> rest + | "6" <> rest + | "7" <> rest + | "8" <> rest + | "9" <> rest + | "A" <> rest + | "B" <> rest + | "C" <> rest + | "D" <> rest + | "E" <> rest + | "F" <> rest + | "G" <> rest + | "H" <> rest + | "I" <> rest + | "J" <> rest + | "K" <> rest + | "L" <> rest + | "M" <> rest + | "N" <> rest + | "O" <> rest + | "P" <> rest + | "Q" <> rest + | "R" <> rest + | "S" <> rest + | "T" <> rest + | "U" <> rest + | "V" <> rest + | "W" <> rest + | "X" <> rest + | "Y" <> rest + | "Z" <> rest + | "a" <> rest + | "b" <> rest + | "c" <> rest + | "d" <> rest + | "e" <> rest + | "f" <> rest + | "g" <> rest + | "h" <> rest + | "i" <> rest + | "j" <> rest + | "k" <> rest + | "l" <> rest + | "m" <> rest + | "n" <> rest + | "o" <> rest + | "p" <> rest + | "q" <> rest + | "r" <> rest + | "s" <> rest + | "t" <> rest + | "u" <> rest + | "v" <> rest + | "w" <> rest + | "x" <> rest + | "y" <> rest + | "z" <> rest -> is_record_key_body(rest) + _ -> False + } +} + +/// The record key as its underlying string. +pub fn to_string(key: RecordKey) -> String { + let RecordKey(value) = key + value +} diff --git a/core/test/record_key_test.gleam b/core/test/record_key_test.gleam new file mode 100644 index 0000000..c3dc545 --- /dev/null +++ b/core/test/record_key_test.gleam @@ -0,0 +1,65 @@ +import alicia/core/record_key +import gleam/list +import gleam/string + +pub fn record_key_valid_test() { + [ + "3jui7kd54zh2y", + "3k5nobkf2w72g", + "self", + "oyster.cafe", + "~1.2-3_", + "dHJ1ZQ", + "pre:fix", + "_", + "a", + "ABC", + "...", + "did:plc:limpet", + string.repeat("a", record_key.max_byte_length), + ] + |> list.each(fn(input) { + let assert Ok(parsed) = record_key.parse(input) as input + assert record_key.to_string(parsed) == input + }) +} + +pub fn record_key_case_sensitive_test() { + assert record_key.parse("Self") != record_key.parse("self") + let assert Ok(upper) = record_key.parse("ABC") + assert record_key.to_string(upper) == "ABC" +} + +pub fn record_key_rejects_test() { + [ + #("", record_key.Empty), + #(".", record_key.DotSegment), + #("..", record_key.DotSegment), + #("alpha/beta", record_key.InvalidCharacter), + #("#extra", record_key.InvalidCharacter), + #("@handle", record_key.InvalidCharacter), + #("any space", record_key.InvalidCharacter), + #("any+space", record_key.InvalidCharacter), + #("number[3]", record_key.InvalidCharacter), + #("number(3)", record_key.InvalidCharacter), + #("\"quote\"", record_key.InvalidCharacter), + #("dHJ1ZQ==", record_key.InvalidCharacter), + #("nel💩", record_key.InvalidCharacter), + #("50%", record_key.InvalidCharacter), + #("%20", record_key.InvalidCharacter), + #("bell\u{7}", record_key.InvalidCharacter), + ] + |> list.each(fn(pair) { + let #(input, expected) = pair + assert record_key.parse(input) == Error(expected) as input + }) +} + +pub fn record_key_length_limits_test() { + let longest = string.repeat("a", record_key.max_byte_length) + let assert Ok(parsed) = record_key.parse(longest) as longest + assert record_key.to_string(parsed) == longest + assert record_key.parse("a" <> longest) == Error(record_key.TooLong) + assert record_key.parse(string.repeat("a", 1_000_000)) + == Error(record_key.TooLong) +}