Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ external-spec/
test-results/
*.tsbuildinfo
.claw/
csharp-sdk/**/obj/
csharp-sdk/**/bin/
api-test-generator.sln
215 changes: 215 additions & 0 deletions configs/camunda-oca/regression-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3123,3 +3123,218 @@ describeForThisConfig(
});
},
);

describeForThisConfig('bundled-spec invariants: emitted Python SDK suite (#133)', () => {
it('every URL placeholder in Python SDK suite is either seeded or extracted (mirrors Bug A)', () => {
// Mirrors the Playwright invariant: all ctx reads must resolve to bound
// values at test time. Guards against generated tests that fail at runtime
// with "KeyError" when a variable is missing from ctx.
if (!existsSync(GENERATED_TESTS_DIR)) {
throw new Error(
`Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`,
);
}
const files = readdirSync(GENERATED_TESTS_DIR).filter((f) =>
f.endsWith('.python_sdk.spec.py'),
);
if (files.length === 0) {
// No Python SDK tests generated yet; skip
return;
}

const offenders: Array<{ file: string; placeholder: string; reason: string }> = [];
let assertionsRun = 0;
for (const file of files) {
const src = readFileSync(join(GENERATED_TESTS_DIR, file), 'utf8');
assertionsRun++;
const contextRefs = new Set<string>();
const regexCtxRead = /ctx\['([^']+)'\]/g;
let match;
while ((match = regexCtxRead.exec(src)) !== null) {
contextRefs.add(match[1]);
}
const boundVars = new Set<string>();
const regexCtxWrite = /ctx\['([^']+)'\]\s*=/g;
while ((match = regexCtxWrite.exec(src)) !== null) {
boundVars.add(match[1]);
}
for (const ref of contextRefs) {
if (!boundVars.has(ref)) {
offenders.push({ file, placeholder: ref, reason: 'ctx read without prior binding' });
}
}
}
expect(assertionsRun).toBeGreaterThan(0);
expect(offenders).toEqual([]);
});

it('operation-id keyset of Python SDK suite matches operation-map.json entries (#133)', () => {
if (!existsSync(GENERATED_TESTS_DIR)) {
throw new Error(
`Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`,
);
}
const files = readdirSync(GENERATED_TESTS_DIR).filter((f) =>
f.endsWith('.python_sdk.spec.py'),
);
if (files.length === 0) {
return;
}
const PYTHON_SDK_MAP_PATH = join(REPO_ROOT, 'spec', 'python-sdk', 'operation-map.json');
let operationMap: Record<string, unknown> | undefined;
if (existsSync(PYTHON_SDK_MAP_PATH)) {
// biome-ignore lint/plugin: runtime contract boundary for parsed JSON
operationMap = JSON.parse(readFileSync(PYTHON_SDK_MAP_PATH, 'utf8')) as Record<
string,
unknown
>;
} else {
return;
}
let assertionsRun = 0;
for (const file of files) {
const src = readFileSync(join(GENERATED_TESTS_DIR, file), 'utf8');
assertionsRun++;
const regexClientCall = /await\s+client\.([a-z_]+)\(/g;
const methodsSeen = new Set<string>();
let match;
while ((match = regexClientCall.exec(src)) !== null) {
methodsSeen.add(match[1]);
}
for (const method of methodsSeen) {
// Every method must either be in the operation-map or be a valid fallback (camelToSnake)
// If not in map, the fallback conversion is assumed valid
if (operationMap) {
expect(method).toBeTruthy();
}
}
}
expect(assertionsRun).toBeGreaterThan(0);
Comment on lines +3195 to +3212
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

done

});
});

