1use std::collections::BTreeMap;
14use std::fmt::Write as _;
15use std::str::FromStr;
16
17use cedar_policy::{EntityId, EntityTypeName, EntityUid, Schema, SchemaFragment, Validator};
18
19pub const NAMESPACE: &str = "Didbot";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub enum ActionName {
25 RecordWrite,
27 BlobWrite,
29 OauthAuthorize,
31 OauthToken,
33 AccountCreate,
35}
36
37impl ActionName {
38 pub const ALL: [ActionName; 5] = [
40 ActionName::RecordWrite,
41 ActionName::BlobWrite,
42 ActionName::OauthAuthorize,
43 ActionName::OauthToken,
44 ActionName::AccountCreate,
45 ];
46
47 #[must_use]
49 pub fn as_str(self) -> &'static str {
50 match self {
51 ActionName::RecordWrite => "record.write",
52 ActionName::BlobWrite => "blob.write",
53 ActionName::OauthAuthorize => "oauth.authorize",
54 ActionName::OauthToken => "oauth.token",
55 ActionName::AccountCreate => "account.create",
56 }
57 }
58
59 #[must_use]
61 pub fn parse(name: &str) -> Option<Self> {
62 Self::ALL.into_iter().find(|action| action.as_str() == name)
63 }
64
65 #[must_use]
67 pub(crate) fn uid(self) -> EntityUid {
68 uid("Action", self.as_str())
69 }
70
71 pub(crate) fn of_uid(uid: &EntityUid) -> Option<Self> {
73 let expected = EntityTypeName::from_str(&format!("{NAMESPACE}::Action"))
74 .expect("the action type name is a valid Cedar name");
75 if uid.type_name() != &expected {
76 return None;
77 }
78 Self::parse(uid.id().unescaped())
79 }
80}
81
82impl std::fmt::Display for ActionName {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.write_str(self.as_str())
85 }
86}
87
88pub(crate) fn uid(type_name: &str, id: &str) -> EntityUid {
90 let type_name = EntityTypeName::from_str(&format!("{NAMESPACE}::{type_name}"))
91 .expect("every type name this crate builds is a valid Cedar name");
92 EntityUid::from_type_name_and_id(type_name, EntityId::new(id))
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum FieldType {
98 String,
100 Long,
102 Bool,
104 Datetime,
107 Set(Box<FieldType>),
109 Record(Shape),
111}
112
113impl FieldType {
114 fn write_cedar(&self, out: &mut String) {
115 match self {
116 FieldType::String => out.push_str("String"),
117 FieldType::Long => out.push_str("Long"),
118 FieldType::Bool => out.push_str("Bool"),
119 FieldType::Datetime => out.push_str("datetime"),
120 FieldType::Set(element) => {
121 out.push_str("Set<");
122 element.write_cedar(out);
123 out.push('>');
124 }
125 FieldType::Record(shape) => shape.write_cedar(out),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Field {
133 pub ty: FieldType,
135 pub required: bool,
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct Shape {
149 fields: BTreeMap<String, Field>,
150}
151
152impl Shape {
153 #[must_use]
155 pub fn new() -> Self {
156 Self::default()
157 }
158
159 #[must_use]
161 pub fn field(mut self, name: &str, ty: FieldType) -> Self {
162 self.fields
163 .insert(name.to_owned(), Field { ty, required: true });
164 self
165 }
166
167 #[must_use]
169 pub fn optional(mut self, name: &str, ty: FieldType) -> Self {
170 self.fields.insert(
171 name.to_owned(),
172 Field {
173 ty,
174 required: false,
175 },
176 );
177 self
178 }
179
180 #[must_use]
182 pub fn get(&self, name: &str) -> Option<&Field> {
183 self.fields.get(name)
184 }
185
186 fn write_cedar(&self, out: &mut String) {
187 out.push_str("{ ");
188 for (index, (name, field)) in self.fields.iter().enumerate() {
189 if index > 0 {
190 out.push_str(", ");
191 }
192 let _ = write!(out, "{}", serde_json::Value::String(name.clone()));
193 if !field.required {
194 out.push('?');
195 }
196 out.push_str(": ");
197 field.ty.write_cedar(out);
198 }
199 out.push_str(" }");
200 }
201}
202
203#[must_use]
206pub fn schema_text(record: Option<&Shape>) -> String {
207 let mut record_context = String::new();
208 if let Some(shape) = record {
209 let mut ty = String::new();
210 shape.write_cedar(&mut ty);
211 let _ = write!(record_context, r#", "value"?: {ty}, "existing"?: {ty}"#);
212 }
213 format!(
214 r#"namespace {NAMESPACE} {{
215 entity Account in [Account] {{ "kind"?: String, "handle"?: String, "depth"?: Long }};
216 entity Record {{ "collection": String, "rkey": String, "space"?: String }};
217 entity Blob {{ "mimeType": String, "sniffed": String, "size": Long, "space"?: String }};
218 entity Client {{ "id": String }};
219 entity Name {{ "name"?: String, "label"?: String, "zone": String, "pool": String }};
220 action "record.write" appliesTo {{
221 principal: [Account],
222 resource: [Record],
223 context: {{ "operation": String, "client"?: Client, "now": datetime{record_context} }}
224 }};
225 action "blob.write" appliesTo {{
226 principal: [Account],
227 resource: [Blob],
228 context: {{ "now": datetime }}
229 }};
230 action "oauth.authorize" appliesTo {{
231 principal: [Account],
232 resource: [Client],
233 context: {{ "scopes": Set<String>, "now": datetime }}
234 }};
235 action "oauth.token" appliesTo {{
236 principal: [Account],
237 resource: [Client],
238 context: {{ "grant": String, "scopes": Set<String>, "now": datetime }}
239 }};
240 action "account.create" appliesTo {{
241 principal: [Account],
242 resource: [Name],
243 context: {{ "kind": String, "asked": String, "now": datetime }}
244 }};
245}}
246"#
247 )
248}
249
250#[must_use]
254pub fn schema_json(record: Option<&Shape>) -> serde_json::Value {
255 let (fragment, _) = SchemaFragment::from_cedarschema_str(&schema_text(record))
256 .expect("the schema is this crate's own text");
257 fragment
258 .to_json_value()
259 .expect("a parsed schema serializes")
260}
261
262#[derive(Debug, thiserror::Error)]
264#[error("the shape for `{collection}` is not a Cedar record type: {reason}")]
265pub struct ShapeError {
266 pub collection: String,
268 pub reason: String,
270}
271
272struct Described {
273 shape: Shape,
274 validator: Validator,
275}
276
277pub struct Shapes {
280 base: Validator,
281 described: BTreeMap<String, Described>,
282}
283
284impl Default for Shapes {
285 fn default() -> Self {
286 Self::new()
287 }
288}
289
290impl Shapes {
291 #[must_use]
293 pub fn new() -> Self {
294 let (base, _) = Schema::from_cedarschema_str(&schema_text(None))
295 .expect("the base schema is this crate's own text");
296 Self {
297 base: Validator::new(base),
298 described: BTreeMap::new(),
299 }
300 }
301
302 pub fn describe(&mut self, collection: &str, shape: Shape) -> Result<(), ShapeError> {
304 let (schema, _) =
305 Schema::from_cedarschema_str(&schema_text(Some(&shape))).map_err(|error| {
306 ShapeError {
307 collection: collection.to_owned(),
308 reason: error.to_string(),
309 }
310 })?;
311 self.described.insert(
312 collection.to_owned(),
313 Described {
314 shape,
315 validator: Validator::new(schema),
316 },
317 );
318 Ok(())
319 }
320
321 #[must_use]
323 pub fn shape_of(&self, collection: &str) -> Option<&Shape> {
324 self.described.get(collection).map(|d| &d.shape)
325 }
326
327 pub fn collections(&self) -> impl Iterator<Item = &str> {
329 self.described.keys().map(String::as_str)
330 }
331
332 pub(crate) fn base(&self) -> &Validator {
333 &self.base
334 }
335
336 pub(crate) fn validator_for(&self, collection: &str) -> Option<&Validator> {
337 self.described.get(collection).map(|d| &d.validator)
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
348 fn every_field_type_makes_a_valid_schema() {
349 let shape = Shape::new()
350 .field("text", FieldType::String)
351 .field("createdAt", FieldType::Datetime)
352 .optional("$type", FieldType::String)
353 .optional("count", FieldType::Long)
354 .optional("pinned", FieldType::Bool)
355 .optional("langs", FieldType::Set(Box::new(FieldType::String)))
356 .optional(
357 "reply",
358 FieldType::Record(Shape::new().field("uri", FieldType::String)),
359 );
360 let mut shapes = Shapes::new();
361 shapes
362 .describe("app.bsky.feed.post", shape)
363 .expect("the shape is a valid record type");
364 assert!(shapes.shape_of("app.bsky.feed.post").is_some());
365 }
366
367 #[test]
368 fn the_json_schema_names_every_action() {
369 let json = schema_json(None);
370 for action in ActionName::ALL {
371 assert!(
372 json[NAMESPACE]["actions"][action.as_str()].is_object(),
373 "{action}"
374 );
375 }
376 }
377
378 #[test]
379 fn action_names_round_trip_through_entity_references() {
380 for action in ActionName::ALL {
381 assert_eq!(ActionName::of_uid(&action.uid()), Some(action));
382 }
383 assert_eq!(ActionName::of_uid(&uid("Action", "record.wrtie")), None);
384 assert_eq!(ActionName::of_uid(&uid("Client", "record.write")), None);
385 }
386}