// NSID -- Namespaced Identifier. The name of a Lexicon: a record collection
// (`app.bsky.feed.post`), an XRPC endpoint (`com.atproto.repo.createRecord`),
// or a type definition.
//
// https://atproto.com/specs/nsid
// Ported from @atproto/syntax packages/syntax/src/nsid.ts.
//
// segment = alpha *( alpha / number / "-" )
// authority = segment *( delim segment )
// name = alpha *( alpha / number )
// nsid = authority delim name
//
// The authority is a domain name in REVERSE order, so `app.bsky.feed.post` is
// the `post` Lexicon published by whoever controls `bsky.app`. That reversal is
// the only surprising thing about NSIDs and is why `authority()` exists rather
// than leaving callers to slice and flip the string themselves.
//
// Note the name part is stricter than the authority parts: no hyphens, and no
// leading digit. A 2023 tightening of the spec, and the rule most likely to
// trip up a hand-written validator that treats every segment alike.
///|
/// 253 for the authority (a domain name), plus the separating dot, plus 63 for
/// the name.
const NSID_MAX_LENGTH : Int = 317
///|
const NSID_MAX_SEGMENT_LENGTH : Int = 63
///|
/// A syntactically valid NSID.
pub struct Nsid(String) derive(Eq, Debug)
///|
pub impl Show for Nsid with fn output(self, logger) {
logger.write_string(self.0)
}
///|
pub fn Nsid::to_string(self : Self) -> String {
self.0
}
///|
pub fn Nsid::unchecked(nsid : String) -> Nsid {
Nsid(nsid)
}
///|
pub fn Nsid::is_valid(nsid : String) -> Bool {
try {
Nsid::parse(nsid) |> ignore
true
} catch {
_ => false
}
}
///|
pub fn Nsid::parse(nsid : String) -> Nsid raise SyntaxError {
fn bad(reason : String) -> SyntaxError {
SyntaxError(kind=Nsid, input=nsid, reason~)
}
guard nsid.length() <= NSID_MAX_LENGTH else {
raise bad("NSID is too long (\{NSID_MAX_LENGTH} chars max)")
}
guard all_chars(nsid, is_dns_char) else {
raise bad(
"Disallowed characters in NSID (ASCII letters, digits, dashes, periods only)",
)
}
let segments = nsid.split(".").collect()
guard segments.length() >= 3 else {
raise bad("NSID needs at least three parts")
}
for segment in segments {
guard segment.length() > 0 else { raise bad("NSID parts can not be empty") }
guard segment.length() <= NSID_MAX_SEGMENT_LENGTH else {
raise bad("NSID part too long (max \{NSID_MAX_SEGMENT_LENGTH} chars)")
}
guard !code_unit_is(segment[0], '-') &&
!code_unit_is(segment[segment.length() - 1], '-') else {
raise bad("NSID parts can not start or end with hyphen")
}
}
guard !code_unit_is_ascii_digit(segments[0][0]) else {
raise bad("NSID first part may not start with a digit")
}
// The name is the last segment, and its rule is not the segment rule: letters
// and digits only -- no hyphen -- and no leading digit.
let name = segments[segments.length() - 1]
guard code_unit_is_ascii_alpha(name[0]) && !name.contains("-") else {
raise bad(
"NSID name part must be only letters and digits (and no leading digit)",
)
}
Nsid(nsid)
}
///|
/// The publishing authority, as a domain name -- so `app.bsky.feed.post` has
/// authority `bsky.app`. The segments are reversed back into DNS order, which
/// is the form you would look up or check a certificate against.
pub fn Nsid::authority(self : Self) -> String {
let segments = self.0.split(".").collect()
let b = StringBuilder::new(size_hint=self.0.length())
// Every segment but the last, in reverse.
for i = segments.length() - 2; i >= 0; i = i - 1 {
b.write_string(segments[i].to_owned())
if i > 0 {
b.write_char('.')
}
}
b.to_string()
}
///|
/// The name part -- `post` in `app.bsky.feed.post`.
pub fn Nsid::name(self : Self) -> String {
let segments = self.0.split(".").collect()
segments[segments.length() - 1].to_owned()
}