alef 0.85.15

Opinionated polyglot binding generator for Rust libraries
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! C# HTTP e2e test rendering.

use crate::e2e::escape::{escape_csharp, sanitize_ident};
use crate::e2e::fixture::{Fixture, HttpFixture, ValidationErrorExpectation};
use heck::ToUpperCamelCase;

use crate::e2e::codegen::client;

// ---------------------------------------------------------------------------
// HTTP test rendering — shared-driver integration
// ---------------------------------------------------------------------------

/// Renderer that emits xUnit `[Fact] public async Task Test_*()` methods using
/// `System.Net.Http.HttpClient` against the mock server at `MOCK_SERVER_URL`.
/// Satisfies [`client::TestClientRenderer`] so the shared
/// [`client::http_call::render_http_test`] driver drives the call sequence.
struct CSharpTestClientRenderer;

/// C# HttpMethod static properties are PascalCase (Get, Post, Put, Delete, …).
fn to_csharp_http_method(method: &str) -> String {
    let lower = method.to_ascii_lowercase();
    let mut chars = lower.chars();
    match chars.next() {
        Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
        None => String::new(),
    }
}

/// Headers that belong to `request.Content.Headers` rather than `request.Headers`.
///
/// Adding these to `request.Headers` causes .NET to throw "Misused header name".
const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
    "content-length",
    "host",
    "connection",
    "expect",
    "transfer-encoding",
    "upgrade",
    // Content-Type is owned by request.Content.Headers and is set when
    // StringContent is constructed; adding it to request.Headers throws.
    "content-type",
    // Other entity headers also belong to request.Content.Headers.
    "content-encoding",
    "content-language",
    "content-location",
    "content-md5",
    "content-range",
    "content-disposition",
];

/// Whether `name` (any case) belongs to `response.Content.Headers` rather than
/// `response.Headers`. Picking the wrong collection causes .NET to throw
/// "Misused header name".
fn is_csharp_content_header(name: &str) -> bool {
    matches!(
        name.to_ascii_lowercase().as_str(),
        "content-type"
            | "content-length"
            | "content-encoding"
            | "content-language"
            | "content-location"
            | "content-md5"
            | "content-range"
            | "content-disposition"
            | "expires"
            | "last-modified"
            | "allow"
    )
}

