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
25 changes: 1 addition & 24 deletions sdk/openapi/cloud.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -313,30 +313,7 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/DeviceToken' }
default: { $ref: '#/components/responses/Error' }
/api/cloud/projects/:
get:
operationId: listProjects
security: [{ bearerAuth: [] }]
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/ProjectList' }
default: { $ref: '#/components/responses/Error' }
post:
operationId: createProject
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NewProject' }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/CreatedProject' }
default: { $ref: '#/components/responses/Error' }
/api/cloud/projects/: { $ref: 'projects.yaml#/paths/projects' }
/api/cloud/projects/{projectId}:
get:
operationId: readProject
Expand Down
65 changes: 65 additions & 0 deletions sdk/openapi/projects.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
paths:
projects:
get:
operationId: listProjects
security: [{ bearerAuth: [] }]
responses:
'200':
content:
application/json:
schema: { $ref: './cloud.openapi.yaml#/components/schemas/ProjectList' }
default: { $ref: './cloud.openapi.yaml#/components/responses/Error' }
post:
operationId: createProject
description: |
Creates a project with its production environment and, from a repository, its first service.

A client that may lose the answer sends an `Idempotency-Key` and retries with the same key and
the same request: the same `slug`, `region`, `repository` and `branch`. Once the first create
finished, the retry answers 200 with the project that key created, as a fresh create would:
its `projectId`, current `slug` and `region`, and the original `environmentId` and
`serviceId`. No second project is created, and the replay needs no GitHub call and no room
under the plan's project ceiling.

A key belongs to its workspace and to the member who sent it. Another member sending the
same key and body is not given the project; on its taken slug they get 409
`cloud.errors.projects.slug_taken`, as does a create with another key, or none.

While another create holds the slug, a keyed request answers 409
`cloud.errors.projects.creation_in_progress`: it may be its own first attempt still running,
so the client retries with the same key. The same key from the same member with a different
request answers 422 `cloud.errors.projects.idempotency_key_reused`.
security: [{ bearerAuth: [] }]
parameters:
- in: header
name: Idempotency-Key
required: false
description: Identifies one create across retries. 1 to 255 visible ASCII characters.
schema: { type: string, minLength: 1, maxLength: 255, pattern: '^[\x21-\x7E]+$' }
requestBody:
required: true
content:
application/json:
schema: { $ref: './cloud.openapi.yaml#/components/schemas/NewProject' }
responses:
'200':
description: The project created, or the one this `Idempotency-Key` already created.
content:
application/json:
schema: { $ref: './cloud.openapi.yaml#/components/schemas/CreatedProject' }
'400':
description: '`cloud.errors.projects.invalid_idempotency_key`: the `Idempotency-Key` is empty, longer than 255 characters, or holds a character outside 0x21-0x7E.'
content:
application/json:
schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' }
'409':
description: '`cloud.errors.projects.slug_taken`: another project holds the slug. `cloud.errors.projects.creation_in_progress`: a keyed request met another create holding the slug; retry with the same key.'
content:
application/json:
schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' }
'422':
description: '`cloud.errors.projects.idempotency_key_reused`: the same member''s `Idempotency-Key` already created a project from a request with a different `slug`, `region`, `repository` or `branch`.'
content:
application/json:
schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' }
default: { $ref: './cloud.openapi.yaml#/components/responses/Error' }
24 changes: 22 additions & 2 deletions sdk/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ export interface CloudApiOptions extends EndpointOptions {
token: string;
}

/** How a project create is sent. */
export interface CreateProjectOptions {
/**
* Identifies this create across retries, 1 to 255 visible ASCII characters.
* Scoped to the workspace and to the member who sends it.
*/
idempotencyKey?: string;
}

