xidl-parser 0.105.0

A IDL codegen.
Documentation
use crate::{hir, parser::parser_text, rest_hir::*};

fn project_contract(source: &str) -> Result<RestHirDocument, String> {
    let spec = hir::Specification::from_typed_ast_with_properties_and_path(
        parser_text(source).expect("contract syntax"),
        [("expand_interface".into(), false.into())].into(),
        "workflow.idl",
    )
    .expect("contract HIR");
    project(&spec).map_err(|error| error.to_string())
}

#[test]
fn binary_upload_binding_distinguishes_the_entire_body_from_object_fields() {
    let document = project_contract(
        r#"
interface Uploads {
    @post(path="/raw") void raw(@body sequence<octet> data);
    @post(path="/text") void text(@body string data);
    @post(path="/record") void record(sequence<octet> data, string checksum);
    @get(path="/status") string status();
};
"#,
    )
    .expect("upload HTTP contract");
    let actual = document.interfaces[0]
        .operations
        .iter()
        .map(|operation| {
            (
                operation.meta.name.as_str(),
                operation.http.request.body.shape.byte_parameter(),
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(
        actual,
        [
            ("raw", Some("data")),
            ("text", None),
            ("record", None),
            ("status", None),
        ]
    );
}

#[test]
fn conditional_file_update_preserves_each_wire_binding_and_typed_failure() {
    let document = project_contract(
        r#"
module files {
    struct Entry { string name; };
    @http(status=412) exception Precondition {
        @header @optional sequence<string> reasons;
        @cookie string session;
        @body @rename("message") string why;
    };
    interface Store {
        @patch(path="/files/{id}{?revision}")
        Entry update(@path string id, @query @optional uint32 revision,
            @header @rename("If-Match") string etag, @cookie string session,
            @body @flatten Entry entry, @header @rename("ETag") out string next_etag,
            @cookie out string rotated) raises(Precondition);
    };
};
"#,
    )
    .unwrap();
    let operation = &document.interfaces[0].operations[0];
    assert_eq!(operation.meta.method, HttpMethod::Patch);
    assert_eq!(operation.meta.routes[0].path, "/files/{id}");
    let request = &operation.http.request;
    assert_eq!(request.path[0].source_param, "id");
    assert_eq!(request.query[0].wire_name, "revision");
    assert!(request.query[0].optional);
    assert_eq!(request.header[0].wire_name, "If-Match");
    assert_eq!(request.cookie[0].wire_name, "session");
    assert!(
        matches!(&request.body.shape, HttpRequestBodyShape::SingleValue { source_param, flatten: true, .. } if source_param == "entry")
    );
    assert_eq!(
        request.body.content_type.as_deref(),
        Some("application/json")
    );
    let entry = operation
        .signature
        .params
        .iter()
        .find(|p| p.name == "entry")
        .unwrap();
    assert!(
        entry
            .annotations
            .contains(&HttpSignatureParamAnnotation::Flatten)
    );
    assert!(
        entry
            .annotations
            .contains(&HttpSignatureParamAnnotation::Body)
    );
    let response = &operation.http.response;
    assert_eq!(response.status, "200");
    assert_eq!(response.header[0].wire_name, "ETag");
    assert_eq!(response.cookie[0].wire_name, "rotated");
    assert!(matches!(
        response.body.shape,
        HttpResponseBodyShape::ReturnOnly { .. }
    ));
    let error = &document.document.exceptions[0];
    assert_eq!(error.status, 412);
    assert!(error.headers[0].optional && error.headers[0].is_multi);
    assert_eq!(error.cookies[0].wire_name, "session");
    assert_eq!(error.body[0].wire_name, "message");
    assert_eq!(operation.meta.raises[0].module_path, ["files"]);
}

#[test]
fn blob_and_event_workflows_select_their_framing_and_default_media_types() {
    let document = project_contract(
        r#"
interface Blobs {
    @post(path="/blob") void put(@body sequence<octet> payload);
    @client_stream void upload(@body sequence<octet> chunks);
    @server_stream sequence<octet> download();
    @server_stream void download_out(out sequence<octet> chunks);
    @server_stream @stream_codec("ndjson") string events();
};
"#,
    )
    .unwrap();
    let operations = &document.interfaces[0].operations;
    let put = &operations[0].http;
    assert!(matches!(
        put.request.body.shape,
        HttpRequestBodyShape::SingleValue { .. }
    ));
    assert_eq!(
        put.request.body.content_type.as_deref(),
        Some("application/octet-stream")
    );
    assert_eq!(put.response.status, "204");
    let upload = &operations[1].http.request.body;
    assert!(
        matches!(&upload.shape, HttpRequestBodyShape::Stream { source_param, codec: HttpStreamPayloadCodec::Bytes, .. } if source_param == "chunks")
    );
    for operation in &operations[2..4] {
        assert_eq!(operation.http.response.status, "200");
        assert_eq!(
            operation.http.response.body.content_type.as_deref(),
            Some("application/octet-stream")
        );
        assert!(matches!(
            operation.http.response.body.shape,
            HttpResponseBodyShape::Stream {
                codec: HttpStreamPayloadCodec::Bytes,
                ..
            }
        ));
    }
    assert!(matches!(
        operations[2].http.response.body.shape,
        HttpResponseBodyShape::Stream {
            item_source: HttpOutputSource::ReturnValue,
            ..
        }
    ));
    assert!(
        matches!(&operations[3].http.response.body.shape, HttpResponseBodyShape::Stream { item_source: HttpOutputSource::Param { name }, .. } if name == "chunks")
    );
    assert_eq!(
        operations[4].http.response.body.content_type.as_deref(),
        Some("application/x-ndjson")
    );
    assert!(matches!(
        operations[4].http.response.body.shape,
        HttpResponseBodyShape::Stream {
            codec: HttpStreamPayloadCodec::Ndjson,
            ..
        }
    ));
}

#[test]
fn command_routes_keep_default_object_bodies_and_head_metadata() {
    let document = project_contract(
        r#"
interface Commands {
    @post(path="/items") void create(@body string name, @body @optional string note);
    @put(path="/items") void replace(@body string value);
    @head(path="/items") void probe(@header out string etag);
    @options(path="/items") void capabilities();
    @oneway void notify(@body string message);
};
"#,
    )
    .unwrap();
    let operations = &document.interfaces[0].operations;
    let request = &operations[0].http.request.body;
    assert_eq!(request.content_type.as_deref(), Some("application/json"));
    let HttpRequestBodyShape::Object { fields } = &request.shape else {
        panic!("object body")
    };
    assert_eq!(
        fields
            .iter()
            .map(|p| (p.field_name.as_str(), p.optional))
            .collect::<Vec<_>>(),
        [("name", false), ("note", true)]
    );
    assert_eq!(
        operations.iter().map(|p| p.meta.method).collect::<Vec<_>>(),
        [
            HttpMethod::Post,
            HttpMethod::Put,
            HttpMethod::Head,
            HttpMethod::Options,
            HttpMethod::Post
        ]
    );
    assert!(matches!(
        operations[2].http.response.body.shape,
        HttpResponseBodyShape::Empty
    ));
    assert_eq!(operations[2].http.response.header[0].wire_name, "etag");
    assert_eq!(operations[4].meta.routes[0].path, "/notify");
}

#[test]
fn failures_report_the_invalid_exception_or_route_contract() {
    for (source, expected) in [
        (
            "@http(422) exception Invalid { string names[2]; };",
            vec!["Invalid", "array declarator"],
        ),
        (
            "exception MissingStatus { string message; };",
            vec!["MissingStatus", "@http"],
        ),
        (
            "@http(other=412) exception Invalid { string message; };",
            vec!["Invalid", "status"],
        ),
        (
            "@http(\"not-a-status\") exception Invalid { string message; };",
            vec!["Invalid", "not-a-status"],
        ),
        (
            "interface Files { string read() raises(Missing); };",
            vec!["read", "Missing"],
        ),
        (
            "@http(409) exception A { string message; }; @http(409) exception B { string message; }; interface Files { void read() raises(A, B); };",
            vec!["read", "409"],
        ),
        (
            "interface Files { @get(path=\"/files{?cursor}/tail\") void read(); };",
            vec!["query template", "terminate"],
        ),
        (
            "interface Files { @get(path=\"/files{?cursor,}\") void read(); };",
            vec!["empty", "query"],
        ),
        (
            "interface Files { @get(path=\"/files/{}\") void read(); };",
            vec!["empty", "path"],
        ),
        (
            "interface Files { @get(path=\"/files\") void read(@path string id); };",
            vec!["id", "route"],
        ),
    ] {
        let error = project_contract(source).unwrap_err();
        for fragment in expected {
            assert!(
                error.contains(fragment),
                "{source}: {error} lacks {fragment}"
            );
        }
    }
}