Skip to content
Merged
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
128 changes: 128 additions & 0 deletions .claude/commands/scaffold-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Scaffold a new benchmark implementation

Guide the user interactively through creating a new implementation for a benchmark project.

## Step 1 — Discover available projects

List directories under `projects/` that contain a `_shared/` subdirectory. Present them to the user and ask which project they want to add an implementation for (e.g., `content-api`).

## Step 2 — Pick API style

List the API style subdirectories available under `projects/<project>/_shared/` (e.g., `openapi`, `graphql`, `protobuf`). Ask the user to choose which API style this implementation will use.

## Step 3 — Name the implementation

Ask the user for the implementation name in kebab-case (e.g., `spring-boot`, `express`, `ktor`, `actix-web`). This becomes the directory name under the project.

## Step 4 — Specify language and framework

Ask the user for:
- The programming language (e.g., `java`, `kotlin`, `javascript`, `typescript`, `go`, `rust`)
- The framework name (e.g., `spring-boot`, `express`, `ktor`, `actix-web`, `gin`)

These are used in benchmark tags and guide code generation.

## Step 5 — Generate the implementation

Read the API spec and k6 script from `projects/<project>/_shared/<api-style>/` to understand the contract.

Generate the following files under `projects/<project>/<implementation>/`:

### Dockerfile
- Use an appropriate base image for the language/framework
- Multi-stage build where applicable (build stage + runtime stage)
- The final image should be as small as practical
- Expose port 8080

Comment on lines +31 to +36

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

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

The Dockerfile guidance says to "Expose port 8080", but later the protobuf style section states the gRPC server should listen on port 50051 in addition to the HTTP health check on 8080. To keep the scaffold instructions consistent, update the Dockerfile/compose guidance to include exposing/mapping port 50051 for protobuf implementations (or clarify that EXPOSE is optional but the service must listen on both ports).

Copilot uses AI. Check for mistakes.
### docker-compose.yml
- Primary service named `api` on port 8080
- Add a database service if the framework conventionally uses one
- Include healthcheck in the compose file
- Example structure:
```yaml
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- SERVER_PORT=8080
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
```

### Application source code
- Implement ALL endpoints defined in the API spec
- For openapi style:
- `GET /health` returning `{"status": "up"}`
- `GET /api/v1/content` with pagination (limit/offset query params)
- `POST /api/v1/content` creating a new content item (return 201)
- `GET /api/v1/content/{id}` returning a single item (404 if not found)
- `PUT /api/v1/content/{id}` updating an item (404 if not found)
- `DELETE /api/v1/content/{id}` deleting an item (return 204, 404 if not found)
- For graphql style:
- Implement all queries and mutations defined in the schema
- Serve at `/graphql` endpoint
- Also serve `GET /health` returning `{"status": "up"}` for the readiness probe
- For protobuf style:
- Implement all RPCs defined in the proto service
- Serve on port 50051 for gRPC
- Also serve `GET /health` on port 8080 for the readiness probe
- Use in-memory storage (a simple map/list) unless the user specifically requests a database
- Generate UUIDs for content IDs
- Track createdAt and updatedAt timestamps
- Return proper HTTP status codes / gRPC status codes as specified
- Follow idiomatic conventions for the chosen language and framework

### Build files
- Language-appropriate build file (pom.xml, package.json, build.gradle.kts, go.mod, Cargo.toml, etc.)
- Include only the dependencies needed for a minimal implementation

## Step 6 — Register the target

Add a target entry to `benchmark.config.json`:

```json
"<project>/<implementation>": {
"path": "./projects/<project>/<implementation>",
"composeFile": "docker-compose.yml",
"service": "api",
"port": 8080,
"protocol": "<http for openapi/graphql, grpc for protobuf>",
"readinessProbe": {
"httpGet": {
"path": "/health",
"port": 8080,
"expectedStatus": 200
}
},
"k6": {
"script": "./projects/<project>/_shared/<api-style>/k6/<project>.js",
"vus": 50,
"duration": "30s",
"env": {
"BASE_URL": "http://localhost:8080"
}
},
"tags": {
"project": "<project>",
"language": "<language>",
"framework": "<framework>",
"api-style": "<api-style>"
}
}
```

For protobuf targets, adjust:
- `protocol`: `"grpc"`
- `k6.env`: use `GRPC_HOST` and `PROTO_PATH` instead of `BASE_URL`

## Step 7 — Verify