impl client::TestClientRenderer for CSharpTestClientRenderer {
    fn language_name(&self) -> &'static str {
        "csharp"
    }

    /// Convert a fixture id to the PascalCase identifier used in `Test_{name}`.
    fn sanitize_test_name(&self, id: &str) -> String {
        id.to_upper_camel_case()
    }

    /// Emit `[Fact]` (or `[Fact(Skip = "…")]` for skipped tests), the method
    /// signature, the opening brace, and the description comment.
    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
        let escaped_reason = skip_reason.map(escape_csharp);
        let rendered = crate::e2e::template_env::render(
            "csharp/http_test_open.jinja",
            minijinja::context! {
                fn_name => fn_name,
                description => description,
                skip_reason => escaped_reason,
            },
        );
        out.push_str(&rendered);
    }

    /// Emit the closing `}` for a test method.
    fn render_test_close(&self, out: &mut String) {
        let rendered = crate::e2e::template_env::render("csharp/http_test_close.jinja", minijinja::context! {});
        out.push_str(&rendered);
    }

    /// Emit the `HttpRequestMessage` construction, headers, cookies, body, and
    /// `var response = await client.SendAsync(request)`.
    ///
    /// The fixture path follows the mock-server convention `/fixtures/<id>`.
    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
        let method = to_csharp_http_method(ctx.method);

        // Extract path parameter names from placeholders like {id}, {date}, etc.
        // These will be declared as placeholder variables (empty strings for now)
        let path_param_names = extract_path_param_names(ctx.path);

        out.push_str("        var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";\n");

        // Emit declarations for any path parameters found in the URL pattern
        // These are placeholders like {id}, {date} that will be interpolated into the path
        for param_name in &path_param_names {
            out.push_str(&format!("        var {param_name} = \"\";\n"));
        }

        // Disable auto-follow so redirect-status fixtures (3xx) can assert the
        // server's status code rather than the followed-target's status.
        // AutomaticDecompression is not a convenience here, it is required for
        // correctness. A fixture declaring `content-encoding` is served a genuinely
        // encoded body, and this client advertises the encoding in `Accept-Encoding`;
        // without decompression the raw bytes reach the JSON reader, which fails on
        // brotli's leading 0x1B. .NET decodes gzip, deflate and brotli natively, so the
        // whole class is covered by the handler rather than per-encoding test code.
        out.push_str(
            "        using var handler = new System.Net.Http.HttpClientHandler { AllowAutoRedirect = false, AutomaticDecompression = System.Net.DecompressionMethods.All };\n",
        );
        out.push_str("        using var client = new System.Net.Http.HttpClient(handler);\n");
        // Don't escape the path - it contains {param} placeholders that need to be preserved
        // for C# string interpolation
        out.push_str(&format!("        var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{}\");\n", ctx.path));

        // Set body + Content-Type when a request body is present.
        if let Some(body) = ctx.body {
            let content_type = ctx.content_type.unwrap_or("application/json");
            // When body is a JSON string, use it directly as the request body content
            // (no additional serialization). For objects/arrays, serialize to JSON.
            let body_str = match body {
                serde_json::Value::String(s) => s.clone(),
                other => serde_json::to_string(other).unwrap_or_default(),
            };
            let escaped = escape_csharp(&body_str);

            // For multipart/form-data with boundary, use ByteArrayContent with explicit header
            // because StringContent constructor rejects boundary in MediaType.
            if content_type.contains("multipart/form-data") && content_type.contains("boundary=") {
                // Extract the base content type and boundary parameter
                let boundary_pos = content_type.find("boundary=").unwrap_or(0);
                let boundary_value = &content_type[boundary_pos + 9..];

                out.push_str("        var multipartBytes = System.Text.Encoding.UTF8.GetBytes(\"");
                out.push_str(&escaped);
                out.push_str("\");\n");
                out.push_str("        var multipartContent = new System.Net.Http.ByteArrayContent(multipartBytes);\n");
                out.push_str("        var mediaType = new System.Net.Http.Headers.MediaTypeHeaderValue(\"multipart/form-data\");\n");
                out.push_str(&format!("        mediaType.Parameters.Add(new System.Net.Http.Headers.NameValueHeaderValue(\"boundary\", \"{boundary_value}\"));\n"));
                out.push_str("        multipartContent.Headers.ContentType = mediaType;\n");
                out.push_str("        request.Content = multipartContent;\n");
            } else if content_type.contains(';') {
                // Any media type carrying parameters has to go through `MediaTypeHeaderValue`,
                // not through `StringContent`'s three-argument constructor: that constructor runs
                // `CheckMediaTypeFormat`, which rejects a media type with parameters outright and
                // throws `FormatException` before a request is ever sent. The multipart branch
                // above exists for the same reason; it is kept separate only because
                // `ByteArrayContent` avoids `StringContent` appending its own `charset` for the
                // one case where the fixture body is already-encoded bytes. Assigning
                // `Headers.ContentType` after construction overwrites that appended charset, so
                // the header the request carries is exactly what the fixture declared. ~keep
                out.push_str(&format!(
                    "        var parameterizedContent = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8);\n"
                ));
                out.push_str(&format!(
                    "        parameterizedContent.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(\"{content_type}\");\n"
                ));
                out.push_str("        request.Content = parameterizedContent;\n");
            } else {
                out.push_str(&format!("        request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");\n"));
            }
        }

        // Add request headers (skip restricted headers that belong to Content.Headers).
        for (name, value) in ctx.headers {
            if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
                continue;
            }
            let escaped_name = escape_csharp(name);
            let escaped_value = escape_csharp(value);
            out.push_str(&format!(
                "        request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");\n"
            ));
        }

        // Combine cookies into a single `Cookie` header.
        if !ctx.cookies.is_empty() {
            let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
            pairs.sort();
            let cookie_header = escape_csharp(&pairs.join("; "));
            out.push_str(&format!(
                "        request.Headers.Add(\"Cookie\", \"{cookie_header}\");\n"
            ));
        }

        out.push_str("        var response = await client.SendAsync(request);\n");
    }

    /// Emit `Assert.Equal(status, (int)response.StatusCode)`.
    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
        out.push_str(&format!("        Assert.Equal({status}, (int)response.StatusCode);\n"));
    }

    /// Emit a response-header assertion.
    ///
    /// Handles special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
    /// Picks `response.Content.Headers` vs `response.Headers` based on the header name.
    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
        let target = if is_csharp_content_header(name) {
            "response.Content.Headers"
        } else {
            "response.Headers"
        };
        let escaped_name = escape_csharp(name);
        match expected {
            "<<present>>" => {
                out.push_str(&format!("        Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");\n"));
            }
            "<<absent>>" => {
                out.push_str(&format!("        Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");\n"));
            }
            "<<uuid>>" => {
                // UUID regex: 8-4-4-4-12 hex groups.
                out.push_str(&format!("        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var _uuidHdr) && System.Text.RegularExpressions.Regex.IsMatch(string.Join(\", \", _uuidHdr), @\"^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}$\"), \"header {escaped_name} is not a UUID\");\n"));
            }
            literal => {
                // Use a deterministic local-variable name derived from the header name so
                // multiple header assertions in the same method body do not redeclare.
                let var_name = format!("hdr{}", sanitize_ident(name));
                let escaped_value = escape_csharp(literal);
                out.push_str(&format!("        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");\n"));
            }
        }
    }

    /// Emit a JSON body equality assertion via `JsonDocument`.
    ///
    /// Plain-string bodies are compared with `Assert.Equal` after trimming.
    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
        match expected {
            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
                let json_str = serde_json::to_string(expected).unwrap_or_default();
                let escaped = escape_csharp(&json_str);
                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
                out.push_str("        var body = JsonDocument.Parse(bodyText).RootElement;\n");
                out.push_str(&format!(
                    "        var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;\n"
                ));
                out.push_str("        Assert.Equal(expectedBody.GetRawText(), body.GetRawText());\n");
            }
            serde_json::Value::String(s) => {
                let escaped = escape_csharp(s);
                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
                out.push_str(&format!("        Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
            }
            other => {
                let escaped = escape_csharp(&other.to_string());
                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
                out.push_str(&format!("        Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
            }
        }
    }

    /// Emit per-field equality assertions for a partial body match.
    ///
    /// Uses a separate `partialBodyText` local so it does not collide with
    /// `bodyText` if `render_assert_json_body` was also called.
    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
        if let Some(obj) = expected.as_object() {
            out.push_str("        var partialBodyText = await response.Content.ReadAsStringAsync();\n");
            out.push_str("        var partialBody = JsonDocument.Parse(partialBodyText).RootElement;\n");
            for (key, val) in obj {
                let escaped_key = escape_csharp(key);
                let json_str = serde_json::to_string(val).unwrap_or_default();
                let escaped_val = escape_csharp(&json_str);
                let var_name = format!("expected{}", key.to_upper_camel_case());
                out.push_str(&format!(
                    "        var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;\n"
                ));
                out.push_str(&format!("        Assert.True(partialBody.TryGetProperty(\"{escaped_key}\", out var _partialProp{var_name}) && _partialProp{var_name}.GetRawText() == {var_name}.GetRawText(), \"partial body field '{escaped_key}' mismatch\");\n"));
            }
        }
    }

    /// Emit validation-error assertions by checking each expected `msg` string
    /// appears in the JSON-encoded body.
    fn render_assert_validation_errors(
        &self,
        out: &mut String,
        _response_var: &str,
        errors: &[ValidationErrorExpectation],
    ) {
        out.push_str("        var validationBodyText = await response.Content.ReadAsStringAsync();\n");
        for err in errors {
            let escaped_msg = escape_csharp(&err.msg);
            out.push_str(&format!(
                "        Assert.Contains(\"{escaped_msg}\", validationBodyText);\n"
            ));
        }
    }
}

/// Render an HTTP server test method using the shared [`client::http_call::render_http_test`]
/// driver via [`CSharpTestClientRenderer`].
pub(super) fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
    client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
}

/// Extract path parameter names from a URL pattern like `/fixtures/{id}/items/{item_id}`.
/// Returns parameter names such as `["id", "item_id"]`, stripping any type syntax like `:uuid`.
fn extract_path_param_names(path: &str) -> Vec<String> {
    let mut params = Vec::new();
    let mut in_param = false;
    let mut current_param = String::new();

    for ch in path.chars() {
        match ch {
            '{' => {
                in_param = true;
                current_param.clear();
            }
            '}' => {
                if in_param && !current_param.is_empty() {
                    // Strip type syntax: {id:uuid} → just "id"
                    let param_name = current_param.split(':').next().unwrap_or("").to_string();
                    if !param_name.is_empty() {
                        params.push(param_name);
                    }
                }
                in_param = false;
                current_param.clear();
            }
            _ if in_param => {
                current_param.push(ch);
            }
            _ => {}
        }
    }

    params
}

#[cfg(test)]
mod content_type_parameter_tests {
    use super::*;
    use crate::e2e::codegen::client::{CallCtx, TestClientRenderer};
    use std::collections::BTreeMap;

    fn render_body_call(content_type: &str) -> String {
        let headers = BTreeMap::new();
        let query_params = BTreeMap::new();
        let cookies = BTreeMap::new();
        let body = serde_json::json!({"value": "test"});
        let ctx = CallCtx {
            method: "POST",
            path: "/fixtures/sample/data",
            headers: &headers,
            query_params: &query_params,
            cookies: &cookies,
            body: Some(&body),
            content_type: Some(content_type),
            response_var: "response",
        };
        let mut out = String::new();
        CSharpTestClientRenderer.render_call(&mut out, &ctx);
        out
    }

    /// `StringContent(string, Encoding, string)` runs `MediaTypeHeaderValue.CheckMediaTypeFormat`
    /// on its third argument, which rejects any media type carrying parameters. Handing it a
    /// fixture's full `Content-Type` threw `FormatException` before the request was sent, so the
    /// fixture failed on the test's own construction rather than on anything the server did.
    #[test]
    fn a_content_type_with_parameters_is_set_through_the_header_not_the_constructor() {
        let out = render_body_call("application/json; charset=utf-16");

        assert!(
            out.contains("MediaTypeHeaderValue.Parse(\"application/json; charset=utf-16\")"),
            "a parameterized content type must be parsed into the header, got:\n{out}"
        );
        assert!(
            !out.contains("System.Text.Encoding.UTF8, \"application/json; charset=utf-16\""),
            "the parameterized value must not reach StringContent's mediaType argument, got:\n{out}"
        );
    }

    /// Negative control: a bare media type has no parameters to reject, so it keeps the shorter
    /// three-argument form. Without this, "always use MediaTypeHeaderValue" would pass the test
    /// above while churning every other generated request.
    #[test]
    fn a_bare_content_type_still_uses_the_string_content_constructor() {
        let out = render_body_call("application/json");

        assert!(
            out.contains("System.Text.Encoding.UTF8, \"application/json\""),
            "a bare media type must keep the constructor form, got:\n{out}"
        );
        assert!(
            !out.contains("MediaTypeHeaderValue.Parse"),
            "a bare media type needs no header parse, got:\n{out}"
        );
    }
}