Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,6 +143,33 @@ 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.

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.
Expand Down
3 changes: 2 additions & 1 deletion demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ function App() {
const [source, setSource] = useState<string>('GET /_search');
const [error, setError] = useState<string | null>(null);
const [code, setCode] = useState<string>('Loading...');
const [language, setLanguage] = useState<string>(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<string>('JavaScript');
const [showClipboardMsg, setShowClipboardMsg] = useState(false);

useEffect(() => {
Expand Down
12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down Expand Up @@ -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"
},
Expand Down
6 changes: 5 additions & 1 deletion src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -39,14 +40,17 @@ export interface FormatExporter {
convert(requests: ParsedRequest[], options: ConvertOptions): Promise<string>;
}

const csharpExporter = new CSharpExporter();
const EXPORTERS: Record<string, FormatExporter> = {
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.
Expand Down
94 changes: 94 additions & 0 deletions src/exporters/csharp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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";

type DynamicImport = (
specifier: string,
) => Promise<{ boot?: () => Promise<ExternalFormatExporter> }>;

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;
}
Comment on lines +25 to +31

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever. 😮


/** 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<ExternalExporter>;

constructor(private readonly bundlePath?: string) {}

async check(requests: ParsedRequest[]): Promise<boolean> {
if (!requests.every((request) => request.service === "es")) {
return false;
}
return (await this.getExporter()).check(requests);
}

async convert(
requests: ParsedRequest[],
options: ConvertOptions,
): Promise<string> {
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",
use_strongly_typed_document: true,
...options,
});
}

private getExporter(): Promise<ExternalExporter> {
this.exporter ??= this.loadExporter();
return this.exporter;
}

private async loadExporter(): Promise<ExternalExporter> {
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 getDynamicImport()(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());
}
}
101 changes: 101 additions & 0 deletions tests/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,107 @@ 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("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(
convertRequests(
"GET /_search\n{}",
new CSharpExporter("./does-not-exist.mjs"),
{},
),
).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");
});

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<MyDocument>()");
expect(code).toContain(".Field(x => x.User.Id)");
},
30_000,
);
});

describe("web external exporter tests", () => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const baseUrl = "http://127.0.0.1:5000";
Expand Down
18 changes: 18 additions & 0 deletions tests/fixtures/csharp-bundle/main.mjs
Original file line number Diff line number Diff line change
@@ -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)}`,
});
},
};
}
Loading