diff --git a/lib/textbin_web/api_spec.ex b/lib/textbin_web/api_spec.ex new file mode 100644 index 0000000..19a9158 --- /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} <- Enum.sort(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..ca7543e --- /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: Enum.sort([:api_token | identity.required]), + 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: required(paste_metadata_properties()), + additionalProperties: false, + properties: paste_metadata_properties() + } + end + + def paste do + metadata = paste_metadata_properties() + + %Schema{ + title: "Paste", + description: "A paste with its content.", + 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 + + 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: required(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 + + # 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) + + %Schema{ + title: title, + description: description, + type: :object, + required: required(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..10eb36c --- /dev/null +++ b/priv/openapi.json @@ -0,0 +1,2522 @@ +{ + "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": [ + "api_token", + "token", + "user" + ], + "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": { + "description": "A paste with its content.", + "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": "object" + }, + { + "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": "object" + } + ], + "title": "Paste" + }, + "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/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..b6c8cae --- /dev/null +++ b/test/support/api_contract.ex @@ -0,0 +1,97 @@ +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 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, Reference, Response, Schema} + 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) + + # 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 + + {: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 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 + 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 new file mode 100644 index 0000000..9da411e --- /dev/null +++ b/test/textbin_web/api_contract_test.exs @@ -0,0 +1,324 @@ +defmodule TextbinWeb.ApiContractTest do + @moduledoc """ + Keeps `TextbinWeb.ApiSpec` and the server in step: the document covers + every API route, the exported `priv/openapi.json` is current, and each + operation's real responses match their documented schemas. + """ + + 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__) + + 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 + + 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 "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") + + 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 "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 + |> 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) + defp references(list) when is_list(list), do: Enum.flat_map(list, &references/1) + defp references(_value), do: [] +end