From dc7edfdabbf32c465f860233330634796b7247c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:23:01 +0000 Subject: [PATCH 1/4] feat: OpenAPI document for the v1 API RFD 3 asks for an OpenAPI document versioned with the server and checked for drift in CI. There was none. Add one, using open_api_spex: - TextbinWeb.ApiSpec describes all 21 operations: token creation and identity, organizations, workspaces, the audit log, workspace recovery, paste create/list/show/delete, the removed-paste lifecycle (both the personal and workspace-scoped routes) and raw paste content. - TextbinWeb.ApiSpec.Schemas holds the request and response schemas. Response objects are closed (additionalProperties: false), so a field added to a JSON view without being documented fails the contract tests that follow. - `mix openapi.export` writes priv/openapi.json, the copy for client generation and review. Tests fail when a route under /api/v1 has no operation in the document or the reverse, when a schema reference does not resolve, and when priv/openapi.json differs from the document in code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- lib/textbin_web/api_spec.ex | 382 ++++ lib/textbin_web/api_spec/schemas.ex | 343 ++++ mix.exs | 5 + mix.lock | 1 + priv/openapi.json | 2420 ++++++++++++++++++++++++ test/textbin_web/api_contract_test.exs | 63 + 6 files changed, 3214 insertions(+) create mode 100644 lib/textbin_web/api_spec.ex create mode 100644 lib/textbin_web/api_spec/schemas.ex create mode 100644 priv/openapi.json create mode 100644 test/textbin_web/api_contract_test.exs diff --git a/lib/textbin_web/api_spec.ex b/lib/textbin_web/api_spec.ex new file mode 100644 index 0000000..beea8d9 --- /dev/null +++ b/lib/textbin_web/api_spec.ex @@ -0,0 +1,382 @@ +defmodule TextbinWeb.ApiSpec do + @moduledoc """ + The OpenAPI document for the v1 API and the raw paste endpoint. + + The document is written out by hand rather than derived from controller + annotations, so the whole public contract reads in one place. Two checks + keep it honest: a test fails when a route under /api/v1 has no operation + here (or the reverse), and contract tests cast real responses against the + schemas. `priv/openapi.json` is the exported copy; CI regenerates it and + fails if it differs, so a change to the contract shows up in review. + """ + + @behaviour OpenApiSpex.OpenApi + + alias OpenApiSpex.{ + Components, + Info, + MediaType, + OpenApi, + Operation, + Parameter, + PathItem, + RequestBody, + Response, + Schema, + SecurityScheme, + Server + } + + alias TextbinWeb.ApiSpec.Schemas + + @impl OpenApi + def spec do + %OpenApi{ + info: %Info{ + title: "Textbin API", + version: "1", + description: + "Authenticate with `Authorization: Bearer `. Create a token with POST /api/v1/auth/tokens or in account settings." + }, + servers: [%Server{url: "/"}], + security: [%{"bearer" => []}], + components: %Components{ + schemas: Schemas.all(), + securitySchemes: %{ + "bearer" => %SecurityScheme{type: "http", scheme: "bearer"} + } + }, + paths: paths() + } + end + + @doc "Every operation in the document as `{method, path, operation}`." + def operations do + for {path, item} <- spec().paths, + {method, %Operation{} = operation} <- Map.from_struct(item), + method in [:get, :post, :put, :patch, :delete], + do: {method, path, operation} + end + + defp paths do + %{ + "/api/v1/auth/tokens" => %PathItem{post: create_token()}, + "/api/v1/me" => %PathItem{get: get_identity()}, + "/api/v1/me/token" => %PathItem{delete: revoke_current_token()}, + "/api/v1/organizations" => %PathItem{get: list_organizations()}, + "/api/v1/organizations/{id}/workspaces" => %PathItem{get: list_workspaces()}, + "/api/v1/organizations/{id}/audit-events" => %PathItem{get: list_audit_events()}, + "/api/v1/workspaces/{workspace_id}/recovery" => %PathItem{post: recover_workspace()}, + "/api/v1/pastes" => %PathItem{ + get: list_pastes(:personal), + post: create_paste(:personal) + }, + "/api/v1/pastes/{id}" => %PathItem{ + get: get_paste(:personal), + delete: delete_paste(:personal) + }, + "/api/v1/pastes/deleted" => %PathItem{get: list_removed_pastes(:personal)}, + "/api/v1/pastes/deleted/{id}" => %PathItem{delete: purge_paste(:personal)}, + "/api/v1/pastes/deleted/{id}/restore" => %PathItem{post: restore_paste(:personal)}, + "/api/v1/workspaces/{workspace_id}/pastes" => %PathItem{ + get: list_pastes(:workspace), + post: create_paste(:workspace) + }, + "/api/v1/workspaces/{workspace_id}/pastes/{id}" => %PathItem{ + get: get_paste(:workspace), + delete: delete_paste(:workspace) + }, + "/api/v1/workspaces/{workspace_id}/pastes/deleted" => %PathItem{ + get: list_removed_pastes(:workspace) + }, + "/api/v1/workspaces/{workspace_id}/pastes/deleted/{id}" => %PathItem{ + delete: purge_paste(:workspace) + }, + "/api/v1/workspaces/{workspace_id}/pastes/deleted/{id}/restore" => %PathItem{ + post: restore_paste(:workspace) + }, + "/pastes/{id}/raw" => %PathItem{get: get_raw_paste()} + } + end + + ## Authentication + + defp create_token do + %Operation{ + operationId: "createApiToken", + tags: ["Authentication"], + summary: "Create an API token with an email and password", + security: [], + requestBody: json_body("CreateTokenRequest"), + responses: %{ + 201 => json("The token and the account it belongs to", "CreatedTokenResponse"), + 400 => error("Email or password missing"), + 401 => error("Wrong email or password"), + 422 => error("The token could not be created") + } + } + end + + defp get_identity do + %Operation{ + operationId: "getIdentity", + tags: ["Authentication"], + summary: "Show the authenticated account and token", + responses: %{200 => json("The current identity", "IdentityResponse"), 401 => unauthorized()} + } + end + + defp revoke_current_token do + %Operation{ + operationId: "revokeCurrentToken", + tags: ["Authentication"], + summary: "Revoke the token used for this request", + responses: %{204 => empty("Revoked, or already gone"), 401 => unauthorized()} + } + end + + ## Organizations and workspaces + + defp list_organizations do + %Operation{ + operationId: "listOrganizations", + tags: ["Organizations"], + summary: "List the organizations you belong to", + responses: %{200 => json("Organizations", "OrganizationList"), 401 => unauthorized()} + } + end + + defp list_workspaces do + %Operation{ + operationId: "listOrganizationWorkspaces", + tags: ["Organizations"], + summary: "List workspaces you have joined or can join", + parameters: [path(:id, "Organization id")], + responses: %{ + 200 => json("The organization and its workspaces", "WorkspaceList"), + 401 => unauthorized(), + 404 => error("Organization not found") + } + } + end + + defp list_audit_events do + %Operation{ + operationId: "listAuditEvents", + tags: ["Organizations"], + summary: "Page through an organization's audit log", + parameters: [ + path(:id, "Organization id"), + query(:limit, %Schema{type: :integer, minimum: 1}, "Events per page"), + query(:cursor, %Schema{type: :string}, "next_cursor from the previous page") + ], + responses: %{ + 200 => json("A page of audit events", "AuditEventPage"), + 401 => unauthorized(), + 404 => error("Organization not found, or you cannot read its audit log") + } + } + end + + defp recover_workspace do + %Operation{ + operationId: "recoverWorkspaceAccess", + tags: ["Organizations"], + summary: "Make yourself owner of a workspace in an organization you own", + parameters: [path(:workspace_id, "Workspace id")], + responses: %{ + 201 => json("Your new membership", "WorkspaceRecovery"), + 401 => unauthorized(), + 404 => error("Workspace not found, or you cannot recover it") + } + } + end + + ## Pastes + + # Every paste operation exists twice: once for the caller's personal + # workspace, and once scoped to a workspace id. + defp list_pastes(scope) do + %Operation{ + operationId: operation_id("listPastes", scope), + tags: ["Pastes"], + summary: "List pastes in #{scope_name(scope)}", + parameters: scope_parameters(scope), + responses: scoped_responses(%{200 => json("Pastes, newest first", "PasteList")}) + } + end + + defp create_paste(scope) do + fields = + for {name, schema} <- Schemas.create_paste_fields() do + query(name, schema, schema.description) + end + + %Operation{ + operationId: operation_id("createPaste", scope), + tags: ["Pastes"], + summary: "Create a paste in #{scope_name(scope)}", + description: + "Send JSON, or send the content itself as the request body (any other content type) with the fields as query parameters.", + parameters: scope_parameters(scope) ++ fields, + requestBody: %RequestBody{ + required: true, + content: %{ + "application/json" => %MediaType{schema: Schemas.ref("CreatePasteRequest")}, + "*/*" => %MediaType{schema: %Schema{type: :string, format: :binary}} + } + }, + responses: + scoped_responses(%{ + 201 => json("The new paste, without its content", "PasteMetadataResponse"), + 400 => error("The request body could not be read"), + 413 => error("The content is larger than the server allows"), + 422 => json("Rejected fields", "ValidationError"), + 503 => error("Paste storage is temporarily unavailable") + }) + } + end + + defp get_paste(scope) do + %Operation{ + operationId: operation_id("getPaste", scope), + tags: ["Pastes"], + summary: "Show a paste with its content", + parameters: scope_parameters(scope) ++ [path(:id, "Paste id")], + responses: + scoped_responses(%{ + 200 => json("The paste", "PasteResponse"), + 400 => error("The id is not a UUID"), + 404 => error("Paste or workspace not found") + }) + } + end + + defp delete_paste(scope) do + %Operation{ + operationId: operation_id("deletePaste", scope), + tags: ["Pastes"], + summary: "Delete a paste; it stays restorable for the workspace's recovery window", + parameters: scope_parameters(scope) ++ [path(:id, "Paste id")], + responses: + scoped_responses(%{ + 204 => empty("Deleted, or nothing to delete"), + 503 => error("The deletion could not be completed") + }) + } + end + + defp list_removed_pastes(scope) do + %Operation{ + operationId: operation_id("listRemovedPastes", scope), + tags: ["Pastes"], + summary: "List deleted and expired pastes that can still be restored", + parameters: scope_parameters(scope), + responses: scoped_responses(%{200 => json("Restorable pastes", "RemovedPasteList")}) + } + end + + defp restore_paste(scope) do + %Operation{ + operationId: operation_id("restorePaste", scope), + tags: ["Pastes"], + summary: "Restore a deleted or expired paste", + parameters: scope_parameters(scope) ++ [path(:id, "Paste id")], + responses: + scoped_responses(%{ + 200 => json("The restored paste, without its content", "PasteMetadataResponse"), + 400 => error("The id is not a UUID"), + 404 => error("No restorable paste you can manage has this id") + }) + } + end + + defp purge_paste(scope) do + %Operation{ + operationId: operation_id("purgePaste", scope), + tags: ["Pastes"], + summary: "Delete a removed paste for good", + parameters: scope_parameters(scope) ++ [path(:id, "Paste id")], + responses: + scoped_responses(%{ + 204 => empty("Deleted for good"), + 400 => error("The id is not a UUID"), + 404 => error("No removed paste you can manage has this id"), + 503 => error("The stored content could not be deleted; try again") + }) + } + end + + defp get_raw_paste do + %Operation{ + operationId: "getRawPaste", + tags: ["Pastes"], + summary: "Download a paste's exact stored bytes", + description: + "Uses the browser session or the paste's link, not a bearer token. Text that is safe to show inline is served with its content type; anything else downloads.", + security: [], + parameters: [ + path(:id, "Paste id"), + query(:download, %Schema{type: :string}, "Present to download even safe text") + ], + responses: %{ + 200 => %Response{ + description: "The stored bytes", + content: %{"*/*" => %MediaType{schema: %Schema{type: :string, format: :binary}}} + }, + 404 => %Response{ + description: "Not found, or not shared with you", + content: %{"text/plain" => %MediaType{schema: %Schema{type: :string}}} + } + } + } + end + + ## Helpers + + defp operation_id(name, :personal), do: name + defp operation_id(name, :workspace), do: name <> "InWorkspace" + + defp scope_name(:personal), do: "your personal workspace" + defp scope_name(:workspace), do: "a workspace" + + defp scope_parameters(:personal), do: [] + defp scope_parameters(:workspace), do: [path(:workspace_id, "Workspace id")] + + # Responses every paste operation shares; an operation's own 404 wording wins. + defp scoped_responses(responses) do + Map.merge(%{401 => unauthorized(), 404 => error("Workspace not found")}, responses) + end + + defp path(name, description) do + %Parameter{ + name: name, + in: :path, + required: true, + description: description, + schema: %Schema{type: :string, format: :uuid} + } + end + + defp query(name, schema, description) do + %Parameter{name: name, in: :query, required: false, description: description, schema: schema} + end + + defp json_body(title) do + %RequestBody{ + required: true, + content: %{"application/json" => %MediaType{schema: Schemas.ref(title)}} + } + end + + defp json(description, title) do + %Response{ + description: description, + content: %{"application/json" => %MediaType{schema: Schemas.ref(title)}} + } + end + + defp error(description), do: json(description, "Error") + defp unauthorized, do: error("Missing, invalid or revoked API token") + defp empty(description), do: %Response{description: description} +end diff --git a/lib/textbin_web/api_spec/schemas.ex b/lib/textbin_web/api_spec/schemas.ex new file mode 100644 index 0000000..168f6af --- /dev/null +++ b/lib/textbin_web/api_spec/schemas.ex @@ -0,0 +1,343 @@ +defmodule TextbinWeb.ApiSpec.Schemas do + @moduledoc """ + Response and request schemas for the v1 API. + + Response objects set `additionalProperties: false` on purpose: the contract + tests cast real responses against these schemas, so a field added to a JSON + view without being documented here fails the build instead of drifting + silently. Request objects stay open because the server ignores unknown + fields. + """ + + alias OpenApiSpex.{Reference, Schema} + + @uuid %Schema{type: :string, format: :uuid} + @timestamp %Schema{type: :string, format: :"date-time"} + @nullable_timestamp %Schema{type: :string, format: :"date-time", nullable: true} + @expiry_examples ["10m", "1h", "6h", "12h", "1d", "7d", "30d", "never"] + + @doc "Every named schema, keyed by the title it is referenced by." + def all do + [ + error(), + validation_error(), + create_token_request(), + identity(), + identity_response(), + created_token_response(), + organization(), + organization_list(), + workspace(), + workspace_list(), + audit_event(), + audit_event_page(), + workspace_recovery(), + paste_metadata(), + paste(), + removed_paste(), + paste_response(), + paste_metadata_response(), + paste_list(), + removed_paste_list(), + create_paste_request() + ] + |> Map.new(&{&1.title, &1}) + end + + def ref(title), do: %Reference{"$ref": "#/components/schemas/#{title}"} + + def error do + object("Error", "A request the server could not complete.", + errors: + object(nil, nil, detail: %Schema{type: :string, description: "Human-readable reason"}) + ) + end + + def validation_error do + %Schema{ + title: "ValidationError", + description: "Rejected input, as messages keyed by field.", + type: :object, + required: [:errors], + additionalProperties: false, + properties: %{ + errors: %Schema{ + type: :object, + additionalProperties: %Schema{type: :array, items: %Schema{type: :string}} + } + } + } + end + + def create_token_request do + %Schema{ + title: "CreateTokenRequest", + type: :object, + required: [:email, :password], + properties: %{ + email: %Schema{type: :string, format: :email}, + password: %Schema{type: :string, format: :password}, + name: %Schema{type: :string, description: "Defaults to \"Textbin CLI\""} + } + } + end + + def identity do + object("Identity", "The account and API token a request is authenticated as.", + user: object(nil, nil, id: @uuid, email: %Schema{type: :string, format: :email}), + token: + object(nil, "The token's metadata. The secret is only returned once, on creation.", + id: @uuid, + name: %Schema{type: :string}, + inserted_at: @timestamp + ) + ) + end + + def identity_response, do: object("IdentityResponse", nil, data: ref("Identity")) + + def created_token_response do + identity = identity() + + object("CreatedTokenResponse", nil, + data: %Schema{ + identity + | title: nil, + required: identity.required ++ [:api_token], + properties: + Map.put(identity.properties, :api_token, %Schema{ + type: :string, + description: "The bearer token. Store it now; it is not shown again." + }) + } + ) + end + + def organization do + object("Organization", nil, + id: @uuid, + name: %Schema{type: :string}, + slug: %Schema{type: :string}, + kind: %Schema{type: :string, enum: ["personal", "team"]} + ) + end + + def organization_list, + do: object("OrganizationList", nil, data: %Schema{type: :array, items: ref("Organization")}) + + def workspace do + object("Workspace", nil, + id: @uuid, + organization_id: @uuid, + name: %Schema{type: :string}, + slug: %Schema{type: :string}, + visibility: %Schema{ + type: :string, + enum: ["open", "private"], + description: "Who can join: open to anyone in the organization, or private (invite only)" + }, + external_sharing_policy: %Schema{ + type: :string, + enum: ["disabled", "unlisted", "public"], + description: + "Sharing outside the team: members only, members may share a link, or members may also publish" + }, + is_default: %Schema{type: :boolean} + ) + end + + def workspace_list do + object("WorkspaceList", nil, + data: %Schema{type: :array, items: ref("Workspace")}, + organization: ref("Organization") + ) + end + + def audit_event do + object("AuditEvent", nil, + id: @uuid, + actor_user_id: %Schema{type: :string, format: :uuid, nullable: true}, + action: %Schema{type: :string, example: "workspace.created"}, + target_type: %Schema{type: :string}, + target_id: %Schema{type: :string, format: :uuid, nullable: true}, + metadata: %Schema{type: :object, additionalProperties: true}, + inserted_at: @timestamp + ) + end + + def audit_event_page do + object("AuditEventPage", "Newest first. Pass next_cursor back as cursor for older events.", + data: %Schema{type: :array, items: ref("AuditEvent")}, + next_cursor: %Schema{type: :string, nullable: true} + ) + end + + def workspace_recovery do + object("WorkspaceRecovery", nil, + data: + object(nil, nil, + workspace_id: @uuid, + user_id: @uuid, + role: %Schema{type: :string, enum: ["owner", "member"]} + ) + ) + end + + def paste_metadata do + %Schema{ + title: "PasteMetadata", + description: "A paste without its content.", + type: :object, + required: Map.keys(paste_metadata_properties()), + additionalProperties: false, + properties: paste_metadata_properties() + } + end + + def paste do + properties = + Map.merge(paste_metadata_properties(), %{ + data: %Schema{ + type: :string, + nullable: true, + description: "The content as text, or null when it is binary" + }, + data_base64: %Schema{ + type: :string, + format: :byte, + description: "Present only for binary content" + }, + data_encoding: %Schema{type: :string, enum: ["base64"]} + }) + + %Schema{ + title: "Paste", + description: "A paste with its content.", + type: :object, + required: Map.keys(paste_metadata_properties()) ++ [:data], + additionalProperties: false, + properties: properties + } + end + + def removed_paste do + properties = + Map.merge(paste_metadata_properties(), %{ + removed_at: @timestamp, + removal_reason: %Schema{ + type: :string, + enum: ["deleted", "expired", "moderated", "purging"] + }, + purge_after: %Schema{ + type: :string, + format: :"date-time", + description: "When the paste is deleted for good unless restored" + } + }) + + %Schema{ + title: "RemovedPaste", + description: + "A deleted or expired paste that can still be restored. Content is never included.", + type: :object, + required: Map.keys(properties), + additionalProperties: false, + properties: properties + } + end + + def paste_response, do: object("PasteResponse", nil, data: ref("Paste")) + + def paste_metadata_response, + do: object("PasteMetadataResponse", nil, data: ref("PasteMetadata")) + + def paste_list, + do: object("PasteList", nil, data: %Schema{type: :array, items: ref("Paste")}) + + def removed_paste_list, + do: object("RemovedPasteList", nil, data: %Schema{type: :array, items: ref("RemovedPaste")}) + + def create_paste_request do + %Schema{ + title: "CreatePasteRequest", + description: + "JSON form of a new paste. To upload raw bytes instead, send them as the request body with any other content type and pass these fields as query parameters.", + type: :object, + required: [:data], + properties: + Map.merge(create_paste_fields(), %{ + data: %Schema{type: :string, description: "The paste content"} + }) + } + end + + @doc "Optional paste fields, accepted in a JSON body or as query parameters." + def create_paste_fields do + %{ + title: %Schema{type: :string, description: "Taken from the first line when blank"}, + reference_url: %Schema{ + type: :string, + format: :uri, + description: "A ticket or incident link" + }, + content_type: %Schema{type: :string, example: "text/plain"}, + syntax_highlight: %Schema{type: :string, example: "elixir"}, + audience: %Schema{ + type: :string, + enum: ["workspace", "unlisted", "public"], + description: + "Who can see this: the workspace, anyone with the link, or the public. Limited by the workspace's external sharing policy." + }, + visibility: %Schema{ + type: :string, + enum: ["private", "unlisted", "public"], + deprecated: true, + description: "Use audience. private means workspace." + }, + expires_in: %Schema{ + type: :string, + description: "One of #{Enum.join(@expiry_examples, ", ")}", + example: "1d" + }, + ttl: %Schema{type: :string, deprecated: true, description: "Use expires_in."} + } + end + + defp paste_metadata_properties do + %{ + id: @uuid, + url: %Schema{type: :string, format: :uri, description: "The page to share"}, + raw_url: %Schema{type: :string, format: :uri, description: "The exact stored bytes"}, + organization_id: @uuid, + workspace_id: @uuid, + title: %Schema{type: :string, nullable: true}, + reference_url: %Schema{type: :string, nullable: true}, + content_type: %Schema{type: :string}, + syntax_highlight: %Schema{type: :string}, + audience: %Schema{type: :string, enum: ["workspace", "unlisted", "public"]}, + visibility: %Schema{ + type: :string, + enum: ["private", "unlisted", "public"], + deprecated: true, + description: "Use audience. private means workspace." + }, + expires_at: @nullable_timestamp, + inserted_at: @timestamp, + updated_at: @timestamp + } + end + + # A closed object whose listed properties are all required. + defp object(title, description, properties) do + properties = Map.new(properties) + + %Schema{ + title: title, + description: description, + type: :object, + required: Map.keys(properties), + additionalProperties: false, + properties: properties + } + end +end diff --git a/mix.exs b/mix.exs index 511b58a..df6600e 100644 --- a/mix.exs +++ b/mix.exs @@ -57,6 +57,7 @@ defmodule Textbin.MixProject do {:swoosh, "~> 1.16"}, # http client {:req, "~> 0.5"}, + {:open_api_spex, "~> 3.22"}, # Telemetry {:telemetry_metrics, "~> 1.0"}, @@ -95,6 +96,10 @@ defmodule Textbin.MixProject do "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], "ecto.migration": ["ecto.migrate", "ecto.dump"], + # Rewrites the committed API contract; ApiContractTest fails until it is current. + "openapi.export": [ + "openapi.spec.json --spec TextbinWeb.ApiSpec --pretty=true --vendor-extensions=false --start-app=false priv/openapi.json" + ], test: [ "ecto.create --quiet", "ecto.migrate --quiet", diff --git a/mix.lock b/mix.lock index b1ff55f..7c48127 100644 --- a/mix.lock +++ b/mix.lock @@ -27,6 +27,7 @@ "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "open_api_spex": {:hex, :open_api_spex, "3.22.4", "00d8f32676b459080a860b81d777a8010e61dfa333f54175dcf51dd6f35a0fac", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "f658d73396e2277e584085a6146481eb085d815a716089defe4f94b26cf8b05f"}, "phoenix": {:hex, :phoenix, "1.8.11", "9bb8f0c4e9f0b9eaf7f34cf8cb0bd25bd8599763a5587d64918a9ebb04027823", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "44f028f4129e5a29487e868f84903373e3d032da151ad0c789c3849f464e7351"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, diff --git a/priv/openapi.json b/priv/openapi.json new file mode 100644 index 0000000..cb04b15 --- /dev/null +++ b/priv/openapi.json @@ -0,0 +1,2420 @@ +{ + "components": { + "schemas": { + "AuditEvent": { + "additionalProperties": false, + "properties": { + "action": { + "example": "workspace.created", + "type": "string" + }, + "actor_user_id": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "type": "object" + }, + "target_id": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "target_type": { + "type": "string" + } + }, + "required": [ + "action", + "actor_user_id", + "id", + "inserted_at", + "metadata", + "target_id", + "target_type" + ], + "title": "AuditEvent", + "type": "object" + }, + "AuditEventPage": { + "additionalProperties": false, + "description": "Newest first. Pass next_cursor back as cursor for older events.", + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AuditEvent" + }, + "type": "array" + }, + "next_cursor": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "data", + "next_cursor" + ], + "title": "AuditEventPage", + "type": "object" + }, + "CreatePasteRequest": { + "description": "JSON form of a new paste. To upload raw bytes instead, send them as the request body with any other content type and pass these fields as query parameters.", + "properties": { + "audience": { + "description": "Who can see this: the workspace, anyone with the link, or the public. Limited by the workspace's external sharing policy.", + "enum": [ + "workspace", + "unlisted", + "public" + ], + "type": "string" + }, + "content_type": { + "example": "text/plain", + "type": "string" + }, + "data": { + "description": "The paste content", + "type": "string" + }, + "expires_in": { + "description": "One of 10m, 1h, 6h, 12h, 1d, 7d, 30d, never", + "example": "1d", + "type": "string" + }, + "reference_url": { + "description": "A ticket or incident link", + "format": "uri", + "type": "string" + }, + "syntax_highlight": { + "example": "elixir", + "type": "string" + }, + "title": { + "description": "Taken from the first line when blank", + "type": "string" + }, + "ttl": { + "deprecated": true, + "description": "Use expires_in.", + "type": "string" + }, + "visibility": { + "deprecated": true, + "description": "Use audience. private means workspace.", + "enum": [ + "private", + "unlisted", + "public" + ], + "type": "string" + } + }, + "required": [ + "data" + ], + "title": "CreatePasteRequest", + "type": "object" + }, + "CreateTokenRequest": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "name": { + "description": "Defaults to \"Textbin CLI\"", + "type": "string" + }, + "password": { + "format": "password", + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "title": "CreateTokenRequest", + "type": "object" + }, + "CreatedTokenResponse": { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "description": "The account and API token a request is authenticated as.", + "properties": { + "api_token": { + "description": "The bearer token. Store it now; it is not shown again.", + "type": "string" + }, + "token": { + "additionalProperties": false, + "description": "The token's metadata. The secret is only returned once, on creation.", + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "inserted_at", + "name" + ], + "type": "object" + }, + "user": { + "additionalProperties": false, + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "email", + "id" + ], + "type": "object" + } + }, + "required": [ + "token", + "user", + "api_token" + ], + "type": "object" + } + }, + "required": [ + "data" + ], + "title": "CreatedTokenResponse", + "type": "object" + }, + "Error": { + "additionalProperties": false, + "description": "A request the server could not complete.", + "properties": { + "errors": { + "additionalProperties": false, + "properties": { + "detail": { + "description": "Human-readable reason", + "type": "string" + } + }, + "required": [ + "detail" + ], + "type": "object" + } + }, + "required": [ + "errors" + ], + "title": "Error", + "type": "object" + }, + "Identity": { + "additionalProperties": false, + "description": "The account and API token a request is authenticated as.", + "properties": { + "token": { + "additionalProperties": false, + "description": "The token's metadata. The secret is only returned once, on creation.", + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "inserted_at", + "name" + ], + "type": "object" + }, + "user": { + "additionalProperties": false, + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "email", + "id" + ], + "type": "object" + } + }, + "required": [ + "token", + "user" + ], + "title": "Identity", + "type": "object" + }, + "IdentityResponse": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/Identity" + } + }, + "required": [ + "data" + ], + "title": "IdentityResponse", + "type": "object" + }, + "Organization": { + "additionalProperties": false, + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "kind": { + "enum": [ + "personal", + "team" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "name", + "slug" + ], + "title": "Organization", + "type": "object" + }, + "OrganizationList": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Organization" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "OrganizationList", + "type": "object" + }, + "Paste": { + "additionalProperties": false, + "description": "A paste with its content.", + "properties": { + "audience": { + "enum": [ + "workspace", + "unlisted", + "public" + ], + "type": "string" + }, + "content_type": { + "type": "string" + }, + "data": { + "description": "The content as text, or null when it is binary", + "nullable": true, + "type": "string" + }, + "data_base64": { + "description": "Present only for binary content", + "format": "byte", + "type": "string" + }, + "data_encoding": { + "enum": [ + "base64" + ], + "type": "string" + }, + "expires_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "organization_id": { + "format": "uuid", + "type": "string" + }, + "raw_url": { + "description": "The exact stored bytes", + "format": "uri", + "type": "string" + }, + "reference_url": { + "nullable": true, + "type": "string" + }, + "syntax_highlight": { + "type": "string" + }, + "title": { + "nullable": true, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "url": { + "description": "The page to share", + "format": "uri", + "type": "string" + }, + "visibility": { + "deprecated": true, + "description": "Use audience. private means workspace.", + "enum": [ + "private", + "unlisted", + "public" + ], + "type": "string" + }, + "workspace_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "audience", + "content_type", + "expires_at", + "id", + "inserted_at", + "organization_id", + "raw_url", + "reference_url", + "syntax_highlight", + "title", + "updated_at", + "url", + "visibility", + "workspace_id", + "data" + ], + "title": "Paste", + "type": "object" + }, + "PasteList": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Paste" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "PasteList", + "type": "object" + }, + "PasteMetadata": { + "additionalProperties": false, + "description": "A paste without its content.", + "properties": { + "audience": { + "enum": [ + "workspace", + "unlisted", + "public" + ], + "type": "string" + }, + "content_type": { + "type": "string" + }, + "expires_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "organization_id": { + "format": "uuid", + "type": "string" + }, + "raw_url": { + "description": "The exact stored bytes", + "format": "uri", + "type": "string" + }, + "reference_url": { + "nullable": true, + "type": "string" + }, + "syntax_highlight": { + "type": "string" + }, + "title": { + "nullable": true, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "url": { + "description": "The page to share", + "format": "uri", + "type": "string" + }, + "visibility": { + "deprecated": true, + "description": "Use audience. private means workspace.", + "enum": [ + "private", + "unlisted", + "public" + ], + "type": "string" + }, + "workspace_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "audience", + "content_type", + "expires_at", + "id", + "inserted_at", + "organization_id", + "raw_url", + "reference_url", + "syntax_highlight", + "title", + "updated_at", + "url", + "visibility", + "workspace_id" + ], + "title": "PasteMetadata", + "type": "object" + }, + "PasteMetadataResponse": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/PasteMetadata" + } + }, + "required": [ + "data" + ], + "title": "PasteMetadataResponse", + "type": "object" + }, + "PasteResponse": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/Paste" + } + }, + "required": [ + "data" + ], + "title": "PasteResponse", + "type": "object" + }, + "RemovedPaste": { + "additionalProperties": false, + "description": "A deleted or expired paste that can still be restored. Content is never included.", + "properties": { + "audience": { + "enum": [ + "workspace", + "unlisted", + "public" + ], + "type": "string" + }, + "content_type": { + "type": "string" + }, + "expires_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "organization_id": { + "format": "uuid", + "type": "string" + }, + "purge_after": { + "description": "When the paste is deleted for good unless restored", + "format": "date-time", + "type": "string" + }, + "raw_url": { + "description": "The exact stored bytes", + "format": "uri", + "type": "string" + }, + "reference_url": { + "nullable": true, + "type": "string" + }, + "removal_reason": { + "enum": [ + "deleted", + "expired", + "moderated", + "purging" + ], + "type": "string" + }, + "removed_at": { + "format": "date-time", + "type": "string" + }, + "syntax_highlight": { + "type": "string" + }, + "title": { + "nullable": true, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "url": { + "description": "The page to share", + "format": "uri", + "type": "string" + }, + "visibility": { + "deprecated": true, + "description": "Use audience. private means workspace.", + "enum": [ + "private", + "unlisted", + "public" + ], + "type": "string" + }, + "workspace_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "audience", + "content_type", + "expires_at", + "id", + "inserted_at", + "organization_id", + "purge_after", + "raw_url", + "reference_url", + "removal_reason", + "removed_at", + "syntax_highlight", + "title", + "updated_at", + "url", + "visibility", + "workspace_id" + ], + "title": "RemovedPaste", + "type": "object" + }, + "RemovedPasteList": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/RemovedPaste" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "RemovedPasteList", + "type": "object" + }, + "ValidationError": { + "additionalProperties": false, + "description": "Rejected input, as messages keyed by field.", + "properties": { + "errors": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + } + }, + "required": [ + "errors" + ], + "title": "ValidationError", + "type": "object" + }, + "Workspace": { + "additionalProperties": false, + "properties": { + "external_sharing_policy": { + "description": "Sharing outside the team: members only, members may share a link, or members may also publish", + "enum": [ + "disabled", + "unlisted", + "public" + ], + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "organization_id": { + "format": "uuid", + "type": "string" + }, + "slug": { + "type": "string" + }, + "visibility": { + "description": "Who can join: open to anyone in the organization, or private (invite only)", + "enum": [ + "open", + "private" + ], + "type": "string" + } + }, + "required": [ + "external_sharing_policy", + "id", + "is_default", + "name", + "organization_id", + "slug", + "visibility" + ], + "title": "Workspace", + "type": "object" + }, + "WorkspaceList": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Workspace" + }, + "type": "array" + }, + "organization": { + "$ref": "#/components/schemas/Organization" + } + }, + "required": [ + "data", + "organization" + ], + "title": "WorkspaceList", + "type": "object" + }, + "WorkspaceRecovery": { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "role": { + "enum": [ + "owner", + "member" + ], + "type": "string" + }, + "user_id": { + "format": "uuid", + "type": "string" + }, + "workspace_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "role", + "user_id", + "workspace_id" + ], + "type": "object" + } + }, + "required": [ + "data" + ], + "title": "WorkspaceRecovery", + "type": "object" + } + }, + "securitySchemes": { + "bearer": { + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Authenticate with `Authorization: Bearer `. Create a token with POST /api/v1/auth/tokens or in account settings.", + "title": "Textbin API", + "version": "1" + }, + "openapi": "3.0.0", + "paths": { + "/api/v1/auth/tokens": { + "post": { + "callbacks": {}, + "deprecated": false, + "operationId": "createApiToken", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatedTokenResponse" + } + } + }, + "description": "The token and the account it belongs to" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Email or password missing" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Wrong email or password" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The token could not be created" + } + }, + "security": [], + "summary": "Create an API token with an email and password", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/me": { + "get": { + "callbacks": {}, + "deprecated": false, + "operationId": "getIdentity", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IdentityResponse" + } + } + }, + "description": "The current identity" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + } + }, + "summary": "Show the authenticated account and token", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/me/token": { + "delete": { + "callbacks": {}, + "deprecated": false, + "operationId": "revokeCurrentToken", + "parameters": [], + "responses": { + "204": { + "description": "Revoked, or already gone" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + } + }, + "summary": "Revoke the token used for this request", + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/organizations": { + "get": { + "callbacks": {}, + "deprecated": false, + "operationId": "listOrganizations", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationList" + } + } + }, + "description": "Organizations" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + } + }, + "summary": "List the organizations you belong to", + "tags": [ + "Organizations" + ] + } + }, + "/api/v1/organizations/{id}/audit-events": { + "get": { + "callbacks": {}, + "deprecated": false, + "operationId": "listAuditEvents", + "parameters": [ + { + "description": "Organization id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Events per page", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "minimum": 1, + "type": "integer" + } + }, + { + "description": "next_cursor from the previous page", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditEventPage" + } + } + }, + "description": "A page of audit events" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Organization not found, or you cannot read its audit log" + } + }, + "summary": "Page through an organization's audit log", + "tags": [ + "Organizations" + ] + } + }, + "/api/v1/organizations/{id}/workspaces": { + "get": { + "callbacks": {}, + "deprecated": false, + "operationId": "listOrganizationWorkspaces", + "parameters": [ + { + "description": "Organization id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceList" + } + } + }, + "description": "The organization and its workspaces" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Organization not found" + } + }, + "summary": "List workspaces you have joined or can join", + "tags": [ + "Organizations" + ] + } + }, + "/api/v1/pastes": { + "get": { + "callbacks": {}, + "deprecated": false, + "operationId": "listPastes", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasteList" + } + } + }, + "description": "Pastes, newest first" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Workspace not found" + } + }, + "summary": "List pastes in your personal workspace", + "tags": [ + "Pastes" + ] + }, + "post": { + "callbacks": {}, + "deprecated": false, + "description": "Send JSON, or send the content itself as the request body (any other content type) with the fields as query parameters.", + "operationId": "createPaste", + "parameters": [ + { + "description": "Who can see this: the workspace, anyone with the link, or the public. Limited by the workspace's external sharing policy.", + "in": "query", + "name": "audience", + "required": false, + "schema": { + "description": "Who can see this: the workspace, anyone with the link, or the public. Limited by the workspace's external sharing policy.", + "enum": [ + "workspace", + "unlisted", + "public" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "content_type", + "required": false, + "schema": { + "example": "text/plain", + "type": "string" + } + }, + { + "description": "One of 10m, 1h, 6h, 12h, 1d, 7d, 30d, never", + "in": "query", + "name": "expires_in", + "required": false, + "schema": { + "description": "One of 10m, 1h, 6h, 12h, 1d, 7d, 30d, never", + "example": "1d", + "type": "string" + } + }, + { + "description": "A ticket or incident link", + "in": "query", + "name": "reference_url", + "required": false, + "schema": { + "description": "A ticket or incident link", + "format": "uri", + "type": "string" + } + }, + { + "in": "query", + "name": "syntax_highlight", + "required": false, + "schema": { + "example": "elixir", + "type": "string" + } + }, + { + "description": "Taken from the first line when blank", + "in": "query", + "name": "title", + "required": false, + "schema": { + "description": "Taken from the first line when blank", + "type": "string" + } + }, + { + "description": "Use expires_in.", + "in": "query", + "name": "ttl", + "required": false, + "schema": { + "deprecated": true, + "description": "Use expires_in.", + "type": "string" + } + }, + { + "description": "Use audience. private means workspace.", + "in": "query", + "name": "visibility", + "required": false, + "schema": { + "deprecated": true, + "description": "Use audience. private means workspace.", + "enum": [ + "private", + "unlisted", + "public" + ], + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "format": "binary", + "type": "string" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePasteRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasteMetadataResponse" + } + } + }, + "description": "The new paste, without its content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request body could not be read" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Workspace not found" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The content is larger than the server allows" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Rejected fields" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Paste storage is temporarily unavailable" + } + }, + "summary": "Create a paste in your personal workspace", + "tags": [ + "Pastes" + ] + } + }, + "/api/v1/pastes/deleted": { + "get": { + "callbacks": {}, + "deprecated": false, + "operationId": "listRemovedPastes", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemovedPasteList" + } + } + }, + "description": "Restorable pastes" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Workspace not found" + } + }, + "summary": "List deleted and expired pastes that can still be restored", + "tags": [ + "Pastes" + ] + } + }, + "/api/v1/pastes/deleted/{id}": { + "delete": { + "callbacks": {}, + "deprecated": false, + "operationId": "purgePaste", + "parameters": [ + { + "description": "Paste id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted for good" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The id is not a UUID" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No removed paste you can manage has this id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The stored content could not be deleted; try again" + } + }, + "summary": "Delete a removed paste for good", + "tags": [ + "Pastes" + ] + } + }, + "/api/v1/pastes/deleted/{id}/restore": { + "post": { + "callbacks": {}, + "deprecated": false, + "operationId": "restorePaste", + "parameters": [ + { + "description": "Paste id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasteMetadataResponse" + } + } + }, + "description": "The restored paste, without its content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The id is not a UUID" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No restorable paste you can manage has this id" + } + }, + "summary": "Restore a deleted or expired paste", + "tags": [ + "Pastes" + ] + } + }, + "/api/v1/pastes/{id}": { + "delete": { + "callbacks": {}, + "deprecated": false, + "operationId": "deletePaste", + "parameters": [ + { + "description": "Paste id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted, or nothing to delete" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Workspace not found" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The deletion could not be completed" + } + }, + "summary": "Delete a paste; it stays restorable for the workspace's recovery window", + "tags": [ + "Pastes" + ] + }, + "get": { + "callbacks": {}, + "deprecated": false, + "operationId": "getPaste", + "parameters": [ + { + "description": "Paste id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasteResponse" + } + } + }, + "description": "The paste" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The id is not a UUID" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Paste or workspace not found" + } + }, + "summary": "Show a paste with its content", + "tags": [ + "Pastes" + ] + } + }, + "/api/v1/workspaces/{workspace_id}/pastes": { + "get": { + "callbacks": {}, + "deprecated": false, + "operationId": "listPastesInWorkspace", + "parameters": [ + { + "description": "Workspace id", + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasteList" + } + } + }, + "description": "Pastes, newest first" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Workspace not found" + } + }, + "summary": "List pastes in a workspace", + "tags": [ + "Pastes" + ] + }, + "post": { + "callbacks": {}, + "deprecated": false, + "description": "Send JSON, or send the content itself as the request body (any other content type) with the fields as query parameters.", + "operationId": "createPasteInWorkspace", + "parameters": [ + { + "description": "Workspace id", + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Who can see this: the workspace, anyone with the link, or the public. Limited by the workspace's external sharing policy.", + "in": "query", + "name": "audience", + "required": false, + "schema": { + "description": "Who can see this: the workspace, anyone with the link, or the public. Limited by the workspace's external sharing policy.", + "enum": [ + "workspace", + "unlisted", + "public" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "content_type", + "required": false, + "schema": { + "example": "text/plain", + "type": "string" + } + }, + { + "description": "One of 10m, 1h, 6h, 12h, 1d, 7d, 30d, never", + "in": "query", + "name": "expires_in", + "required": false, + "schema": { + "description": "One of 10m, 1h, 6h, 12h, 1d, 7d, 30d, never", + "example": "1d", + "type": "string" + } + }, + { + "description": "A ticket or incident link", + "in": "query", + "name": "reference_url", + "required": false, + "schema": { + "description": "A ticket or incident link", + "format": "uri", + "type": "string" + } + }, + { + "in": "query", + "name": "syntax_highlight", + "required": false, + "schema": { + "example": "elixir", + "type": "string" + } + }, + { + "description": "Taken from the first line when blank", + "in": "query", + "name": "title", + "required": false, + "schema": { + "description": "Taken from the first line when blank", + "type": "string" + } + }, + { + "description": "Use expires_in.", + "in": "query", + "name": "ttl", + "required": false, + "schema": { + "deprecated": true, + "description": "Use expires_in.", + "type": "string" + } + }, + { + "description": "Use audience. private means workspace.", + "in": "query", + "name": "visibility", + "required": false, + "schema": { + "deprecated": true, + "description": "Use audience. private means workspace.", + "enum": [ + "private", + "unlisted", + "public" + ], + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "format": "binary", + "type": "string" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePasteRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasteMetadataResponse" + } + } + }, + "description": "The new paste, without its content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request body could not be read" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Workspace not found" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The content is larger than the server allows" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Rejected fields" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Paste storage is temporarily unavailable" + } + }, + "summary": "Create a paste in a workspace", + "tags": [ + "Pastes" + ] + } + }, + "/api/v1/workspaces/{workspace_id}/pastes/deleted": { + "get": { + "callbacks": {}, + "deprecated": false, + "operationId": "listRemovedPastesInWorkspace", + "parameters": [ + { + "description": "Workspace id", + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemovedPasteList" + } + } + }, + "description": "Restorable pastes" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Workspace not found" + } + }, + "summary": "List deleted and expired pastes that can still be restored", + "tags": [ + "Pastes" + ] + } + }, + "/api/v1/workspaces/{workspace_id}/pastes/deleted/{id}": { + "delete": { + "callbacks": {}, + "deprecated": false, + "operationId": "purgePasteInWorkspace", + "parameters": [ + { + "description": "Workspace id", + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Paste id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted for good" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The id is not a UUID" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No removed paste you can manage has this id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The stored content could not be deleted; try again" + } + }, + "summary": "Delete a removed paste for good", + "tags": [ + "Pastes" + ] + } + }, + "/api/v1/workspaces/{workspace_id}/pastes/deleted/{id}/restore": { + "post": { + "callbacks": {}, + "deprecated": false, + "operationId": "restorePasteInWorkspace", + "parameters": [ + { + "description": "Workspace id", + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Paste id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasteMetadataResponse" + } + } + }, + "description": "The restored paste, without its content" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The id is not a UUID" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No restorable paste you can manage has this id" + } + }, + "summary": "Restore a deleted or expired paste", + "tags": [ + "Pastes" + ] + } + }, + "/api/v1/workspaces/{workspace_id}/pastes/{id}": { + "delete": { + "callbacks": {}, + "deprecated": false, + "operationId": "deletePasteInWorkspace", + "parameters": [ + { + "description": "Workspace id", + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Paste id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted, or nothing to delete" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Workspace not found" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The deletion could not be completed" + } + }, + "summary": "Delete a paste; it stays restorable for the workspace's recovery window", + "tags": [ + "Pastes" + ] + }, + "get": { + "callbacks": {}, + "deprecated": false, + "operationId": "getPasteInWorkspace", + "parameters": [ + { + "description": "Workspace id", + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Paste id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasteResponse" + } + } + }, + "description": "The paste" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The id is not a UUID" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Paste or workspace not found" + } + }, + "summary": "Show a paste with its content", + "tags": [ + "Pastes" + ] + } + }, + "/api/v1/workspaces/{workspace_id}/recovery": { + "post": { + "callbacks": {}, + "deprecated": false, + "operationId": "recoverWorkspaceAccess", + "parameters": [ + { + "description": "Workspace id", + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRecovery" + } + } + }, + "description": "Your new membership" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Missing, invalid or revoked API token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Workspace not found, or you cannot recover it" + } + }, + "summary": "Make yourself owner of a workspace in an organization you own", + "tags": [ + "Organizations" + ] + } + }, + "/pastes/{id}/raw": { + "get": { + "callbacks": {}, + "deprecated": false, + "description": "Uses the browser session or the paste's link, not a bearer token. Text that is safe to show inline is served with its content type; anything else downloads.", + "operationId": "getRawPaste", + "parameters": [ + { + "description": "Paste id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Present to download even safe text", + "in": "query", + "name": "download", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "The stored bytes" + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Not found, or not shared with you" + } + }, + "security": [], + "summary": "Download a paste's exact stored bytes", + "tags": [ + "Pastes" + ] + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "servers": [ + { + "url": "/", + "variables": {} + } + ], + "tags": [] +} diff --git a/test/textbin_web/api_contract_test.exs b/test/textbin_web/api_contract_test.exs new file mode 100644 index 0000000..0a7320c --- /dev/null +++ b/test/textbin_web/api_contract_test.exs @@ -0,0 +1,63 @@ +defmodule TextbinWeb.ApiContractTest do + @moduledoc """ + Keeps `TextbinWeb.ApiSpec` and the server in step: the document covers + every API route and the exported `priv/openapi.json` is current. + """ + + use ExUnit.Case, async: true + + alias OpenApiSpex.Reference + alias TextbinWeb.ApiSpec + + @exported_spec Path.expand("../../priv/openapi.json", __DIR__) + + describe "the document" do + test "documents every API route and nothing else" do + routed = + for route <- Phoenix.Router.routes(TextbinWeb.Router), + String.starts_with?(route.path, "/api/") or route.path == "/pastes/:id/raw", + into: MapSet.new(), + do: {route.verb, Regex.replace(~r/:(\w+)/, route.path, "{\\1}")} + + documented = + MapSet.new(ApiSpec.operations(), fn {method, path, _operation} -> {method, path} end) + + assert MapSet.difference(routed, documented) == MapSet.new(), + "routes missing from TextbinWeb.ApiSpec" + + assert MapSet.difference(documented, routed) == MapSet.new(), + "operations in TextbinWeb.ApiSpec with no route" + end + + test "gives each operation a unique id and resolves every schema reference" do + operation_ids = + for {_method, _path, operation} <- ApiSpec.operations(), do: operation.operationId + + assert Enum.uniq(operation_ids) == operation_ids + + spec = ApiSpec.spec() + known = Map.keys(spec.components.schemas) + + for ref <- references(spec) do + assert "#/components/schemas/" <> title = ref + assert title in known, "#{ref} does not resolve" + end + end + + test "priv/openapi.json is the exported spec" do + current = + ApiSpec.spec() |> OpenApiSpex.OpenApi.to_map() |> Jason.encode!() |> Jason.decode!() + + assert File.exists?(@exported_spec), "run `mix openapi.export` to create priv/openapi.json" + + assert Jason.decode!(File.read!(@exported_spec)) == current, + "priv/openapi.json is out of date; run `mix openapi.export`" + end + end + + defp references(%Reference{"$ref": ref}), do: [ref] + defp references(%_struct{} = struct), do: struct |> Map.from_struct() |> references() + defp references(map) when is_map(map), do: Enum.flat_map(Map.values(map), &references/1) + defp references(list) when is_list(list), do: Enum.flat_map(list, &references/1) + defp references(_value), do: [] +end From 6406b81471ecb2dcf00a4a8a2c705445f64795bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:23:17 +0000 Subject: [PATCH 2/4] test: check every API operation's responses against the OpenAPI document TextbinWeb.ApiContract.assert_api_response/2 takes a real response and the operation it implements. The status must be one the document lists for that operation, and a JSON body must cast against its schema. Response schemas are closed, so an undocumented field fails as surely as a missing or mistyped one. Each of the 21 operations is exercised for its success response and its main errors, including binary paste content, the removed-paste lifecycle in both scopes, and the raw endpoint. Adding a field to PasteJSON, or changing a field's type, fails these tests until the document says so. Tick the two RFD 3 items this and #71 complete: canonical and raw URLs in paste responses, and OpenAPI contract tests in CI (they run in mix test, in every storage matrix job). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- rfd/0003/IMPLEMENTATION.org | 4 +- test/support/api_contract.ex | 70 ++++++++ test/textbin_web/api_contract_test.exs | 224 ++++++++++++++++++++++++- 3 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 test/support/api_contract.ex diff --git a/rfd/0003/IMPLEMENTATION.org b/rfd/0003/IMPLEMENTATION.org index b18a24a..ac60f35 100644 --- a/rfd/0003/IMPLEMENTATION.org +++ b/rfd/0003/IMPLEMENTATION.org @@ -8,6 +8,6 @@ Implements [[file:README.adoc][RFD 3: Paste discovery and API contract]]. - [ ] Search cannot reveal the existence or metadata of an unauthorized paste. - [ ] Every CLI command's `--json` output is machine-readable and covered by compatibility tests. -- [ ] API create/show responses contain canonical and raw URLs. +- [x] API create/show responses contain canonical and raw URLs. - [ ] Oversized JSON is rejected before full decoding. -- [ ] OpenAPI validation and server contract tests run in CI. +- [x] OpenAPI validation and server contract tests run in CI. diff --git a/test/support/api_contract.ex b/test/support/api_contract.ex new file mode 100644 index 0000000..b3d6445 --- /dev/null +++ b/test/support/api_contract.ex @@ -0,0 +1,70 @@ +defmodule TextbinWeb.ApiContract do + @moduledoc """ + Checks a real API response against the operation it claims to implement in + `TextbinWeb.ApiSpec`: the status must be documented for that operation, and + a JSON body must cast against its schema. Response schemas are closed, so an + undocumented field fails as surely as a missing one. + """ + + import ExUnit.Assertions + + alias OpenApiSpex.{MediaType, Response} + alias TextbinWeb.ApiSpec + + def assert_api_response(%Plug.Conn{} = conn, operation_id) do + spec = spec() + operation = operation!(operation_id) + + response = + Map.get(operation.responses, conn.status) || + flunk( + "#{operation_id} returned #{conn.status}, which the spec does not document " <> + "(documented: #{operation.responses |> Map.keys() |> Enum.sort() |> inspect()})" + ) + + case response do + %Response{content: %{"application/json" => %MediaType{schema: schema}}} -> + body = Jason.decode!(conn.resp_body) + + case OpenApiSpex.cast_value(body, schema, spec) do + {:ok, _value} -> + :ok + + {:error, errors} -> + details = Enum.map_join(errors, "\n", &" #{&1}") + + flunk( + "#{operation_id} #{conn.status} response does not match the spec:\n#{details}\n\n" <> + "Body: #{conn.resp_body}" + ) + end + + %Response{content: nil} -> + assert conn.resp_body == "", "#{operation_id} #{conn.status} should have no body" + + %Response{} -> + :ok + end + + conn + end + + defp operation!(operation_id) do + Enum.find_value(ApiSpec.operations(), fn {_method, _path, operation} -> + operation.operationId == operation_id && operation + end) || flunk("No operation #{operation_id} in TextbinWeb.ApiSpec") + end + + # Building the spec is pure but not free; every assertion shares one copy. + defp spec do + case :persistent_term.get({__MODULE__, :spec}, nil) do + nil -> + spec = ApiSpec.spec() + :persistent_term.put({__MODULE__, :spec}, spec) + spec + + spec -> + spec + end + end +end diff --git a/test/textbin_web/api_contract_test.exs b/test/textbin_web/api_contract_test.exs index 0a7320c..a16fb6c 100644 --- a/test/textbin_web/api_contract_test.exs +++ b/test/textbin_web/api_contract_test.exs @@ -1,12 +1,19 @@ defmodule TextbinWeb.ApiContractTest do @moduledoc """ Keeps `TextbinWeb.ApiSpec` and the server in step: the document covers - every API route and the exported `priv/openapi.json` is current. + every API route, the exported `priv/openapi.json` is current, and each + operation's real responses match their documented schemas. """ - use ExUnit.Case, async: true + use TextbinWeb.ConnCase, async: false + + import TextbinWeb.ApiContract + import Textbin.AccountsFixtures alias OpenApiSpex.Reference + alias Textbin.Accounts + alias Textbin.Organizations + alias Textbin.Pastes alias TextbinWeb.ApiSpec @exported_spec Path.expand("../../priv/openapi.json", __DIR__) @@ -55,6 +62,219 @@ defmodule TextbinWeb.ApiContractTest do end end + describe "authentication" do + setup :api_user + + test "createApiToken" do + user = user_fixture() + + build_conn() + |> post(~p"/api/v1/auth/tokens", %{email: user.email, password: valid_user_password()}) + |> assert_api_response("createApiToken") + + build_conn() + |> post(~p"/api/v1/auth/tokens", %{email: user.email, password: "wrong password"}) + |> assert_api_response("createApiToken") + + build_conn() + |> post(~p"/api/v1/auth/tokens", %{}) + |> assert_api_response("createApiToken") + end + + test "getIdentity and revokeCurrentToken", %{conn: conn} do + conn |> get(~p"/api/v1/me") |> assert_api_response("getIdentity") + build_conn() |> get(~p"/api/v1/me") |> assert_api_response("getIdentity") + conn |> delete(~p"/api/v1/me/token") |> assert_api_response("revokeCurrentToken") + end + end + + describe "organizations" do + setup [:api_user, :team_organization] + + test "listOrganizations and listOrganizationWorkspaces", context do + context.conn |> get(~p"/api/v1/organizations") |> assert_api_response("listOrganizations") + + context.conn + |> get(~p"/api/v1/organizations/#{context.organization.id}/workspaces") + |> assert_api_response("listOrganizationWorkspaces") + + context.conn + |> get(~p"/api/v1/organizations/#{Ecto.UUID.generate()}/workspaces") + |> assert_api_response("listOrganizationWorkspaces") + end + + test "listAuditEvents", context do + context.conn + |> get(~p"/api/v1/organizations/#{context.organization.id}/audit-events?limit=1") + |> assert_api_response("listAuditEvents") + end + + test "recoverWorkspaceAccess", context do + member = user_fixture() + + {:ok, _memberships} = + Organizations.add_organization_member(context.scope, context.organization, member) + + {:ok, workspace} = + Organizations.create_workspace(context.scope, context.organization, %{ + name: "Contract recovery", + slug: "contract-recovery", + visibility: "private" + }) + + {:ok, _membership} = + Organizations.add_workspace_member(context.scope, workspace, member, "owner") + + {:ok, _membership} = Organizations.leave_workspace(context.scope, workspace) + + context.conn + |> post(~p"/api/v1/workspaces/#{workspace.id}/recovery") + |> assert_api_response("recoverWorkspaceAccess") + + context.conn + |> post(~p"/api/v1/workspaces/#{Ecto.UUID.generate()}/recovery") + |> assert_api_response("recoverWorkspaceAccess") + end + end + + describe "pastes in the personal workspace" do + setup :api_user + + test "create, list, show and delete", %{conn: conn} do + created = + conn + |> post(~p"/api/v1/pastes", %{ + data: "hello", + title: "Greeting", + reference_url: "https://example.com/ticket/1", + expires_in: "1d" + }) + |> assert_api_response("createPaste") + + id = json_response(created, 201)["data"]["id"] + + binary = + conn + |> put_req_header("content-type", "application/octet-stream") + |> post(~p"/api/v1/pastes?title=Bytes", <<0, 255, 1, 2>>) + |> assert_api_response("createPaste") + + binary_id = json_response(binary, 201)["data"]["id"] + conn |> get(~p"/api/v1/pastes/#{binary_id}") |> assert_api_response("getPaste") + + conn |> post(~p"/api/v1/pastes", %{data: ""}) |> assert_api_response("createPaste") + conn |> get(~p"/api/v1/pastes") |> assert_api_response("listPastes") + conn |> get(~p"/api/v1/pastes/#{id}") |> assert_api_response("getPaste") + conn |> get(~p"/api/v1/pastes/not-a-uuid") |> assert_api_response("getPaste") + conn |> get(~p"/api/v1/pastes/#{Ecto.UUID.generate()}") |> assert_api_response("getPaste") + conn |> delete(~p"/api/v1/pastes/#{id}") |> assert_api_response("deletePaste") + build_conn() |> get(~p"/api/v1/pastes") |> assert_api_response("listPastes") + end + + test "list, restore and purge removed pastes", %{conn: conn, scope: scope} do + {:ok, paste} = Pastes.create_paste(scope, %{data: "gone for now"}) + {:ok, _removed} = Pastes.delete_paste(scope, paste) + + conn |> get(~p"/api/v1/pastes/deleted") |> assert_api_response("listRemovedPastes") + + conn + |> post(~p"/api/v1/pastes/deleted/#{paste.id}/restore") + |> assert_api_response("restorePaste") + + conn + |> post(~p"/api/v1/pastes/deleted/#{paste.id}/restore") + |> assert_api_response("restorePaste") + + {:ok, _removed} = Pastes.delete_paste(scope, paste) + conn |> delete(~p"/api/v1/pastes/deleted/#{paste.id}") |> assert_api_response("purgePaste") + conn |> delete(~p"/api/v1/pastes/deleted/#{paste.id}") |> assert_api_response("purgePaste") + conn |> delete(~p"/api/v1/pastes/deleted/not-a-uuid") |> assert_api_response("purgePaste") + end + + test "getRawPaste", %{scope: scope} do + {:ok, paste} = Pastes.create_paste(scope, %{data: "shared", audience: "unlisted"}) + + build_conn() |> get(~p"/pastes/#{paste.id}/raw") |> assert_api_response("getRawPaste") + + build_conn() + |> get(~p"/pastes/#{Ecto.UUID.generate()}/raw") + |> assert_api_response("getRawPaste") + end + end + + describe "pastes in a named workspace" do + setup [:api_user, :team_organization] + + test "create, list, show and delete", %{conn: conn, workspace: workspace} do + created = + conn + |> post(~p"/api/v1/workspaces/#{workspace.id}/pastes", %{data: "team note"}) + |> assert_api_response("createPasteInWorkspace") + + id = json_response(created, 201)["data"]["id"] + + conn + |> get(~p"/api/v1/workspaces/#{workspace.id}/pastes") + |> assert_api_response("listPastesInWorkspace") + + conn + |> get(~p"/api/v1/workspaces/#{Ecto.UUID.generate()}/pastes") + |> assert_api_response("listPastesInWorkspace") + + conn + |> get(~p"/api/v1/workspaces/#{workspace.id}/pastes/#{id}") + |> assert_api_response("getPasteInWorkspace") + + conn + |> delete(~p"/api/v1/workspaces/#{workspace.id}/pastes/#{id}") + |> assert_api_response("deletePasteInWorkspace") + + conn + |> get(~p"/api/v1/workspaces/#{workspace.id}/pastes/deleted") + |> assert_api_response("listRemovedPastesInWorkspace") + + conn + |> post(~p"/api/v1/workspaces/#{workspace.id}/pastes/deleted/#{id}/restore") + |> assert_api_response("restorePasteInWorkspace") + + conn + |> delete(~p"/api/v1/workspaces/#{workspace.id}/pastes/#{id}") + |> assert_api_response("deletePasteInWorkspace") + + conn + |> delete(~p"/api/v1/workspaces/#{workspace.id}/pastes/deleted/#{id}") + |> assert_api_response("purgePasteInWorkspace") + end + end + + defp api_user(_context) do + user = user_fixture() + {:ok, {token, _user_token}} = Accounts.create_user_api_token(user, %{"name" => "Contract"}) + + %{ + user: user, + scope: user_scope_fixture(user), + conn: put_req_header(build_conn(), "authorization", "Bearer #{token}") + } + end + + defp team_organization(%{scope: scope}) do + {:ok, organization} = + Organizations.create_organization(scope, %{ + name: "Contract organization", + slug: "contract-#{System.unique_integer([:positive])}" + }) + + {:ok, workspace} = + Organizations.create_workspace(scope, organization, %{ + name: "Contract workspace", + slug: "contract-workspace", + visibility: "open" + }) + + %{organization: organization, workspace: workspace} + end + defp references(%Reference{"$ref": ref}), do: [ref] defp references(%_struct{} = struct), do: struct |> Map.from_struct() |> references() defp references(map) when is_map(map), do: Enum.flat_map(Map.values(map), &references/1) From 5f8770782de95d5dd66e44b0dd552d1c7e16feea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:25:44 +0000 Subject: [PATCH 3/4] fix: export the OpenAPI document identically on every OTP release CI failed "priv/openapi.json is the exported spec" while it passed locally. The document builds two kinds of list from map iteration order: each schema's `required` list (Map.keys/1) and the create-paste query parameters. Atom-keyed maps stopped iterating alphabetically in OTP 26, so CI (OTP 28) produced those lists in a different order from a local OTP 25 build, and the committed export never matched. Sort both explicitly, and regenerate priv/openapi.json. Only list order changes; the document's content is the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- lib/textbin_web/api_spec.ex | 2 +- lib/textbin_web/api_spec/schemas.ex | 15 ++++++++++----- priv/openapi.json | 8 ++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/lib/textbin_web/api_spec.ex b/lib/textbin_web/api_spec.ex index beea8d9..19a9158 100644 --- a/lib/textbin_web/api_spec.ex +++ b/lib/textbin_web/api_spec.ex @@ -208,7 +208,7 @@ defmodule TextbinWeb.ApiSpec do defp create_paste(scope) do fields = - for {name, schema} <- Schemas.create_paste_fields() do + for {name, schema} <- Enum.sort(Schemas.create_paste_fields()) do query(name, schema, schema.description) end diff --git a/lib/textbin_web/api_spec/schemas.ex b/lib/textbin_web/api_spec/schemas.ex index 168f6af..a6e2320 100644 --- a/lib/textbin_web/api_spec/schemas.ex +++ b/lib/textbin_web/api_spec/schemas.ex @@ -103,7 +103,7 @@ defmodule TextbinWeb.ApiSpec.Schemas do data: %Schema{ identity | title: nil, - required: identity.required ++ [:api_token], + required: Enum.sort([:api_token | identity.required]), properties: Map.put(identity.properties, :api_token, %Schema{ type: :string, @@ -188,7 +188,7 @@ defmodule TextbinWeb.ApiSpec.Schemas do title: "PasteMetadata", description: "A paste without its content.", type: :object, - required: Map.keys(paste_metadata_properties()), + required: required(paste_metadata_properties()), additionalProperties: false, properties: paste_metadata_properties() } @@ -214,7 +214,7 @@ defmodule TextbinWeb.ApiSpec.Schemas do title: "Paste", description: "A paste with its content.", type: :object, - required: Map.keys(paste_metadata_properties()) ++ [:data], + required: paste_metadata_properties() |> Map.put(:data, nil) |> required(), additionalProperties: false, properties: properties } @@ -240,7 +240,7 @@ defmodule TextbinWeb.ApiSpec.Schemas do description: "A deleted or expired paste that can still be restored. Content is never included.", type: :object, - required: Map.keys(properties), + required: required(properties), additionalProperties: false, properties: properties } @@ -327,6 +327,11 @@ defmodule TextbinWeb.ApiSpec.Schemas do } end + # Map key order is not stable across OTP releases (atom-keyed maps stopped + # iterating alphabetically in OTP 26), and the exported document must be + # byte-for-byte the same wherever it is generated. + defp required(properties), do: properties |> Map.keys() |> Enum.sort() + # A closed object whose listed properties are all required. defp object(title, description, properties) do properties = Map.new(properties) @@ -335,7 +340,7 @@ defmodule TextbinWeb.ApiSpec.Schemas do title: title, description: description, type: :object, - required: Map.keys(properties), + required: required(properties), additionalProperties: false, properties: properties } diff --git a/priv/openapi.json b/priv/openapi.json index cb04b15..2723927 100644 --- a/priv/openapi.json +++ b/priv/openapi.json @@ -204,9 +204,9 @@ } }, "required": [ + "api_token", "token", - "user", - "api_token" + "user" ], "type": "object" } @@ -444,6 +444,7 @@ "required": [ "audience", "content_type", + "data", "expires_at", "id", "inserted_at", @@ -455,8 +456,7 @@ "updated_at", "url", "visibility", - "workspace_id", - "data" + "workspace_id" ], "title": "Paste", "type": "object" From 7fe1b7af4397baf650350c68a389147d0a6270f5 Mon Sep 17 00:00:00 2001 From: "Darwin D. Wu" Date: Sat, 10 Oct 2026 22:48:09 -0700 Subject: [PATCH 4/4] fix: validate OpenAPI response wire types and paste alternatives Validate decoded JSON without coercion before casting for formats and closed objects. Express paste text and binary content as exclusive required representations. Add negative contract tests for quoted booleans, missing/extra fields and malformed content. Both regressions failed before the fix; 582 ExUnit tests and Credo pass. Regenerate OpenAPI export. --- lib/textbin_web/api_spec/schemas.ex | 31 ++- priv/openapi.json | 302 +++++++++++++++++-------- test/support/api_contract.ex | 31 ++- test/textbin_web/api_contract_test.exs | 41 ++++ 4 files changed, 285 insertions(+), 120 deletions(-) diff --git a/lib/textbin_web/api_spec/schemas.ex b/lib/textbin_web/api_spec/schemas.ex index a6e2320..ca7543e 100644 --- a/lib/textbin_web/api_spec/schemas.ex +++ b/lib/textbin_web/api_spec/schemas.ex @@ -195,28 +195,23 @@ defmodule TextbinWeb.ApiSpec.Schemas do end def paste do - properties = - Map.merge(paste_metadata_properties(), %{ - data: %Schema{ - type: :string, - nullable: true, - description: "The content as text, or null when it is binary" - }, - data_base64: %Schema{ - type: :string, - format: :byte, - description: "Present only for binary content" - }, - data_encoding: %Schema{type: :string, enum: ["base64"]} - }) + metadata = paste_metadata_properties() %Schema{ title: "Paste", description: "A paste with its content.", - type: :object, - required: paste_metadata_properties() |> Map.put(:data, nil) |> required(), - additionalProperties: false, - properties: properties + oneOf: [ + object(nil, "Text content", Map.put(metadata, :data, %Schema{type: :string})), + object( + nil, + "Binary content", + Map.merge(metadata, %{ + data: %Schema{type: :string, nullable: true, enum: [nil]}, + data_base64: %Schema{type: :string, format: :byte}, + data_encoding: %Schema{type: :string, enum: ["base64"]} + }) + ) + ] } end diff --git a/priv/openapi.json b/priv/openapi.json index 2723927..10eb36c 100644 --- a/priv/openapi.json +++ b/priv/openapi.json @@ -354,112 +354,214 @@ "type": "object" }, "Paste": { - "additionalProperties": false, "description": "A paste with its content.", - "properties": { - "audience": { - "enum": [ - "workspace", - "unlisted", - "public" - ], - "type": "string" - }, - "content_type": { - "type": "string" - }, - "data": { - "description": "The content as text, or null when it is binary", - "nullable": true, - "type": "string" - }, - "data_base64": { - "description": "Present only for binary content", - "format": "byte", - "type": "string" - }, - "data_encoding": { - "enum": [ - "base64" + "oneOf": [ + { + "additionalProperties": false, + "description": "Text content", + "properties": { + "audience": { + "enum": [ + "workspace", + "unlisted", + "public" + ], + "type": "string" + }, + "content_type": { + "type": "string" + }, + "data": { + "type": "string" + }, + "expires_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "organization_id": { + "format": "uuid", + "type": "string" + }, + "raw_url": { + "description": "The exact stored bytes", + "format": "uri", + "type": "string" + }, + "reference_url": { + "nullable": true, + "type": "string" + }, + "syntax_highlight": { + "type": "string" + }, + "title": { + "nullable": true, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "url": { + "description": "The page to share", + "format": "uri", + "type": "string" + }, + "visibility": { + "deprecated": true, + "description": "Use audience. private means workspace.", + "enum": [ + "private", + "unlisted", + "public" + ], + "type": "string" + }, + "workspace_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "audience", + "content_type", + "data", + "expires_at", + "id", + "inserted_at", + "organization_id", + "raw_url", + "reference_url", + "syntax_highlight", + "title", + "updated_at", + "url", + "visibility", + "workspace_id" ], - "type": "string" - }, - "expires_at": { - "format": "date-time", - "nullable": true, - "type": "string" - }, - "id": { - "format": "uuid", - "type": "string" - }, - "inserted_at": { - "format": "date-time", - "type": "string" - }, - "organization_id": { - "format": "uuid", - "type": "string" - }, - "raw_url": { - "description": "The exact stored bytes", - "format": "uri", - "type": "string" - }, - "reference_url": { - "nullable": true, - "type": "string" - }, - "syntax_highlight": { - "type": "string" - }, - "title": { - "nullable": true, - "type": "string" - }, - "updated_at": { - "format": "date-time", - "type": "string" - }, - "url": { - "description": "The page to share", - "format": "uri", - "type": "string" + "type": "object" }, - "visibility": { - "deprecated": true, - "description": "Use audience. private means workspace.", - "enum": [ - "private", - "unlisted", - "public" + { + "additionalProperties": false, + "description": "Binary content", + "properties": { + "audience": { + "enum": [ + "workspace", + "unlisted", + "public" + ], + "type": "string" + }, + "content_type": { + "type": "string" + }, + "data": { + "enum": [ + null + ], + "nullable": true, + "type": "string" + }, + "data_base64": { + "format": "byte", + "type": "string" + }, + "data_encoding": { + "enum": [ + "base64" + ], + "type": "string" + }, + "expires_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "organization_id": { + "format": "uuid", + "type": "string" + }, + "raw_url": { + "description": "The exact stored bytes", + "format": "uri", + "type": "string" + }, + "reference_url": { + "nullable": true, + "type": "string" + }, + "syntax_highlight": { + "type": "string" + }, + "title": { + "nullable": true, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "url": { + "description": "The page to share", + "format": "uri", + "type": "string" + }, + "visibility": { + "deprecated": true, + "description": "Use audience. private means workspace.", + "enum": [ + "private", + "unlisted", + "public" + ], + "type": "string" + }, + "workspace_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "audience", + "content_type", + "data", + "data_base64", + "data_encoding", + "expires_at", + "id", + "inserted_at", + "organization_id", + "raw_url", + "reference_url", + "syntax_highlight", + "title", + "updated_at", + "url", + "visibility", + "workspace_id" ], - "type": "string" - }, - "workspace_id": { - "format": "uuid", - "type": "string" + "type": "object" } - }, - "required": [ - "audience", - "content_type", - "data", - "expires_at", - "id", - "inserted_at", - "organization_id", - "raw_url", - "reference_url", - "syntax_highlight", - "title", - "updated_at", - "url", - "visibility", - "workspace_id" ], - "title": "Paste", - "type": "object" + "title": "Paste" }, "PasteList": { "additionalProperties": false, diff --git a/test/support/api_contract.ex b/test/support/api_contract.ex index b3d6445..b6c8cae 100644 --- a/test/support/api_contract.ex +++ b/test/support/api_contract.ex @@ -2,13 +2,13 @@ defmodule TextbinWeb.ApiContract do @moduledoc """ Checks a real API response against the operation it claims to implement in `TextbinWeb.ApiSpec`: the status must be documented for that operation, and - a JSON body must cast against its schema. Response schemas are closed, so an + a JSON body must validate without type coercion. Response schemas are closed, so an undocumented field fails as surely as a missing one. """ import ExUnit.Assertions - alias OpenApiSpex.{MediaType, Response} + alias OpenApiSpex.{MediaType, Reference, Response, Schema} alias TextbinWeb.ApiSpec def assert_api_response(%Plug.Conn{} = conn, operation_id) do @@ -26,6 +26,14 @@ defmodule TextbinWeb.ApiContract do %Response{content: %{"application/json" => %MediaType{schema: schema}}} -> body = Jason.decode!(conn.resp_body) + # Casting enforces closed objects and formats but accepts quoted + # booleans/numbers. Validate the original wire types first; this API + # expects property keys to match the decoded JSON's string keys. + case Schema.validate(json_keys(schema), body, json_keys(spec.components.schemas)) do + :ok -> :ok + {:error, detail} -> flunk("#{operation_id} #{conn.status}: #{detail}") + end + case OpenApiSpex.cast_value(body, schema, spec) do {:ok, _value} -> :ok @@ -49,6 +57,25 @@ defmodule TextbinWeb.ApiContract do conn end + defp json_keys(%Schema{} = schema) do + normalized = schema |> Map.from_struct() |> json_keys() |> then(&struct!(Schema, &1)) + + %{ + normalized + | properties: + Map.new(normalized.properties || %{}, fn {key, value} -> {to_string(key), value} end), + required: Enum.map(normalized.required || [], &to_string/1) + } + end + + defp json_keys(%Reference{} = reference), do: reference + + defp json_keys(map) when is_map(map), + do: Map.new(map, fn {key, value} -> {key, json_keys(value)} end) + + defp json_keys(list) when is_list(list), do: Enum.map(list, &json_keys/1) + defp json_keys(value), do: value + defp operation!(operation_id) do Enum.find_value(ApiSpec.operations(), fn {_method, _path, operation} -> operation.operationId == operation_id && operation diff --git a/test/textbin_web/api_contract_test.exs b/test/textbin_web/api_contract_test.exs index a16fb6c..9da411e 100644 --- a/test/textbin_web/api_contract_test.exs +++ b/test/textbin_web/api_contract_test.exs @@ -91,6 +91,26 @@ defmodule TextbinWeb.ApiContractTest do describe "organizations" do setup [:api_user, :team_organization] + test "rejects quoted booleans, missing fields and undocumented fields", context do + response = + get(context.conn, ~p"/api/v1/organizations/#{context.organization.id}/workspaces") + + body = json_response(response, 200) + [workspace | rest] = body["data"] + + for invalid <- [ + Map.put(workspace, "is_default", "false"), + Map.delete(workspace, "is_default"), + Map.put(workspace, "surprise", true) + ] do + malformed = %{response | resp_body: Jason.encode!(%{body | "data" => [invalid | rest]})} + + assert_raise ExUnit.AssertionError, fn -> + assert_api_response(malformed, "listOrganizationWorkspaces") + end + end + end + test "listOrganizations and listOrganizationWorkspaces", context do context.conn |> get(~p"/api/v1/organizations") |> assert_api_response("listOrganizations") @@ -140,6 +160,27 @@ defmodule TextbinWeb.ApiContractTest do describe "pastes in the personal workspace" do setup :api_user + test "rejects incomplete and mixed paste content representations", %{conn: conn, scope: scope} do + {:ok, paste} = Pastes.create_paste(scope, %{data: "text"}) + response = get(conn, ~p"/api/v1/pastes/#{paste.id}") + body = json_response(response, 200) + metadata = Map.delete(body["data"], "data") + + for fields <- [ + %{"data" => nil}, + %{"data" => nil, "data_base64" => "AP8BAg=="}, + %{"data" => nil, "data_encoding" => "base64"}, + %{"data" => "text", "data_base64" => "AP8BAg==", "data_encoding" => "base64"} + ] do + malformed = %{ + response + | resp_body: Jason.encode!(%{"data" => Map.merge(metadata, fields)}) + } + + assert_raise ExUnit.AssertionError, fn -> assert_api_response(malformed, "getPaste") end + end + end + test "create, list, show and delete", %{conn: conn} do created = conn