Skip to main content

max / makenotwork

11.2 KB · 240 lines History Blame Raw
1 //! OpenAPI spec generation and endpoint.
2 //!
3 //! Collects documented API operations into a single spec served at
4 //! `/api/openapi.json`. Only public/stable endpoints are included,
5 //! internal dashboard and HTMX endpoints are intentionally excluded.
6
7 use axum::{Json, response::IntoResponse};
8 use utoipa::OpenApi;
9
10 /// OpenAPI spec collecting all documented endpoints.
11 ///
12 /// Add new handler paths and schema types here as endpoints are annotated.
13 #[derive(OpenApi)]
14 #[openapi(
15 info(
16 title = "Makenotwork API",
17 description = "Creator marketplace API. Only public and stable endpoints are documented.",
18 version = env!("CARGO_PKG_VERSION"),
19 license(name = "PolyForm Noncommercial 1.0.0"),
20 ),
21 paths(
22 // License Keys
23 crate::routes::api::license_keys::validate_key,
24 crate::routes::api::license_keys::deactivate_key,
25 crate::routes::api::license_keys::key_status_post,
26 crate::routes::api::license_keys::key_status,
27 crate::routes::api::license_keys::license_verify,
28 crate::routes::api::license_keys::license_deactivate,
29 crate::routes::api::license_keys::license_text,
30 // Git Notes
31 crate::routes::api::git_notes::list_namespaces,
32 crate::routes::api::git_notes::get_note,
33 crate::routes::api::git_notes::put_note,
34 crate::routes::api::git_notes::delete_note,
35 crate::routes::api::git_notes::search_notes,
36 // SyncKit, Auth
37 crate::routes::synckit::auth::sync_auth,
38 crate::routes::synckit::auth::validate_app,
39 // SyncKit, Sync
40 crate::routes::synckit::sync::sync_push,
41 crate::routes::synckit::sync::sync_pull,
42 crate::routes::synckit::sync::sync_status,
43 crate::routes::synckit::sync::register_device,
44 crate::routes::synckit::sync::list_devices,
45 crate::routes::synckit::sync::delete_device,
46 crate::routes::synckit::sync::put_sync_key,
47 crate::routes::synckit::sync::get_sync_key,
48 // SyncKit, Account & subscription
49 crate::routes::synckit::sync::sync_account,
50 crate::routes::synckit::sync::sync_subscription_status,
51 crate::routes::synckit::sync::get_app_pricing,
52 crate::routes::synckit::sync::quote_subscription_price,
53 crate::routes::synckit::sync::create_subscription_checkout,
54 crate::routes::synckit::sync::queue_storage_cap_change,
55 // SyncKit, Key rotation
56 crate::routes::synckit::sync::begin_rotation,
57 crate::routes::synckit::sync::rotation_entries,
58 crate::routes::synckit::sync::rotation_batch,
59 crate::routes::synckit::sync::complete_rotation,
60 crate::routes::synckit::sync::cancel_rotation,
61 // SyncKit, Blobs
62 crate::routes::synckit::blobs::blob_upload_url,
63 crate::routes::synckit::blobs::blob_multipart_start,
64 crate::routes::synckit::blobs::blob_multipart_parts,
65 crate::routes::synckit::blobs::blob_multipart_complete,
66 crate::routes::synckit::blobs::blob_multipart_abort,
67 crate::routes::synckit::blobs::blob_confirm_upload,
68 crate::routes::synckit::blobs::blob_download_url,
69 ),
70 components(schemas(
71 // License Keys
72 crate::routes::api::license_keys::ValidateKeyRequest,
73 crate::routes::api::license_keys::ValidateKeyResponse,
74 crate::routes::api::license_keys::ValidateKeyLicense,
75 crate::routes::api::license_keys::DeactivateKeyRequest,
76 crate::routes::api::license_keys::DeactivateKeyResponse,
77 crate::routes::api::license_keys::KeyStatusRequest,
78 crate::routes::api::license_keys::KeyStatusResponse,
79 crate::routes::api::license_keys::KeyStatusLicense,
80 crate::routes::api::license_keys::LicenseVerifyRequest,
81 crate::routes::api::license_keys::LicenseVerifyResponse,
82 crate::routes::api::license_keys::LicenseDeactivateRequest,
83 // Git Notes
84 crate::routes::api::git_notes::NamespaceEntry,
85 crate::routes::api::git_notes::NamespacesResponse,
86 crate::routes::api::git_notes::NoteAttribution,
87 crate::routes::api::git_notes::NoteResponse,
88 crate::routes::api::git_notes::PutNoteRequest,
89 crate::routes::api::git_notes::WriteResponse,
90 crate::routes::api::git_notes::SearchHit,
91 crate::routes::api::git_notes::SearchResponse,
92 // SyncKit
93 crate::routes::synckit::SyncAuthRequest,
94 crate::routes::synckit::SyncAuthResponse,
95 crate::routes::synckit::ValidateAppQuery,
96 crate::routes::synckit::ValidateAppResponse,
97 crate::routes::synckit::PushRequest,
98 crate::routes::synckit::ChangeEntry,
99 crate::routes::synckit::PushResponse,
100 crate::routes::synckit::PullRequest,
101 crate::routes::synckit::PullResponse,
102 crate::routes::synckit::PullChangeEntry,
103 crate::routes::synckit::SyncDeviceResponse,
104 crate::routes::synckit::RegisterDeviceRequest,
105 crate::routes::synckit::SyncStatusResponse,
106 crate::routes::synckit::PutKeyRequest,
107 crate::routes::synckit::GetKeyResponse,
108 crate::routes::synckit::BlobUploadUrlRequest,
109 crate::routes::synckit::BlobUploadUrlResponse,
110 crate::routes::synckit::BlobConfirmRequest,
111 crate::routes::synckit::BlobMultipartStartRequest,
112 crate::routes::synckit::BlobMultipartStartResponse,
113 crate::routes::synckit::BlobMultipartPartsRequest,
114 crate::routes::synckit::BlobMultipartPartsResponse,
115 crate::routes::synckit::BlobMultipartPartUrl,
116 crate::routes::synckit::BlobMultipartCompleteRequest,
117 crate::routes::synckit::BlobMultipartCompletedPart,
118 crate::routes::synckit::BlobMultipartAbortRequest,
119 crate::routes::synckit::BlobDownloadUrlRequest,
120 crate::routes::synckit::BlobDownloadUrlResponse,
121 // SyncKit, Account & subscription
122 crate::routes::synckit::SyncAccountResponse,
123 crate::routes::synckit::SyncSubscriptionStatusResponse,
124 crate::routes::synckit::AppPricingRequest,
125 crate::routes::synckit::AppPricingResponse,
126 crate::routes::synckit::SyncQuoteRequest,
127 crate::routes::synckit::SyncQuoteResponse,
128 crate::routes::synckit::SyncSubscribeRequest,
129 crate::routes::synckit::SyncCheckoutResponse,
130 crate::routes::synckit::SyncCapChangeRequest,
131 // SyncKit, Key rotation
132 crate::routes::synckit::BeginRotationRequest,
133 crate::routes::synckit::BeginRotationResponse,
134 crate::routes::synckit::RotationEntriesRequest,
135 crate::routes::synckit::RotationEntriesResponse,
136 crate::routes::synckit::RotationBatchRequest,
137 crate::routes::synckit::RotationBatchEntry,
138 crate::routes::synckit::RotationBatchResponse,
139 crate::routes::synckit::CompleteRotationRequest,
140 )),
141 tags(
142 (name = "License Keys", description = "Public license key validation, activation, and deactivation. Stable API: response shapes are frozen."),
143 (name = "SyncKit", description = "E2E encrypted cloud sync for indie apps. JWT auth via /api/v1/sync/auth, then Bearer token on all other endpoints."),
144 (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."),
145 ),
146 security(
147 ("bearer" = []),
148 ),
149 )]
150 pub struct ApiDoc;
151
152 /// Serve the OpenAPI spec as JSON.
153 pub async fn openapi_json() -> impl IntoResponse {
154 Json(ApiDoc::openapi())
155 }
156
157 /// The spec as pretty JSON, with a trailing newline.
158 ///
159 /// One function so the served endpoint, the exporter binary and the drift test
160 /// cannot disagree about formatting. Pretty-printed on purpose: the committed
161 /// copy is reviewed as a diff, and a single-line spec makes every change look
162 /// like a rewrite.
163 pub fn spec_json() -> String {
164 let mut s = serde_json::to_string_pretty(&ApiDoc::openapi())
165 .expect("the generated spec always serializes");
166 s.push('\n');
167 s
168 }
169
170 #[cfg(test)]
171 mod tests {
172 use super::*;
173
174 /// Path to the committed spec, resolved at compile time.
175 ///
176 /// `CARGO_MANIFEST_DIR` rather than a relative path: cargo-mutants copies the
177 /// crate to a temp dir and runs the suite there, and a test that opens `"./..."`
178 /// at runtime fails in the copy. That is exactly how the server's mutation
179 /// baseline was broken for months (wiki `testing-posture`).
180 const COMMITTED_SPEC: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/openapi.json");
181
182 /// The SyncKit rotation + subscription endpoints are fully annotated but were
183 /// historically missing from the published spec (Run 20). This asserts the
184 /// spec documents them, so dropping a handler from `paths(...)` regresses
185 /// here rather than silently shrinking the SDK's contract.
186 #[test]
187 fn spec_documents_synckit_rotation_and_subscription() {
188 let spec = ApiDoc::openapi();
189 let paths = &spec.paths.paths;
190 for p in [
191 "/api/v1/sync/account",
192 "/api/v1/sync/subscription",
193 "/api/v1/sync/subscription/checkout",
194 "/api/v1/sync/subscription/quote",
195 "/api/v1/sync/subscription/storage-cap",
196 "/api/v1/sync/app/pricing",
197 "/api/v1/sync/keys/rotate",
198 "/api/v1/sync/keys/rotate/batch",
199 "/api/v1/sync/keys/rotate/complete",
200 "/api/v1/sync/keys/rotate/entries",
201 ] {
202 assert!(paths.contains_key(p), "openapi spec is missing path {p}");
203 }
204 }
205
206 /// The committed `openapi.json` is the artifact `synckit-client` validates
207 /// its wiremock fixtures against, so it has to track the handlers. Without
208 /// this, annotating a new field or renaming one leaves the vendored copy
209 /// describing a server that no longer exists, and the client's suite stays
210 /// green while asserting the old shape. That is the imitation-oracle failure
211 /// this whole exercise is about (wiki `testing-posture`).
212 #[test]
213 fn committed_spec_matches_generated() {
214 let committed = std::fs::read_to_string(COMMITTED_SPEC)
215 .expect("openapi.json is committed at the crate root");
216 assert_eq!(
217 committed,
218 spec_json(),
219 "openapi.json is stale. Regenerate it with `cargo run --bin export-openapi`, \
220 then vendor the new copy into synckit-client (tests/openapi.json)."
221 );
222 }
223
224 /// Regression (v0.10.14): the hand-rolled `/api/openapi.json` route and the
225 /// SwaggerUi mount must serve the spec at *distinct* paths. Reusing the same
226 /// path makes axum panic ("Overlapping method route") at `build_app` time,
227 /// which cascades every integration test that boots the app. This mirrors the
228 /// wiring in `lib.rs`; building the router here is DB-free and fails fast if
229 /// the two paths ever collide again.
230 #[test]
231 fn openapi_route_and_swagger_ui_do_not_collide() {
232 let _app: axum::Router = axum::Router::new()
233 .route("/api/openapi.json", axum::routing::get(openapi_json))
234 .merge(
235 utoipa_swagger_ui::SwaggerUi::new("/api/docs")
236 .url("/api-docs/openapi.json", ApiDoc::openapi()),
237 );
238 }
239 }
240