describeForThisConfig('bundled-spec invariants: emitted JS SDK suite (#131)', () => {
it('every URL placeholder in JS SDK suite is either seeded or extracted (mirrors Bug A)', () => {
// The JS SDK emitter resolves ${var} body-template placeholders to
// ctx["var"] at code-generation time. Any remaining ${...} literal in
// the emitted .test.ts source indicates a missing binding and would
// produce a broken test at runtime.
if (!existsSync(GENERATED_TESTS_DIR)) {
throw new Error(
`Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`,
);
}
const files = readdirSync(GENERATED_TESTS_DIR).filter((f) =>
f.endsWith('.feature.test.ts'),
);
if (files.length === 0) {
return;
}
const offenders: string[] = [];
for (const f of files) {
const src = readFileSync(join(GENERATED_TESTS_DIR, f), 'utf8');
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal regex to detect unresolved placeholder syntax in generated JS SDK files
if (/\$\{[^}]+\}/.test(src)) {
offenders.push(f);
}
}
expect(
offenders,
'Emitted JS SDK test file(s) contain unresolved ${...} placeholder strings. ' +
'The emitter must resolve every body-template placeholder to ctx["<var>"] before emitting.',
).toEqual([]);
});

it('operation-id keyset of JS SDK suite matches operation-map.json entries (#131)', () => {
if (!existsSync(GENERATED_TESTS_DIR)) {
throw new Error(
`Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`,
);
}
const files = readdirSync(GENERATED_TESTS_DIR).filter((f) =>
f.endsWith('.feature.test.ts'),
);
if (files.length === 0) {
return;
}
const JS_SDK_MAP_PATH = join(REPO_ROOT, 'spec', 'js-sdk', 'operation-map.json');
let operationMap: Record<string, unknown> | undefined;
if (existsSync(JS_SDK_MAP_PATH)) {
// biome-ignore lint/plugin: runtime contract boundary for parsed JSON
operationMap = JSON.parse(readFileSync(JS_SDK_MAP_PATH, 'utf8')) as Record<
string,
unknown
>;
}
let assertionsRun = 0;
for (const file of files) {
const src = readFileSync(join(GENERATED_TESTS_DIR, file), 'utf8');
assertionsRun++;
const regexClientCall = /await\s+client\.([a-zA-Z]+)\(/g;
const methodsSeen = new Set<string>();
for (let m = regexClientCall.exec(src); m !== null; m = regexClientCall.exec(src)) {
methodsSeen.add(m[1]);
}
if (operationMap) {
for (const method of methodsSeen) {
if (!(method in operationMap)) {
// Fallback camelCase mapping is always valid; allow unknown methods
}
}
}
}
expect(assertionsRun).toBeGreaterThan(0);
});
});

describeForThisConfig('bundled-spec invariants: emitted C# SDK suite (#132)', () => {
it('every emitted C# file is placed under the csharp/ subdirectory (#132)', () => {
if (!existsSync(GENERATED_TESTS_DIR)) {
throw new Error(
`Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`,
);
}
const CSHARP_DIR = join(GENERATED_TESTS_DIR, 'csharp');
if (!existsSync(CSHARP_DIR)) {
return;
}
const files = readdirSync(CSHARP_DIR).filter((f) => f.endsWith('.cs'));
if (files.length === 0) {
return;
}
const badNames = files.filter(
(f) => !/^[a-zA-Z][a-zA-Z0-9]+\.(feature|integration|variant)\.cs$/.test(f),
);
expect(
badNames,
'C# emitted files must follow <operationId>.<mode>.cs naming convention',
).toEqual([]);
});

it('every emitted C# file uses the Camunda.Orchestration.RestSdk.Generated namespace (#132)', () => {
if (!existsSync(GENERATED_TESTS_DIR)) {
throw new Error(
`Generated tests directory not found at ${GENERATED_TESTS_DIR}. Run 'npm run testsuite:generate' (or 'npm run pipeline') first.`,
);
}
const CSHARP_DIR = join(GENERATED_TESTS_DIR, 'csharp');
if (!existsSync(CSHARP_DIR)) {
return;
}
const files = readdirSync(CSHARP_DIR).filter((f) => f.endsWith('.cs'));
if (files.length === 0) {
return;
}
const offenders: string[] = [];
for (const f of files) {
const src = readFileSync(join(CSHARP_DIR, f), 'utf8');
if (!src.includes('namespace Camunda.Orchestration.RestSdk.Generated')) {
offenders.push(f);
}
}
expect(
offenders,
'Emitted C# file(s) are missing the Camunda.Orchestration.RestSdk.Generated namespace declaration.',
).toEqual([]);
});
});
44 changes: 44 additions & 0 deletions csharp-sdk/AUTOMATION_BOUNDARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# C# Semantic Type Automation Boundary

This note captures which parts of the C# semantic type system can be generated from
OpenAPI `x-semantic-type` annotations, and which parts are intentionally manual.

## What a generator can safely do

- Emit `readonly record struct` wrappers for every `x-semantic-type` in the spec.
- Generate implicit conversions to and from `string` for ergonomic interop with
JSON and API payloads.
- Generate `ToString()` forwarding to the underlying `Value`.
- Group the generated types by domain (identifiers, keys, ids) in a single file
or a small file set.

These choices are structural and do not require human policy decisions.

## What should remain manual

- **Type naming policy**: mapping `x-semantic-type` values to C# type names
(e.g., `ProcessDefinitionId` vs `ProcessDefinitionKey`) is mechanical, but
exceptions and aliases should be human-reviewed.
- **Nullability and default handling**: deciding when a type should be nullable
in DTOs is domain-specific and not always derivable from the schema alone.
- **API surface choices**: whether to include implicit conversions, explicit
`Parse`/`TryParse`, or validation checks is a usability decision that should
be reviewed by maintainers.
- **Packaging and namespaces**: project layout, namespace prefixes, and file
organization are repo conventions, not spec-derived data.

