···1+namespace PDSharp.Core
2+3+open System
4+open System.Text.Json
5+6+module Lexicon =
7+ type LexiconResult =
8+ | Ok
9+ | Error of string
10+11+ module Validation =
12+ let private getProperty (p : string) (element : JsonElement) =
13+ match element.TryGetProperty(p) with
14+ | true, prop -> Some prop
15+ | _ -> None
16+17+ let private getString (p : string) (element : JsonElement) =
18+ match getProperty p element with
19+ | Some prop when prop.ValueKind = JsonValueKind.String -> Some(prop.GetString())
20+ | _ -> None
21+22+ let private validateStringField
23+ (element : JsonElement)
24+ (fieldName : string)
25+ (maxLength : int option)
26+ (required : bool)
27+ : LexiconResult =
28+ match getProperty fieldName element with
29+ | Some prop ->
30+ if prop.ValueKind <> JsonValueKind.String then
31+ Error $"Field '{fieldName}' must be a string"
32+ else
33+ match maxLength with
34+ | Some maxLen when prop.GetString().Length > maxLen ->
35+ Error $"Field '{fieldName}' exceeds maximum length of {maxLen}"
36+ | _ -> Ok
37+ | None ->
38+ if required then
39+ Error $"Missing required field '{fieldName}'"
40+ else
41+ Ok
42+43+ let private validateIsoDate (element : JsonElement) (fieldName : string) (required : bool) : LexiconResult =
44+ match getProperty fieldName element with
45+ | Some prop ->
46+ if prop.ValueKind <> JsonValueKind.String then
47+ Error $"Field '{fieldName}' must be a string"
48+ else
49+ let s = prop.GetString()
50+ let mutable dt = DateTimeOffset.MinValue
51+52+ if DateTimeOffset.TryParse(s, &dt) then
53+ Ok
54+ else
55+ Error $"Field '{fieldName}' must be a valid ISO 8601 date string"
56+ | None ->
57+ if required then
58+ Error $"Missing required field '{fieldName}'"
59+ else
60+ Ok
61+62+ let private validateRef (element : JsonElement) (fieldName : string) (required : bool) : LexiconResult =
63+ match getProperty fieldName element with
64+ | Some prop ->
65+ if prop.ValueKind <> JsonValueKind.Object then
66+ Error $"Field '{fieldName}' must be an object"
67+ else
68+ match validateStringField prop "uri" None true, validateStringField prop "cid" None true with
69+ | Ok, Ok -> Ok
70+ | Error e, _ -> Error $"Field '{fieldName}': {e}"
71+ | _, Error e -> Error $"Field '{fieldName}': {e}"
72+ | None ->
73+ if required then
74+ Error $"Missing required field '{fieldName}'"
75+ else
76+ Ok
77+78+ let validatePost (record : JsonElement) : LexiconResult =
79+ let textCheck = validateStringField record "text" (Some 3000) true
80+ let dateCheck = validateIsoDate record "createdAt" true
81+82+ match textCheck, dateCheck with
83+ | Ok, Ok -> Ok
84+ | Error e, _ -> Error e
85+ | _, Error e -> Error e
86+87+ let validateLike (record : JsonElement) : LexiconResult =
88+ let subjectCheck = validateRef record "subject" true
89+ let dateCheck = validateIsoDate record "createdAt" true
90+91+ match subjectCheck, dateCheck with
92+ | Ok, Ok -> Ok
93+ | Error e, _ -> Error e
94+ | _, Error e -> Error e
95+96+ let validateRepost (record : JsonElement) : LexiconResult =
97+ let subjectCheck = validateRef record "subject" true
98+ let dateCheck = validateIsoDate record "createdAt" true
99+100+ match subjectCheck, dateCheck with
101+ | Ok, Ok -> Ok
102+ | Error e, _ -> Error e
103+ | _, Error e -> Error e
104+105+ let validateFollow (record : JsonElement) : LexiconResult =
106+ let subjectCheck = validateStringField record "subject" None true
107+ let dateCheck = validateIsoDate record "createdAt" true
108+109+ match subjectCheck, dateCheck with
110+ | Ok, Ok -> Ok
111+ | Error e, _ -> Error e
112+ | _, Error e -> Error e
113+114+ let validateProfile (record : JsonElement) : LexiconResult =
115+ let nameCheck = validateStringField record "displayName" (Some 640) false
116+ let descCheck = validateStringField record "description" (Some 2560) false
117+118+ match nameCheck, descCheck with
119+ | Ok, Ok -> Ok
120+ | Error e, _ -> Error e
121+ | _, Error e -> Error e
122+123+ /// Unknown records are valid but unvalidated.
124+ let validate (collection : string) (record : JsonElement) : LexiconResult =
125+ match collection with
126+ | "app.bsky.feed.post" -> Validation.validatePost record
127+ | "app.bsky.feed.like" -> Validation.validateLike record
128+ | "app.bsky.feed.repost" -> Validation.validateRepost record
129+ | "app.bsky.graph.follow" -> Validation.validateFollow record
130+ | "app.bsky.actor.profile" -> Validation.validateProfile record
131+ | _ -> Ok