Added file upload support

This commit is contained in:
2026-06-04 04:20:05 -04:00
parent 4effcc7597
commit 67ad91b673
23 changed files with 665 additions and 20 deletions

View File

@@ -165,6 +165,7 @@ fn ts_type_expression(shape: &TypeShape) -> String {
.map(ts_type_expression)
.collect::<Vec<_>>()
.join(" | "),
TypeShape::Upload(_) => "File".to_string(),
}
}

View File

@@ -118,6 +118,9 @@ fn py_type_expression(shape: &TypeShape) -> String {
.map(py_type_expression)
.collect::<Vec<_>>()
.join(" | "),
// The Python (PyO3) client is a consumer, not an upload origin; a file
// input surfaces as raw bytes on this target.
TypeShape::Upload(_) => "bytes".to_string(),
}
}

View File

@@ -440,5 +440,8 @@ fn rust_type_from_shape(shape: &TypeShape, ctx: &mut EnumCtx) -> String {
// Value so the consumer can match on the runtime variant.
"serde_json::Value".to_string()
}
// The Rust adapter does not yet wire multipart; a file input surfaces
// as raw bytes until upload dispatch lands on this target.
TypeShape::Upload(_) => "Vec<u8>".to_string(),
}
}

View File

@@ -296,5 +296,6 @@ fn ts_type_expression(shape: &TypeShape) -> String {
.map(ts_type_expression)
.collect::<Vec<_>>()
.join(" | "),
TypeShape::Upload(_) => "File".to_string(),
}
}

View File

@@ -46,6 +46,15 @@ pub enum TypeShape {
Enum(Vec<String>),
/// Multi-arm union with two or more non-null branches.
Union(Vec<TypeShape>),
/// Binary file input. Carries the declarative `File(...)` constraints.
Upload(UploadConstraints),
}
#[derive(Debug, Clone, Default)]
pub struct UploadConstraints {
pub max_size: Option<u64>,
pub content_types: Vec<String>,
}
@@ -273,6 +282,7 @@ fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
"list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
"enum" => Ok(TypeShape::Enum(parse_string_args(node))),
"upload" => Ok(TypeShape::Upload(parse_upload_constraints(node))),
"union" => {
let children = node.children()
.ok_or_else(|| anyhow!("union: missing children"))?;
@@ -285,6 +295,20 @@ fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
}
fn parse_upload_constraints(node: &KdlNode) -> UploadConstraints {
let max_size = node.entry("max-size")
.and_then(|e| e.value().as_integer())
.map(|i| i as u64);
let content_types = node.children()
.map(|children| children.nodes().iter()
.filter(|n| n.name().value() == "content-type")
.filter_map(|n| first_string_arg(n).ok())
.collect())
.unwrap_or_default();
UploadConstraints { max_size, content_types }
}
fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
let name = first_string_arg(node)
.context("`function` requires a name as its first argument")?;

View File

@@ -0,0 +1,79 @@
//! Upload type-shape lowers to TS `File` across cardinalities. Separate from
//! the byte-parity baselines (which mustn't carry an upload field — the
//! three-way AFI parity gate includes the Rust adapter, which doesn't wire
//! uploads yet).
use std::path::PathBuf;
use mizan_codegen::config::{Config, SourceConfig};
use mizan_codegen::emit::stage1::Stage1;
use mizan_codegen::emit::CodegenTarget;
use mizan_codegen::fetch::parse_ir_from_str;
const UPLOAD_IR: &str = r#"
type "SetAvatarInput" {
struct {
field "user_id" {
primitive "integer"
}
field "avatar" {
upload max-size=5242880 {
content-type "image/png"
content-type "image/jpeg"
}
}
field "photos" {
list {
upload
}
}
field "thumb" required=#false {
optional {
upload
}
}
}
}
type "setAvatarOutput" {
alias {
primitive "string"
}
}
function "set_avatar" {
camel "setAvatar"
has-input #true
input "SetAvatarInput"
output "setAvatarOutput"
transport "http"
affects "user"
}
"#;
fn cfg() -> Config {
Config {
project_id: None,
output: PathBuf::from("/tmp"),
targets: vec!["stage1".to_string()],
source: SourceConfig { fastapi: None, django: None, rust: None, script: None },
rust_kernel: None,
rust_crate_name: None,
}
}
#[test]
fn upload_fields_lower_to_file_type() {
let ir = parse_ir_from_str(UPLOAD_IR).expect("upload IR parses");
let files = Stage1.emit(&ir, &cfg());
let types = files
.iter()
.find(|f| f.rel_path.to_string_lossy().contains("types.ts"))
.expect("types.ts emitted");
let src = &types.content;
assert!(src.contains("avatar: File"), "required upload → File:\n{src}");
assert!(src.contains("File[]"), "list[upload] → File[]:\n{src}");
assert!(src.contains("File | null"), "optional upload → File | null:\n{src}");
}