/** How a log read is shaped: one bounded page, or a tail from a cursor. */
export interface LogRequest {
follow: boolean;
Expand Down Expand Up @@ -125,8 +134,19 @@ export class CloudApi {
return dataOf(result);
}

async createProject(body: NewProject) {
const result = await this.client.POST("/api/cloud/projects/", { body });
/**
* Creates a project. A retry by the same member with the same
* `idempotencyKey` and the same body answers the project that key created
* instead of a second one; the same key with another body is refused with
* `cloud.errors.projects.idempotency_key_reused`, and
* `cloud.errors.projects.creation_in_progress` asks for a retry with the
* same key.
*/
async createProject(body: NewProject, options: CreateProjectOptions = {}) {
const result = await this.client.POST("/api/cloud/projects/", {
body,
params: { header: { "Idempotency-Key": options.idempotencyKey } },
});
return dataOf(result);
}

Expand Down
52 changes: 51 additions & 1 deletion sdk/src/generated/cloud-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ export interface paths {
};
get: operations["listProjects"];
put?: never;
/**
* @description Creates a project with its production environment and, from a repository, its first service.
*
* A client that may lose the answer sends an `Idempotency-Key` and retries with the same key and
* the same request: the same `slug`, `region`, `repository` and `branch`. Once the first create
* finished, the retry answers 200 with the project that key created, as a fresh create would:
* its `projectId`, current `slug` and `region`, and the original `environmentId` and
* `serviceId`. No second project is created, and the replay needs no GitHub call and no room
* under the plan's project ceiling.
*
* A key belongs to its workspace and to the member who sent it. Another member sending the
* same key and body is not given the project; on its taken slug they get 409
* `cloud.errors.projects.slug_taken`, as does a create with another key, or none.
*
* While another create holds the slug, a keyed request answers 409
* `cloud.errors.projects.creation_in_progress`: it may be its own first attempt still running,
* so the client retries with the same key. The same key from the same member with a different
* request answers 422 `cloud.errors.projects.idempotency_key_reused`.
*/
post: operations["createProject"];
delete?: never;
options?: never;
Expand Down Expand Up @@ -1103,7 +1122,10 @@ export interface operations {
createProject: {
parameters: {
query?: never;
header?: never;
header?: {
/** @description Identifies one create across retries. 1 to 255 visible ASCII characters. */
"Idempotency-Key"?: string;
};
path?: never;
cookie?: never;
};
Expand All @@ -1113,6 +1135,7 @@ export interface operations {
};
};
responses: {
/** @description The project created, or the one this `Idempotency-Key` already created. */
200: {
headers: {
[name: string]: unknown;
Expand All @@ -1121,6 +1144,33 @@ export interface operations {
"application/json": components["schemas"]["CreatedProject"];
};
};
/** @description `cloud.errors.projects.invalid_idempotency_key`: the `Idempotency-Key` is empty, longer than 255 characters, or holds a character outside 0x21-0x7E. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description `cloud.errors.projects.slug_taken`: another project holds the slug. `cloud.errors.projects.creation_in_progress`: a keyed request met another create holding the slug; retry with the same key. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description `cloud.errors.projects.idempotency_key_reused`: the same member's `Idempotency-Key` already created a project from a request with a different `slug`, `region`, `repository` or `branch`. */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
default: components["responses"]["Error"];
};
};
Expand Down
24 changes: 24 additions & 0 deletions sdk/test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface Sent {
url: string;
method: string;
authorization: string | null;
idempotencyKey: string | null;
body: string;
}

Expand All @@ -25,6 +26,7 @@ function recorder(answer: () => Response) {
url: request.url,
method: request.method,
authorization: request.headers.get("authorization"),
idempotencyKey: request.headers.get("idempotency-key"),
body: await request.text(),
});
return answer();
Expand Down Expand Up @@ -115,6 +117,28 @@ describe("projects", () => {
branch: "main",
});
});

test("a create sends its idempotency key as the Idempotency-Key header, and no header without one", async () => {
const created = {
projectId: "p-1",
slug: "shop",
region: "eu",
environmentId: "e-1",
serviceId: null,
};
const { sent, send } = recorder(() => json(200, JSON.stringify(created)));
const api = new CloudApi({ token: "t", fetch: send });
await api.createProject(
{ slug: "shop" },
{ idempotencyKey: "setup-2f6c1e0a" },
);
await api.createProject({ slug: "shop" });
assert.deepEqual(
sent.map((request) => request.idempotencyKey),
["setup-2f6c1e0a", null],
);
assert.deepEqual(JSON.parse(sent[0]?.body ?? ""), { slug: "shop" });
});
});

describe("refusals", () => {
Expand Down
Loading