//! OpenAPI spec generation and endpoint. //! //! Collects documented API operations into a single spec served at //! `/api/openapi.json`. Only public/stable endpoints are included, //! internal dashboard and HTMX endpoints are intentionally excluded. use axum::{Json, response::IntoResponse}; use utoipa::OpenApi; /// OpenAPI spec collecting all documented endpoints. /// /// Add new handler paths and schema types here as endpoints are annotated. #[derive(OpenApi)] #[openapi( info( title = "Makenotwork API", description = "Creator marketplace API. Only public and stable endpoints are documented.", version = env!("CARGO_PKG_VERSION"), license(name = "PolyForm Noncommercial 1.0.0"), ), paths( // License Keys crate::routes::api::license_keys::validate_key, crate::routes::api::license_keys::deactivate_key, crate::routes::api::license_keys::key_status_post, crate::routes::api::license_keys::key_status, crate::routes::api::license_keys::license_verify, crate::routes::api::license_keys::license_deactivate, crate::routes::api::license_keys::license_text, // Git Notes crate::routes::api::git_notes::list_namespaces, crate::routes::api::git_notes::get_note, crate::routes::api::git_notes::put_note, crate::routes::api::git_notes::delete_note, crate::routes::api::git_notes::search_notes, // SyncKit, Auth crate::routes::synckit::auth::sync_auth, crate::routes::synckit::auth::validate_app, // SyncKit, Sync crate::routes::synckit::sync::sync_push, crate::routes::synckit::sync::sync_pull, crate::routes::synckit::sync::sync_status, crate::routes::synckit::sync::register_device, crate::routes::synckit::sync::list_devices, crate::routes::synckit::sync::delete_device, crate::routes::synckit::sync::put_sync_key, crate::routes::synckit::sync::get_sync_key, // SyncKit, Account & subscription crate::routes::synckit::sync::sync_account, crate::routes::synckit::sync::sync_subscription_status, crate::routes::synckit::sync::get_app_pricing, crate::routes::synckit::sync::quote_subscription_price, crate::routes::synckit::sync::create_subscription_checkout, crate::routes::synckit::sync::queue_storage_cap_change, // SyncKit, Key rotation crate::routes::synckit::sync::begin_rotation, crate::routes::synckit::sync::rotation_entries, crate::routes::synckit::sync::rotation_batch, crate::routes::synckit::sync::complete_rotation, crate::routes::synckit::sync::cancel_rotation, // SyncKit, Blobs crate::routes::synckit::blobs::blob_upload_url, crate::routes::synckit::blobs::blob_multipart_start, crate::routes::synckit::blobs::blob_multipart_parts, crate::routes::synckit::blobs::blob_multipart_complete, crate::routes::synckit::blobs::blob_multipart_abort, crate::routes::synckit::blobs::blob_confirm_upload, crate::routes::synckit::blobs::blob_download_url, ), components(schemas( // License Keys crate::routes::api::license_keys::ValidateKeyRequest, crate::routes::api::license_keys::ValidateKeyResponse, crate::routes::api::license_keys::ValidateKeyLicense, crate::routes::api::license_keys::DeactivateKeyRequest, crate::routes::api::license_keys::DeactivateKeyResponse, crate::routes::api::license_keys::KeyStatusRequest, crate::routes::api::license_keys::KeyStatusResponse, crate::routes::api::license_keys::KeyStatusLicense, crate::routes::api::license_keys::LicenseVerifyRequest, crate::routes::api::license_keys::LicenseVerifyResponse, crate::routes::api::license_keys::LicenseDeactivateRequest, // Git Notes crate::routes::api::git_notes::NamespaceEntry, crate::routes::api::git_notes::NamespacesResponse, crate::routes::api::git_notes::NoteAttribution, crate::routes::api::git_notes::NoteResponse, crate::routes::api::git_notes::PutNoteRequest, crate::routes::api::git_notes::WriteResponse, crate::routes::api::git_notes::SearchHit, crate::routes::api::git_notes::SearchResponse, // SyncKit crate::routes::synckit::SyncAuthRequest, crate::routes::synckit::SyncAuthResponse, crate::routes::synckit::ValidateAppQuery, crate::routes::synckit::ValidateAppResponse, crate::routes::synckit::PushRequest, crate::routes::synckit::ChangeEntry, crate::routes::synckit::PushResponse, crate::routes::synckit::PullRequest, crate::routes::synckit::PullResponse, crate::routes::synckit::PullChangeEntry, crate::routes::synckit::SyncDeviceResponse, crate::routes::synckit::RegisterDeviceRequest, crate::routes::synckit::SyncStatusResponse, crate::routes::synckit::PutKeyRequest, crate::routes::synckit::GetKeyResponse, crate::routes::synckit::BlobUploadUrlRequest, crate::routes::synckit::BlobUploadUrlResponse, crate::routes::synckit::BlobConfirmRequest, crate::routes::synckit::BlobMultipartStartRequest, crate::routes::synckit::BlobMultipartStartResponse, crate::routes::synckit::BlobMultipartPartsRequest, crate::routes::synckit::BlobMultipartPartsResponse, crate::routes::synckit::BlobMultipartPartUrl, crate::routes::synckit::BlobMultipartCompleteRequest, crate::routes::synckit::BlobMultipartCompletedPart, crate::routes::synckit::BlobMultipartAbortRequest, crate::routes::synckit::BlobDownloadUrlRequest, crate::routes::synckit::BlobDownloadUrlResponse, // SyncKit, Account & subscription crate::routes::synckit::SyncAccountResponse, crate::routes::synckit::SyncSubscriptionStatusResponse, crate::routes::synckit::AppPricingRequest, crate::routes::synckit::AppPricingResponse, crate::routes::synckit::SyncQuoteRequest, crate::routes::synckit::SyncQuoteResponse, crate::routes::synckit::SyncSubscribeRequest, crate::routes::synckit::SyncCheckoutResponse, crate::routes::synckit::SyncCapChangeRequest, // SyncKit, Key rotation crate::routes::synckit::BeginRotationRequest, crate::routes::synckit::BeginRotationResponse, crate::routes::synckit::RotationEntriesRequest, crate::routes::synckit::RotationEntriesResponse, crate::routes::synckit::RotationBatchRequest, crate::routes::synckit::RotationBatchEntry, crate::routes::synckit::RotationBatchResponse, crate::routes::synckit::CompleteRotationRequest, )), tags( (name = "License Keys", description = "Public license key validation, activation, and deactivation. Stable API: response shapes are frozen."), (name = "SyncKit", description = "E2E encrypted cloud sync for indie apps. JWT auth via /api/v1/sync/auth, then Bearer token on all other endpoints."), (name = "Git Notes", description = "Read and write refs/notes/* on a repository. Reads answer from the repository and take a session or a personal access token; writes take a push-scoped personal access token as HTTP Basic auth, the same credential git push uses."), ), security( ("bearer" = []), ), )] pub struct ApiDoc; /// Serve the OpenAPI spec as JSON. pub async fn openapi_json() -> impl IntoResponse { Json(ApiDoc::openapi()) } /// The spec as pretty JSON, with a trailing newline. /// /// One function so the served endpoint, the exporter binary and the drift test /// cannot disagree about formatting. Pretty-printed on purpose: the committed /// copy is reviewed as a diff, and a single-line spec makes every change look /// like a rewrite. pub fn spec_json() -> String { let mut s = serde_json::to_string_pretty(&ApiDoc::openapi()) .expect("the generated spec always serializes"); s.push('\n'); s } #[cfg(test)] mod tests { use super::*; /// Path to the committed spec, resolved at compile time. /// /// `CARGO_MANIFEST_DIR` rather than a relative path: cargo-mutants copies the /// crate to a temp dir and runs the suite there, and a test that opens `"./..."` /// at runtime fails in the copy. That is exactly how the server's mutation /// baseline was broken for months (wiki `testing-posture`). const COMMITTED_SPEC: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/openapi.json"); /// The SyncKit rotation + subscription endpoints are fully annotated but were /// historically missing from the published spec (Run 20). This asserts the /// spec documents them, so dropping a handler from `paths(...)` regresses /// here rather than silently shrinking the SDK's contract. #[test] fn spec_documents_synckit_rotation_and_subscription() { let spec = ApiDoc::openapi(); let paths = &spec.paths.paths; for p in [ "/api/v1/sync/account", "/api/v1/sync/subscription", "/api/v1/sync/subscription/checkout", "/api/v1/sync/subscription/quote", "/api/v1/sync/subscription/storage-cap", "/api/v1/sync/app/pricing", "/api/v1/sync/keys/rotate", "/api/v1/sync/keys/rotate/batch", "/api/v1/sync/keys/rotate/complete", "/api/v1/sync/keys/rotate/entries", ] { assert!(paths.contains_key(p), "openapi spec is missing path {p}"); } } /// The committed `openapi.json` is the artifact `synckit-client` validates /// its wiremock fixtures against, so it has to track the handlers. Without /// this, annotating a new field or renaming one leaves the vendored copy /// describing a server that no longer exists, and the client's suite stays /// green while asserting the old shape. That is the imitation-oracle failure /// this whole exercise is about (wiki `testing-posture`). #[test] fn committed_spec_matches_generated() { let committed = std::fs::read_to_string(COMMITTED_SPEC) .expect("openapi.json is committed at the crate root"); assert_eq!( committed, spec_json(), "openapi.json is stale. Regenerate it with `cargo run --bin export-openapi`, \ then vendor the new copy into synckit-client (tests/openapi.json)." ); } /// Regression (v0.10.14): the hand-rolled `/api/openapi.json` route and the /// SwaggerUi mount must serve the spec at *distinct* paths. Reusing the same /// path makes axum panic ("Overlapping method route") at `build_app` time, /// which cascades every integration test that boots the app. This mirrors the /// wiring in `lib.rs`; building the router here is DB-free and fails fast if /// the two paths ever collide again. #[test] fn openapi_route_and_swagger_ui_do_not_collide() { let _app: axum::Router = axum::Router::new() .route("/api/openapi.json", axum::routing::get(openapi_json)) .merge( utoipa_swagger_ui::SwaggerUi::new("/api/docs") .url("/api-docs/openapi.json", ApiDoc::openapi()), ); } }