From 76940da8f426e10ecb860d08ceb30def79e1fe1d Mon Sep 17 00:00:00 2001 From: Matt Stavola Date: Sun, 12 Oct 2025 05:34:00 +0000 Subject: [PATCH] Annotations for encoding and record key selection --- mlf-codegen/src/lib.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ website/content/docs/language-guide/01-your-first-lexicon.md | 2 ++ website/content/docs/language-guide/07-xrpc.md | 24 ++++++++++++++++++++++++ website/content/docs/language-guide/11-annotations.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 file(s) changed, 151 insertion(s)(+), 6 deletion(s)(-) diff --git a/mlf-codegen/src/lib.rs b/mlf-codegen/src/lib.rs --- a/mlf-codegen/src/lib.rs +++ b/mlf-codegen/src/lib.rs @@ -72,6 +72,46 @@ annotations.iter().any(|ann| ann.name.name == "main") } +fn get_annotation_string_value(annotations: &[Annotation], name: &str) -> Option { + annotations.iter() + .find(|ann| ann.name.name == name) + .and_then(|ann| { + // Get first positional argument if it exists + ann.args.first().and_then(|arg| { + match arg { + AnnotationArg::Positional(AnnotationValue::String(s)) => Some(s.clone()), + _ => None, + } + }) + }) +} + +fn get_encoding_annotation(annotations: &[Annotation], param_name: &str) -> Option { + annotations.iter() + .find(|ann| ann.name.name == "encoding") + .and_then(|ann| { + // First check for named argument matching param_name + for arg in &ann.args { + if let AnnotationArg::Named { name, value } = arg { + if name.name == param_name { + if let AnnotationValue::String(s) = value { + return Some(s.clone()); + } + } + } + } + + // Fall back to positional argument (applies to both input and output) + for arg in &ann.args { + if let AnnotationArg::Positional(AnnotationValue::String(s)) = arg { + return Some(s.clone()); + } + } + + None + }) +} + pub fn generate_lexicon(namespace: &str, lexicon: &Lexicon, workspace: &Workspace) -> Value { let usage_counts = analyze_type_usage(lexicon); @@ -319,10 +359,13 @@ "properties": properties }); + // Check for @key annotation, default to "tid" + let key = get_annotation_string_value(&record.annotations, "key").unwrap_or_else(|| "tid".to_string()); + json!({ "type": "record", "description": extract_docs(&record.docs), - "key": "tid", + "key": key, "record": record_obj }) } @@ -358,11 +401,15 @@ Value::Object(params_obj) }; + // Check for @encoding annotation (output only for queries), default to "application/json" + let output_encoding = get_encoding_annotation(&query.annotations, "output") + .unwrap_or_else(|| "application/json".to_string()); + let output = match &query.returns { ReturnType::None { .. } => None, ReturnType::Type(ty) => { let mut output_obj = Map::new(); - output_obj.insert("encoding".to_string(), json!("application/json")); + output_obj.insert("encoding".to_string(), json!(output_encoding)); output_obj.insert("schema".to_string(), generate_type_json(ty, usage_counts, workspace, current_namespace)); Some(Value::Object(output_obj)) } @@ -378,7 +425,7 @@ } let mut output_obj = Map::new(); - output_obj.insert("encoding".to_string(), json!("application/json")); + output_obj.insert("encoding".to_string(), json!(output_encoding)); output_obj.insert("schema".to_string(), generate_type_json(success, usage_counts, workspace, current_namespace)); output_obj.insert("errors".to_string(), json!(error_defs)); Some(Value::Object(output_obj)) @@ -413,6 +460,10 @@ params_properties.insert(param.name.name.clone(), param_json); } + // Check for @encoding annotation with "input" parameter, default to "application/json" + let input_encoding = get_encoding_annotation(&procedure.annotations, "input") + .unwrap_or_else(|| "application/json".to_string()); + let input = if !params_properties.is_empty() { let mut schema_obj = Map::new(); schema_obj.insert("type".to_string(), json!("object")); @@ -420,18 +471,22 @@ schema_obj.insert("properties".to_string(), json!(params_properties)); let mut input_obj = Map::new(); - input_obj.insert("encoding".to_string(), json!("application/json")); + input_obj.insert("encoding".to_string(), json!(input_encoding)); input_obj.insert("schema".to_string(), Value::Object(schema_obj)); Some(Value::Object(input_obj)) } else { None }; + // Check for @encoding annotation with "output" parameter, default to "application/json" + let output_encoding = get_encoding_annotation(&procedure.annotations, "output") + .unwrap_or_else(|| "application/json".to_string()); + let output = match &procedure.returns { ReturnType::None { .. } => None, ReturnType::Type(ty) => { let mut output_obj = Map::new(); - output_obj.insert("encoding".to_string(), json!("application/json")); + output_obj.insert("encoding".to_string(), json!(output_encoding)); output_obj.insert("schema".to_string(), generate_type_json(ty, usage_counts, workspace, current_namespace)); Some(Value::Object(output_obj)) } @@ -447,7 +502,7 @@ } let mut output_obj = Map::new(); - output_obj.insert("encoding".to_string(), json!("application/json")); + output_obj.insert("encoding".to_string(), json!(output_encoding)); output_obj.insert("schema".to_string(), generate_type_json(success, usage_counts, workspace, current_namespace)); output_obj.insert("errors".to_string(), json!(error_defs)); Some(Value::Object(output_obj)) diff --git a/website/content/docs/language-guide/01-your-first-lexicon.md b/website/content/docs/language-guide/01-your-first-lexicon.md --- a/website/content/docs/language-guide/01-your-first-lexicon.md +++ b/website/content/docs/language-guide/01-your-first-lexicon.md @@ -74,6 +74,8 @@ The MLF syntax is much cleaner and easier to read! +**Note:** The `"key": "tid"` field specifies that records use timestamp-based identifiers by default. You can customize this with the `@key` annotation. See [Annotations](/docs/language-guide/annotations/#key-for-records) for details. + ## Comments MLF supports three types of comments: diff --git a/website/content/docs/language-guide/07-xrpc.md b/website/content/docs/language-guide/07-xrpc.md --- a/website/content/docs/language-guide/07-xrpc.md +++ b/website/content/docs/language-guide/07-xrpc.md @@ -241,6 +241,30 @@ query getPost(uri: AtUri):post | deleted; ``` +## Custom Encoding + +By default, all XRPC input and output uses `application/json` encoding. You can customize this using the `@encoding` annotation: + +**Query with custom output encoding:** +```mlf +@encoding("application/cbor") +query getBinaryData():bytes; +``` + +**Procedure with custom input encoding:** +```mlf +@encoding(input: "text/plain") +procedure parseText(content!: string):result; +``` + +**Procedure with different input/output encodings:** +```mlf +@encoding(input: "application/xml", output: "application/json") +procedure convertXmlToJson(xml!: string):object; +``` + +See [Annotations](/docs/language-guide/annotations/#encoding-for-queries-and-procedures) for more details on `@encoding`. + ## Complete Example Here's a complete API for a forum: diff --git a/website/content/docs/language-guide/11-annotations.md b/website/content/docs/language-guide/11-annotations.md --- a/website/content/docs/language-guide/11-annotations.md +++ b/website/content/docs/language-guide/11-annotations.md @@ -126,6 +126,70 @@ - `@typescript:*` - TypeScript code generator annotations - `@go:*` - Go code generator annotations +## MLF Built-in Annotations + +MLF's lexicon generator recognizes specific annotations that affect the generated ATProto JSON lexicon: + +### `@key` for Records + +Controls the record key type. Defaults to `"tid"` if not specified. + +```mlf +// Use literal "self" as the record key +@key("literal:self") +record profile { + name!: string, +} + +// Use timestamp-based identifier (default) +record post { + text!: string, +} +``` + +**Common key values:** +- `"tid"` - Timestamp-based identifier (default) +- `"literal:self"` - The record key is literally "self" +- Custom values as needed for your schema + +### `@encoding` for Queries and Procedures + +Controls MIME type encoding for XRPC input and output. Defaults to `"application/json"` if not specified. + +**Positional syntax** (applies to output for queries, both input/output for procedures): + +```mlf +@encoding("application/cbor") +query getData(): string; + +@encoding("application/json") +procedure upload(data!: string): string; +``` + +**Named syntax** (explicit control): + +```mlf +// Output only +@encoding(output: "text/plain") +query getText(): string; + +// Input only +@encoding(input: "application/xml") +procedure parse(data!: string): result; + +// Both input and output +@encoding(input: "application/cbor", output: "application/json") +procedure convert(data!: bytes): object; +``` + +**Common encoding values:** +- `"application/json"` - JSON (default) +- `"application/cbor"` - CBOR binary format +- `"text/plain"` - Plain text +- `"application/xml"` - XML +- `"*/*"` - Any MIME type +- Custom MIME types as needed + ## Annotation Processing Annotations are preserved in the MLF AST and can be accessed by: -- tangled.sh