diff --git a/sdk/openapi/cloud.openapi.yaml b/sdk/openapi/cloud.openapi.yaml index d8dcc83..c9f068b 100644 --- a/sdk/openapi/cloud.openapi.yaml +++ b/sdk/openapi/cloud.openapi.yaml @@ -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 diff --git a/sdk/openapi/projects.yaml b/sdk/openapi/projects.yaml new file mode 100644 index 0000000..703dd18 --- /dev/null +++ b/sdk/openapi/projects.yaml @@ -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' } diff --git a/sdk/src/api.ts b/sdk/src/api.ts index 85280de..e5dd73d 100644 --- a/sdk/src/api.ts +++ b/sdk/src/api.ts @@ -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; @@ -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); } diff --git a/sdk/src/generated/cloud-api.ts b/sdk/src/generated/cloud-api.ts index fe4ea51..344dcb2 100644 --- a/sdk/src/generated/cloud-api.ts +++ b/sdk/src/generated/cloud-api.ts @@ -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; @@ -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; }; @@ -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; @@ -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"]; }; }; diff --git a/sdk/test/api.test.ts b/sdk/test/api.test.ts index 57bfc79..7acb1d1 100644 --- a/sdk/test/api.test.ts +++ b/sdk/test/api.test.ts @@ -13,6 +13,7 @@ interface Sent { url: string; method: string; authorization: string | null; + idempotencyKey: string | null; body: string; } @@ -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(); @@ -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", () => {