From 91389a4bc94728cbeeac11b63c57b246a574b6a7 Mon Sep 17 00:00:00 2001 From: Florian Bernd Date: Wed, 22 Jul 2026 15:35:20 +0200 Subject: [PATCH 1/5] Add C# exporter backed by the .NET WASM converter CSharpExporter wraps ExternalExporter to load the optional @elastic/request-converter-dotnet package, resolving the bundle from a constructor argument, the CSHARP_REQUEST_CONVERTER_BUNDLE env var, or the package name in that order. It defaults to descriptor syntax with a strongly typed document, overridable per call. Also switches the test script to run Jest under Node's --experimental-vm-modules: the WASM bundle is ESM-only, so loading it needs a true dynamic import that survives tsc's downlevel to require() under the commonjs module target, and Jest's VM sandbox rejects dynamic import() without that flag. --- package.json | 4 +- src/convert.ts | 6 +- src/exporters/csharp.ts | 79 +++++++++++++++++++++++++++ tests/convert.test.ts | 41 ++++++++++++++ tests/fixtures/csharp-bundle/main.mjs | 18 ++++++ 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 src/exporters/csharp.ts create mode 100644 tests/fixtures/csharp-bundle/main.mjs diff --git a/package.json b/package.json index 9a30e16..c1541ad 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "docs": "typedoc src/index.ts", "lint": "eslint src tests --ignore-pattern tests/wasm/", "prettier": "prettier \"src/**/*.ts\" \"tests/**/*.ts\" --list-different", - "test": "npm run compile-templates && jest --test-path-ignore-patterns integration --coverage", + "test": "npm run compile-templates && node --experimental-vm-modules node_modules/jest/bin/jest.js --test-path-ignore-patterns integration --coverage", "test:setup": "./tests/integration/run-python.sh && ./tests/integration/run-javascript.sh && ./tests/integration/run-ruby.sh && ./tests/integration/run-php.sh", "test:setup-curl": "echo \"Nothing to do!\"", "test:setup-python": "./tests/integration/run-python.sh", @@ -40,7 +40,7 @@ "fix:lint": "eslint src tests --fix --ignore-pattern tests/wasm/", "fix:prettier": "prettier \"src/**/*.ts\" \"tests/**/*.ts\" --write", "fix:precommit": "./scripts/precommit.sh", - "watch:test": "npm run compile-templates && jest --test-path-ignore-patterns integration --coverage --watch", + "watch:test": "npm run compile-templates && node --experimental-vm-modules node_modules/jest/bin/jest.js --test-path-ignore-patterns integration --coverage --watch", "parser": "peggy --plugin ./node_modules/ts-pegjs/dist/tspegjs -o src/es_parser.ts ./src/es_parser.pegjs", "prepare": "husky install" }, diff --git a/src/convert.ts b/src/convert.ts index 1480277..4103038 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -6,6 +6,7 @@ import { CurlExporter } from "./exporters/curl"; import { JavaScriptExporter } from "./exporters/javascript"; import { PHPExporter } from "./exporters/php"; import { RubyExporter } from "./exporters/ruby"; +import { CSharpExporter } from "./exporters/csharp"; import util from "util"; const isBrowser = typeof window !== "undefined"; @@ -39,14 +40,17 @@ export interface FormatExporter { convert(requests: ParsedRequest[], options: ConvertOptions): Promise; } +const csharpExporter = new CSharpExporter(); const EXPORTERS: Record = { javascript: new JavaScriptExporter(), php: new PHPExporter(), python: new PythonExporter(), ruby: new RubyExporter(), curl: new CurlExporter(), + "c#": csharpExporter, + csharp: csharpExporter, }; -const LANGUAGES = ["JavaScript", "PHP", "Python", "Ruby", "curl"]; +const LANGUAGES = ["JavaScript", "PHP", "Python", "Ruby", "curl", "C#"]; /** * Return the list of available export formats. diff --git a/src/exporters/csharp.ts b/src/exporters/csharp.ts new file mode 100644 index 0000000..6d7003f --- /dev/null +++ b/src/exporters/csharp.ts @@ -0,0 +1,79 @@ +import { existsSync } from "fs"; +import { resolve } from "path"; +import { pathToFileURL } from "url"; +import { + ConvertOptions, + ExternalExporter, + ExternalFormatExporter, + FormatExporter, +} from "../convert"; +import { ParsedRequest } from "../parse"; + +const BUNDLE_PACKAGE = "@elastic/request-converter-dotnet"; + +// This package compiles to CommonJS, where tsc downlevels `import()` to +// `require()`, which cannot load the ES-module WASM bundle. The indirection +// keeps a true dynamic import. +const dynamicImport = new Function("specifier", "return import(specifier)") as ( + specifier: string, +) => Promise<{ boot?: () => Promise }>; + +/** Converts requests to C# code for the .NET Elasticsearch client. + * + * Code generation runs inside the .NET WASM bundle shipped as the optional + * `@elastic/request-converter-dotnet` package, which embeds the + * `Elastic.Clients.Elasticsearch` client matching its own major.minor. + */ +export class CSharpExporter implements FormatExporter { + private exporter?: Promise; + + constructor(private readonly bundlePath?: string) {} + + async check(requests: ParsedRequest[]): Promise { + if (!requests.every((request) => request.service === "es")) { + return false; + } + return (await this.getExporter()).check(requests); + } + + async convert( + requests: ParsedRequest[], + options: ConvertOptions, + ): Promise { + return (await this.getExporter()).convert(requests, { + document_type_name: "MyDocument", + syntax_mode: "descriptor", + use_strongly_typed_document: true, + ...options, + }); + } + + private getExporter(): Promise { + this.exporter ??= this.loadExporter(); + return this.exporter; + } + + private async loadExporter(): Promise { + const specifier = + this.bundlePath ?? + process.env.CSHARP_REQUEST_CONVERTER_BUNDLE ?? + BUNDLE_PACKAGE; + let module; + try { + // File-system specifiers need URL form on Windows (drive letters). + const importSpecifier = existsSync(specifier) + ? pathToFileURL(resolve(specifier)).href + : specifier; + module = await dynamicImport(importSpecifier); + } catch (error) { + throw new Error( + `The C# exporter requires the optional ${BUNDLE_PACKAGE} package ` + + `(npm install ${BUNDLE_PACKAGE}): ${(error as Error).message}`, + ); + } + if (typeof module.boot !== "function") { + throw new Error(`${specifier} does not export a boot() function.`); + } + return new ExternalExporter(await module.boot()); + } +} diff --git a/tests/convert.test.ts b/tests/convert.test.ts index 8207f9a..f36efab 100644 --- a/tests/convert.test.ts +++ b/tests/convert.test.ts @@ -712,6 +712,47 @@ response1 = client.search( ).toEqual("search,info"); }); + describe("csharp exporter", () => { + const fixture = "tests/fixtures/csharp-bundle/main.mjs"; + + it("applies typed-document descriptor defaults", async () => { + const { CSharpExporter } = await import("../src/exporters/csharp"); + const code = await convertRequests( + "GET /my-index/_search\n{}", + new CSharpExporter(fixture), + {}, + ); + expect(code).toContain('"syntax_mode":"descriptor"'); + expect(code).toContain('"use_strongly_typed_document":true'); + expect(code).toContain('"document_type_name":"MyDocument"'); + }); + + it("lets callers override the defaults", async () => { + const { CSharpExporter } = await import("../src/exporters/csharp"); + const code = await convertRequests( + "GET /my-index/_search\n{}", + new CSharpExporter(fixture), + { syntax_mode: "object_initializer" }, + ); + expect(code).toContain('"syntax_mode":"object_initializer"'); + }); + + it("is listed as a format", () => { + expect(listFormats()).toContain("C#"); + }); + + it("fails with a helpful error when the bundle is missing", async () => { + const { CSharpExporter } = await import("../src/exporters/csharp"); + await expect( + convertRequests( + "GET /_search\n{}", + new CSharpExporter("./does-not-exist.mjs"), + {}, + ), + ).rejects.toThrow(/request-converter-dotnet/); + }); + }); + describe("web external exporter tests", () => { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const baseUrl = "http://127.0.0.1:5000"; diff --git a/tests/fixtures/csharp-bundle/main.mjs b/tests/fixtures/csharp-bundle/main.mjs new file mode 100644 index 0000000..5ca8d84 --- /dev/null +++ b/tests/fixtures/csharp-bundle/main.mjs @@ -0,0 +1,18 @@ +// Minimal stand-in for @elastic/request-converter-dotnet: echoes the options +// it received so tests can assert the exporter's defaults and overrides. +export async function boot() { + return { + check(input) { + const { requests } = JSON.parse(input); + return JSON.stringify({ return: requests.every((r) => Boolean(r.api)) }); + }, + convert(input) { + const { options, requests } = JSON.parse(input); + return JSON.stringify({ + return: `// apis: ${requests + .map((r) => r.api) + .join(",")}\n// options: ${JSON.stringify(options)}`, + }); + }, + }; +} From 8a76502b755a73cfe3fbfd24a8720fe0be814800 Mon Sep 17 00:00:00 2001 From: Florian Bernd Date: Wed, 22 Jul 2026 15:52:16 +0200 Subject: [PATCH 2/5] Keep JavaScript as the demo default language --- demo/src/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 883a266..52fc03c 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -21,7 +21,8 @@ function App() { const [source, setSource] = useState('GET /_search'); const [error, setError] = useState(null); const [code, setCode] = useState('Loading...'); - const [language, setLanguage] = useState(formats[0]); + // Pinned rather than derived from formats[0]: the browser demo can't load the optional .NET bundle, so C# must not become the initial selection. + const [language, setLanguage] = useState('JavaScript'); const [showClipboardMsg, setShowClipboardMsg] = useState(false); useEffect(() => { From 3bfaa2c31631a7ab7e21f2d7e087373b9b4187a2 Mon Sep 17 00:00:00 2001 From: Florian Bernd Date: Wed, 22 Jul 2026 16:28:26 +0200 Subject: [PATCH 3/5] Reject non-Elasticsearch requests in the C# exporter --- src/exporters/csharp.ts | 3 +++ tests/convert.test.ts | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/src/exporters/csharp.ts b/src/exporters/csharp.ts index 6d7003f..bc48a3d 100644 --- a/src/exporters/csharp.ts +++ b/src/exporters/csharp.ts @@ -40,6 +40,9 @@ export class CSharpExporter implements FormatExporter { requests: ParsedRequest[], options: ConvertOptions, ): Promise { + if (!requests.every((request) => request.service === "es")) { + throw new Error("Cannot perform conversion"); + } return (await this.getExporter()).convert(requests, { document_type_name: "MyDocument", syntax_mode: "descriptor", diff --git a/tests/convert.test.ts b/tests/convert.test.ts index f36efab..852ac0b 100644 --- a/tests/convert.test.ts +++ b/tests/convert.test.ts @@ -751,6 +751,13 @@ response1 = client.search( ), ).rejects.toThrow(/request-converter-dotnet/); }); + + it("errors when converting Kibana to C#", async () => { + const { CSharpExporter } = await import("../src/exporters/csharp"); + await expect( + convertRequests(kibanaScript, new CSharpExporter(fixture), {}), + ).rejects.toThrowError("Cannot perform conversion"); + }); }); describe("web external exporter tests", () => { From ee0a3c21789c90f5ad9aec8f59244f79357fa44f Mon Sep 17 00:00:00 2001 From: Florian Bernd Date: Wed, 22 Jul 2026 16:54:29 +0200 Subject: [PATCH 4/5] Add optional dotnet bundle peer dependency and real-bundle test --- README.md | 19 ++++++++++++++++++- package.json | 8 ++++++++ tests/convert.test.ts | 15 +++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c0acb90..a202838 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ const { convertRequests, listFormats } = require("@elastic/request-converter"); ## Available Formats -At this time the converter supports `curl`, `python`, `javascript`, `php` and `ruby`. Work is currently in +At this time the converter supports `curl`, `python`, `javascript`, `php`, `ruby` and `csharp`. Work is currently in progress to add support for more languages. ### curl @@ -143,6 +143,23 @@ Supported options: | `complete` | `boolean` | no | If `true`, generate a complete script. If `false`, only generate the request code. The default is `false`. | | `elasticsearchUrl` | `string` | no | The Elasticsearch endpoint to use. The default is `http://localhost:9200`. | +### csharp + +The C# exporter generates code for the Elasticsearch .NET client. Unlike the +other exporters, code generation runs inside a .NET WASM bundle shipped +separately as the `@elastic/request-converter-dotnet` package, since the .NET +client's code model isn't available in JavaScript. Install it alongside this +package, choosing the major.minor version that matches your target +Elasticsearch version: + +```bash +npm install @elastic/request-converter-dotnet +``` + +To use a locally built bundle instead (for example while developing the +bundle itself), set the `CSHARP_REQUEST_CONVERTER_BUNDLE` environment +variable to the path of its entry point before running the converter. + ## Command-Line Interface For convenience, a CLI that wraps the `convertRequests` function is also available. diff --git a/package.json b/package.json index c1541ad..800a454 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,14 @@ "handlebars": "^4.7.8", "prettier": "^2.8.8" }, + "peerDependencies": { + "@elastic/request-converter-dotnet": ">=8.19.0" + }, + "peerDependenciesMeta": { + "@elastic/request-converter-dotnet": { + "optional": true + } + }, "directories": { "test": "tests" }, diff --git a/tests/convert.test.ts b/tests/convert.test.ts index 852ac0b..11e6d47 100644 --- a/tests/convert.test.ts +++ b/tests/convert.test.ts @@ -758,6 +758,21 @@ response1 = client.search( convertRequests(kibanaScript, new CSharpExporter(fixture), {}), ).rejects.toThrowError("Cannot perform conversion"); }); + + const realBundle = process.env.CSHARP_REQUEST_CONVERTER_BUNDLE; + (realBundle ? it : it.skip)( + "converts a search request with the real bundle", + async () => { + const code = await convertRequests( + 'GET /my-index-000001/_search?from=40&size=20\n{"query":{"term":{"user.id":{"value":"kimchy"}}}}', + "csharp", + {}, + ); + expect(code).toContain("new SearchRequestDescriptor()"); + expect(code).toContain(".Field(x => x.User.Id)"); + }, + 30_000, + ); }); describe("web external exporter tests", () => { From ac765273c6e1235c9c38fa3f564451260e7dbbfc Mon Sep 17 00:00:00 2001 From: Florian Bernd Date: Thu, 23 Jul 2026 08:54:59 +0200 Subject: [PATCH 5/5] Defer dynamic-import shim creation until the C# bundle loads --- README.md | 10 ++++++++++ src/exporters/csharp.ts | 22 +++++++++++++++++----- tests/convert.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a202838..eef4f13 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,16 @@ To use a locally built bundle instead (for example while developing the bundle itself), set the `CSHARP_REQUEST_CONVERTER_BUNDLE` environment variable to the path of its entry point before running the converter. +Supported options (snake_case, matching the .NET bundle's wire contract): + +| Option name | Type | Required | Description | +| ----------- | ---- | -------- | ----------- | +| `syntax_mode` | `string` | no | `"descriptor"` for fluent descriptor chains, or `"object_initializer"` for object initializers. The default is `"descriptor"`. | +| `use_strongly_typed_document` | `boolean` | no | If `true`, field accessors use lambdas on an illustrative document type. The default is `true`. | +| `document_type_name` | `string` | no | The document type name used in generated code. The default is `"MyDocument"`. | +| `type_name_style` | `string` | no | `"Simplified"`, `"Fqn"`, or `"GlobalFqn"` type-name rendering. The default is `"Simplified"`. | +| `debug` | `boolean` | no | If `true`, append converter diagnostics to error messages. The default is `false`. | + ## Command-Line Interface For convenience, a CLI that wraps the `convertRequests` function is also available. diff --git a/src/exporters/csharp.ts b/src/exporters/csharp.ts index bc48a3d..59fe8a8 100644 --- a/src/exporters/csharp.ts +++ b/src/exporters/csharp.ts @@ -11,13 +11,25 @@ import { ParsedRequest } from "../parse"; const BUNDLE_PACKAGE = "@elastic/request-converter-dotnet"; -// This package compiles to CommonJS, where tsc downlevels `import()` to -// `require()`, which cannot load the ES-module WASM bundle. The indirection -// keeps a true dynamic import. -const dynamicImport = new Function("specifier", "return import(specifier)") as ( +type DynamicImport = ( specifier: string, ) => Promise<{ boot?: () => Promise }>; +let dynamicImport: DynamicImport | undefined; + +// This package compiles to CommonJS, where tsc downlevels `import()` to +// `require()`, which cannot load the ES-module WASM bundle. The indirection +// keeps a true dynamic import. Built lazily, on first use, so that merely +// importing this module does not run an eval-family construct under a CSP +// that forbids it (e.g. a host embedding the converter for other languages). +function getDynamicImport(): DynamicImport { + dynamicImport ??= new Function( + "specifier", + "return import(specifier)", + ) as DynamicImport; + return dynamicImport; +} + /** Converts requests to C# code for the .NET Elasticsearch client. * * Code generation runs inside the .NET WASM bundle shipped as the optional @@ -67,7 +79,7 @@ export class CSharpExporter implements FormatExporter { const importSpecifier = existsSync(specifier) ? pathToFileURL(resolve(specifier)).href : specifier; - module = await dynamicImport(importSpecifier); + module = await getDynamicImport()(importSpecifier); } catch (error) { throw new Error( `The C# exporter requires the optional ${BUNDLE_PACKAGE} package ` + diff --git a/tests/convert.test.ts b/tests/convert.test.ts index 11e6d47..5ee59d3 100644 --- a/tests/convert.test.ts +++ b/tests/convert.test.ts @@ -741,6 +741,44 @@ response1 = client.search( expect(listFormats()).toContain("C#"); }); + it("does not build the dynamic-import shim until a conversion loads the bundle", async () => { + const originalFunction = global.Function; + // Fails only the exact construction the shim performs, so unrelated + // Function/eval usage elsewhere in the module graph is unaffected. + global.Function = new Proxy(originalFunction, { + construct(target, args) { + if ( + args[0] === "specifier" && + args[1] === "return import(specifier)" + ) { + throw new Error("new Function must not run at import time"); + } + return Reflect.construct(target, args); + }, + }); + try { + // isolateModulesAsync gives this import its own registry, so loading + // src/convert.ts (which eagerly imports csharp.ts) here re-runs + // module-scope code under the patched Function above. + await jest.isolateModulesAsync(async () => { + const freshConvert: typeof import("../src/convert") = await import( + "../src/convert" + ); + const { CSharpExporter }: typeof import("../src/exporters/csharp") = + await import("../src/exporters/csharp"); + global.Function = originalFunction; + const code = await freshConvert.convertRequests( + "GET /my-index/_search\n{}", + new CSharpExporter(fixture), + {}, + ); + expect(code).toContain('"syntax_mode":"descriptor"'); + }); + } finally { + global.Function = originalFunction; + } + }); + it("fails with a helpful error when the bundle is missing", async () => { const { CSharpExporter } = await import("../src/exporters/csharp"); await expect(