Run `npm run benchmark -- list` to confirm the new target appears in the output.
29 changes: 1 addition & 28 deletions benchmark.config.json
Original file line number Diff line number Diff line change
@@ -1,32 +1,5 @@
{
"targets": {
"example-api": {
"path": "./examples/example-api",
"composeFile": "docker-compose.yml",
"service": "api",
"port": 8080,
"protocol": "http",
"readinessProbe": {
"httpGet": {
"path": "/health",
"port": 8080,
"expectedStatus": 200
}
},
"k6": {
"script": "toolkit/k6/scripts/default-http.js",
"vus": 50,
"duration": "30s",
"env": {
"BASE_URL": "http://localhost:8080"
}
},
"tags": {
"language": "example",
"framework": "example"
}
}
},
"targets": {},
"defaults": {
"k6": {
"vus": 50,
Expand Down
139 changes: 139 additions & 0 deletions projects/content-api/_shared/graphql/k6/content-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { randomString } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';

const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
const GRAPHQL_URL = `${BASE_URL}/graphql`;

export const options = {
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};

const headers = { 'Content-Type': 'application/json' };

function graphql(query, variables = {}) {
return http.post(GRAPHQL_URL, JSON.stringify({ query, variables }), { headers });
}

export default function () {
let contentId;

group('create content', () => {
const res = graphql(
`mutation CreateContent($input: CreateContentInput!) {
createContent(input: $input) {
id title body status createdAt updatedAt
}
}`,
{
input: {
title: `Benchmark ${randomString(8)}`,
body: `Load test content body ${randomString(32)}`,
status: 'DRAFT',
},
}
);

check(res, {
'create: status is 200': (r) => r.status === 200,
'create: has id': (r) => {
const body = r.json();
contentId = body.data && body.data.createContent && body.data.createContent.id;
return !!contentId;
},
'create: no errors': (r) => !r.json().errors,
});
});

group('get content', () => {
if (!contentId) return;

const res = graphql(
`query GetContent($id: ID!) {
content(id: $id) {
id title body status createdAt updatedAt
}
}`,
{ id: contentId }
);

check(res, {
'get: status is 200': (r) => r.status === 200,
'get: correct id': (r) => {
const body = r.json();
return body.data && body.data.content && body.data.content.id === contentId;
},
});
});

group('list content', () => {
const res = graphql(
`query ListContent($limit: Int, $offset: Int) {
contents(limit: $limit, offset: $offset) {
items { id title status }
total limit offset
}
}`,
{ limit: 10, offset: 0 }
);

check(res, {
'list: status is 200': (r) => r.status === 200,
'list: has items': (r) => {
const body = r.json();
return body.data && Array.isArray(body.data.contents.items);
},
});
});

group('update content', () => {
if (!contentId) return;

const res = graphql(
`mutation UpdateContent($id: ID!, $input: UpdateContentInput!) {
updateContent(id: $id, input: $input) {
id title status updatedAt
}
}`,
{
id: contentId,
input: {
title: `Updated ${randomString(8)}`,
status: 'PUBLISHED',
},
}
);

check(res, {
'update: status is 200': (r) => r.status === 200,
'update: status changed': (r) => {
const body = r.json();
return body.data && body.data.updateContent && body.data.updateContent.status === 'PUBLISHED';
},
});
});

group('delete content', () => {
if (!contentId) return;

const res = graphql(
`mutation DeleteContent($id: ID!) {
deleteContent(id: $id)
}`,
{ id: contentId }
);

check(res, {
'delete: status is 200': (r) => r.status === 200,
'delete: success': (r) => {
const body = r.json();
return body.data && body.data.deleteContent === true;
},
});
});

sleep(0.1);
}
49 changes: 49 additions & 0 deletions projects/content-api/_shared/graphql/schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
type Query {
health: HealthResponse!
content(id: ID!): Content
contents(limit: Int = 20, offset: Int = 0): ContentList!
}

type Mutation {
createContent(input: CreateContentInput!): Content!
updateContent(id: ID!, input: UpdateContentInput!): Content
deleteContent(id: ID!): Boolean!
}

type HealthResponse {
status: String!
}

type Content {
id: ID!
title: String!
body: String!
status: ContentStatus!
createdAt: String!
updatedAt: String!
}

type ContentList {
items: [Content!]!
total: Int!
limit: Int!
offset: Int!
}

input CreateContentInput {
title: String!
body: String!
status: ContentStatus = DRAFT
}

input UpdateContentInput {
title: String
body: String
status: ContentStatus
}

enum ContentStatus {
DRAFT
PUBLISHED
ARCHIVED
}
Loading