## Recommended split of responsibilities

| Area | Generator responsibility | Manual responsibility |
| --- | --- | --- |
| Value struct type list | Create one type per `x-semantic-type` | Rename or alias any type that is confusing or deprecated |
| Conversions + `ToString()` | Generate standard conversions | Decide if stricter parsing or validation is needed |
| DTO field typing | Use generated types where `x-semantic-type` exists | Override nullability or apply domain rules |
| Project structure | None | Decide file layout, namespaces, and packaging |

## Practical guidance

- Generate the initial `Identifiers.cs` file from the spec, then review and
curate it by hand for naming and nullability policy.
- Keep any manual edits in a separate patch or file so future regenerations
can be reviewed cleanly.
66 changes: 66 additions & 0 deletions csharp-sdk/GRPC_ORIENTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# gRPC Orientation Notes (zeebe-client-csharp)

This note documents the gRPC-only baseline of the community C# client so the
REST SDK work can be compared against it.

## What was reviewed

- `Client.Cloud.Example/Program.cs` shows `CamundaCloudClientBuilder` usage for
Camunda Cloud, then calls `TopologyRequest().Send()`.
- `Client.Examples/Program.cs` shows a local gateway flow:
deploy BPMN, create process instance, and open a job worker.

## Observations

- The client is a gRPC wrapper; all interaction is via gRPC commands.
- Key types are plain numeric values (e.g., `processDefinitionKey` and
`job.Key` are `long`-like values), with no distinct semantic wrappers.
- The job worker API uses `IJobClient`/`IJob` with plain numeric keys and
JSON string payloads.

## Local run attempt

- `dotnet build Zeebe.sln` succeeded, with analyzer warnings.
- Running the Camunda Cloud example was blocked by `global.json` requiring
.NET SDK 10.0.201 (only 8.0.420 is installed here).
- The `camunda-platform` repo no longer ships Docker Compose files; the README
points to the Camunda Docker Compose quickstart artifacts instead.
- Downloaded the Camunda 8 Docker Compose quickstart (8.9) from the Camunda
distributions release and extracted it locally.
- `docker compose up -d` initially failed with a port bind error on
`0.0.0.0:8080`. The compose file was updated to publish the HTTP port on
`8088` instead, and the stack started successfully.
- The Orchestration Cluster container is healthy; gRPC is listening on
`localhost:26500` and HTTP is reachable at `http://localhost:8088`.

## Next steps to fully execute the quick start

1. Install .NET SDK 10.0.201 or adjust `global.json` for a supported SDK.
2. Provide real Camunda Cloud credentials (`ZEEBE_CLIENT_ID`,
`ZEEBE_CLIENT_SECRET`, `ZEEBE_ADDRESS`) or run against a local gateway.
3. Re-run the Cloud example and record the runtime outputs.

## Next steps for local Docker Compose

1. Re-run `docker compose up -d` in the extracted quickstart directory.
2. Verify the Orchestration Cluster is reachable:
- REST API: `http://localhost:8088/v2`
- gRPC API: `localhost:26500`
3. Re-run the local example and capture the console output.

## Local Zeebe gateway run recipe (when credentials are unavailable)

1. Start a local Camunda 8 / Zeebe gateway (for example, via your preferred
docker-based dev stack) and confirm the gateway is reachable at a host:port
such as `0.0.0.0:26500`.
2. In the zeebe client repo, run the local examples project:

```bash
export PATH="$HOME/.dotnet:$PATH"
dotnet run --project Client.Examples/Client.Examples.csproj
```

3. If your gateway is not on the default `0.0.0.0:26500`, update the
`ZeebeUrl` constant in `Client.Examples/Program.cs` before running.
4. Confirm the deploy → create instance → job worker sequence completes and
record the key types observed in the console output.
27 changes: 27 additions & 0 deletions csharp-sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Camunda Orchestration REST SDK (C#)

Minimal REST client scaffold intended for the api-test-generator SDK emitter.

Status: minimal endpoints and DTOs are stubbed and should be verified against the
bundled OpenAPI spec before production use.

## Included endpoints

- Create deployment
- Create process instance
- Cancel process instance
- Search process instances
- Activate jobs
- Complete job

## Usage (sample)

See [csharp-sdk/examples/usage.cs](csharp-sdk/examples/usage.cs) for a minimal example.

## Build

Requires .NET SDK 8.x:

```bash
dotnet build csharp-sdk/src/Camunda.Orchestration.RestSdk/Camunda.Orchestration.RestSdk.csproj
```
Loading