From 5396b4dc97f41423e3379254533650254ab80383 Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 01:14:53 +0530 Subject: [PATCH 01/13] chore: scaffold the v1 rewrite Edition 2024, MSRV 1.85, reqwest 0.13 on rustls, pinned toolchain and committed lockfile. The committed openapi.json is the source of truth for every wire shape, drift-checked against the backend in CI. Legacy sources removed; the endpoints they called no longer exist. --- .gitignore | 1 - Cargo.lock | 2344 +++++++ Cargo.toml | 66 +- deny.toml | 27 + openapi.json | 14274 ++++++++++++++++++++++++++++++++++++++++ rust-toolchain.toml | 3 + src/client.rs | 618 -- src/errors.rs | 96 - src/requests.rs | 266 - src/utils.rs | 38 - tests/api.rs | 131 - tests/blocking_api.rs | 131 - tests/utils.rs | 38 - 13 files changed, 16697 insertions(+), 1336 deletions(-) create mode 100644 Cargo.lock create mode 100644 deny.toml create mode 100644 openapi.json create mode 100644 rust-toolchain.toml delete mode 100644 src/client.rs delete mode 100644 src/errors.rs delete mode 100644 src/requests.rs delete mode 100644 src/utils.rs delete mode 100644 tests/api.rs delete mode 100644 tests/blocking_api.rs delete mode 100644 tests/utils.rs diff --git a/.gitignore b/.gitignore index 869df07..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ /target -Cargo.lock \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..38fb5c2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2344 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f877e75f39e9827ec50a572dd592684ac28c029578726c85f1b2aa6ab807449" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spoo-me" +version = "0.2.0" +dependencies = [ + "base64", + "bytes", + "chrono", + "futures-core", + "futures-util", + "gloo-timers", + "rand 0.9.5", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.20", + "tokio", + "tracing", + "wiremock", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 8428b38..539b08a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,30 +1,62 @@ [package] name = "spoo-me" -version = "0.1.1" -authors = ["rdni"] -description = "Bindings for accessing spoo.me api" -edition = "2021" +version = "0.2.0" +authors = ["spoo.me", "rdni"] +description = "Official Rust SDK for the spoo.me URL shortener API" +edition = "2024" +rust-version = "1.85" include = [ "/src/**", "/Cargo.toml", "/LICENSE", "/README.md", - "/tests/**" + "/openapi.json", ] -keywords = ["async", "api", "sdk", "bindings"] -license = "Apache-2.0" -repository = "https://github.com/spoo-me/rust-sdk" +keywords = ["spoo", "url-shortener", "api", "sdk", "async"] +categories = ["api-bindings", "web-programming::http-client"] +license = "MIT" +repository = "https://github.com/spoo-me/spoo-rust" +documentation = "https://docs.rs/spoo-me" [features] -blocking = [] -custom_url = [] -full = ["custom_url"] +default = ["stream"] +# Lazy pagination and export bodies as `futures_core::Stream`. +stream = ["dep:futures-core", "dep:bytes"] +# Sign in with Spoo: PKCE helpers, device-code exchange, refreshing sessions. +oauth = ["dep:sha2", "dep:base64", "dep:rand", "dep:tokio"] +# Per-request tracing spans and retry events. +tracing = ["dep:tracing"] +# Use the platform TLS stack instead of rustls. +native-tls = ["reqwest/native-tls"] [dependencies] -reqwest = { version = "0.12", features = ["json", "blocking"] } -serde = { version = "1.0", features = ["derive"] } -serde_urlencoded = "0.7" -thiserror = "1.0" -regex = "1.11" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } # only for async use +reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "query", "charset", "http2", "rustls", "webpki-roots"] } +serde = { version = "1", features = ["derive"] } serde_json = "1" +thiserror = "2" +chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } +futures-core = { version = "0.3", optional = true } +bytes = { version = "1", optional = true } +sha2 = { version = "0.10", optional = true } +base64 = { version = "0.22", optional = true } +rand = { version = "0.9", optional = true } +tokio = { version = "1", default-features = false, features = ["sync"], optional = true } +tracing = { version = "0.1", optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1", default-features = false, features = ["time"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +gloo-timers = { version = "0.3", features = ["futures"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } +wiremock = "0.6" +futures-util = "0.3" + +[package.metadata.docs.rs] +all-features = true + +[[example]] +name = "sign_in_with_spoo" +required-features = ["oauth"] diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..570da68 --- /dev/null +++ b/deny.toml @@ -0,0 +1,27 @@ +[graph] +all-features = true + +[advisories] +yanked = "deny" + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Zlib", + "CDLA-Permissive-2.0", + "MPL-2.0", +] + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..ac2c346 --- /dev/null +++ b/openapi.json @@ -0,0 +1,14274 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "spoo.me", + "description": "REST API for spoo.me \u2014 free and open-source URL shortening service serving 400k+ redirects/day.\n\nAuthenticate using either:\n- **API Key**: `Authorization: Bearer spoo_`\n- **JWT Token**: `Authorization: Bearer ` (obtained via /auth/login)\n- **Session Cookie**: `access_token` cookie (set automatically on login)", + "contact": { + "name": "spoo.me", + "url": "https://spoo.me/contact", + "email": "support@spoo.me" + }, + "license": { + "name": "AGPL-3.0", + "url": "https://github.com/spoo-me/spoo/blob/main/LICENSE" + }, + "version": "1.0.0" + }, + "paths": { + "/health": { + "get": { + "tags": [ + "System" + ], + "summary": "Health Check", + "description": "Check the health of the application and its dependencies.\n\nPings MongoDB and Redis to determine overall system status:\n\n- **healthy** (200): Both MongoDB and Redis are reachable.\n- **degraded** (200): MongoDB is reachable but Redis is down or not configured.\n- **unhealthy** (503): MongoDB is unreachable -- the app cannot function.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: None", + "operationId": "healthCheck", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + }, + "503": { + "description": "Unhealthy \u2014 MongoDB is unreachable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + }, + "security": [] + } + }, + "/auth/login": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Login", + "description": "Authenticate with email and password.\n\nReturns JWT access token and sets secure HTTP-only cookies for both\naccess and refresh tokens. The refresh token can be used at\n``POST /auth/refresh`` to obtain new tokens without re-authenticating.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 5/min, 50/day\n\n**Security**: Returns identical error for wrong email and wrong password\nto prevent user enumeration.", + "operationId": "loginUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [] + } + }, + "/auth/register": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Register", + "description": "Create a new user account with email and password.\n\nImmediately signs the user in by returning a JWT access token and setting\nsecure HTTP-only cookies. A verification email is sent best-effort; the\n``verification_sent`` field indicates whether it succeeded.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 5/min, 50/day\n\n**Notes**: The account is created even if the verification email fails.\nThe user must verify their email before accessing protected resources.", + "operationId": "registerUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [] + } + }, + "/auth/refresh": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Refresh Tokens", + "description": "Rotate the access and refresh token pair.\n\nReads the ``refresh_token`` cookie, validates it, and issues a new\naccess/refresh pair. Both cookies are replaced. If the refresh token\nis missing, expired, or invalid, all auth cookies are cleared and a\n401 response is returned.\n\n**Authentication**: Requires a valid ``refresh_token`` cookie\n\n**Rate Limits**: 20/min\n\n**Notes**: The old refresh token is invalidated after use (rotation).", + "operationId": "refreshTokens", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [] + } + }, + "/auth/logout": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Logout", + "description": "Log the current user out by clearing auth cookies.\n\nRemoves the ``access_token`` and ``refresh_token`` HTTP-only cookies.\nAlways succeeds regardless of whether the user was authenticated.\n\n**Authentication**: Not required\n\n**Rate Limits**: 60/hour", + "operationId": "logout", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } + } + } + } + } + }, + "/auth/me": { + "get": { + "tags": [ + "Authentication" + ], + "summary": "Get Current User", + "description": "Return the authenticated user's full profile.\n\nIncludes email, verification status, linked OAuth providers, plan,\nand profile picture. Useful for populating the UI after login or\non page load.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 60/min", + "operationId": "getCurrentUser", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Authentication" + ], + "summary": "Update Current User", + "description": "Update the authenticated user's profile.\n\nCurrently only ``user_name`` is editable. Registration makes the\ndisplay name write-once, so accounts that skipped it need this to\nset one later. Send a string to set the name or ``null`` to clear it.\n\n**Authentication**: Required (JWT only \u2014 API keys cannot rename the account)\n\n**Rate Limits**: 10/min", + "operationId": "updateCurrentUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProfileRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/auth/set-password": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Set Password", + "description": "Set a password for an OAuth-only account.\n\nAllows users who signed up via OAuth to add a password so they can\nalso log in with email + password. Fails if the user already has a\npassword set.\n\n**Authentication**: Required (JWT only \u2014 API keys cannot set passwords)\n\n**Rate Limits**: 5/min", + "operationId": "setPassword", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/auth/send-verification": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Send Verification Email", + "description": "Send a 6-digit OTP verification code to the user's email.\n\nThe code expires after the duration returned in ``expires_in`` (seconds).\nReturns 400 if the user's email is already verified.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 1/minute, 3/hour\n\n**Notes**: Previous unused OTPs are invalidated when a new one is sent.", + "operationId": "sendVerification", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendVerificationResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/auth/verify-email": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Verify Email", + "description": "Verify the user's email address using a 6-digit OTP code.\n\nOn success, new JWT tokens are issued with ``email_verified=true`` in the\nclaims, and auth cookies are updated. A welcome email is sent best-effort.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 10/hour\n\n**Notes**: The OTP must match the most recently sent code and must not\nbe expired. Expired or already-used codes are rejected.", + "operationId": "verifyEmail", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyEmailRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyEmailResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/auth/request-password-reset": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Request Password Reset", + "description": "Request a password-reset OTP to be sent via email.\n\nAlways returns the same success response regardless of whether the\nemail is registered. This prevents user enumeration attacks.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 3/hour\n\n**Security**: Timing-safe -- response time is constant whether or not\nthe account exists.", + "operationId": "requestPasswordReset", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestPasswordResetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [] + } + }, + "/auth/reset-password": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Reset Password", + "description": "Reset the account password using a 6-digit OTP code.\n\nThe OTP must have been requested via ``POST /auth/request-password-reset``.\nOn success the password is updated immediately and the OTP is consumed.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 5/hour\n\n**Notes**: Expired or already-used OTPs are rejected with a 400 error.", + "operationId": "resetPassword", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [] + } + }, + "/auth/device/token": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Exchange Device Auth Code", + "description": "Exchange a one-time device auth code for JWT tokens.\n\nThe code is obtained from the callback page after the user authenticates\non spoo.me. The PKCE ``code_verifier`` must match the ``code_challenge``\nthe app sent when initiating the flow. Returns access and refresh tokens\nscoped to the app's grant.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 10/min", + "operationId": "exchangeDeviceCode", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceTokenResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [] + } + }, + "/auth/device/refresh": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Refresh Device Auth Tokens", + "description": "Refresh an app's JWT tokens using a refresh token.\n\nAccepts the refresh token in the request body (not cookies) for use\nby external apps (browser extensions, desktop, CLI, bots). If the\nrefresh token contains an ``app_id`` claim, the server verifies the\napp grant is still active \u2014 revoked apps cannot refresh \u2014 and re-reads\nthe grant's scopes so scope changes propagate on the next refresh.\n\n**Authentication**: Not required (the refresh token itself is the credential)\n\n**Rate Limits**: 20/min", + "operationId": "refreshDeviceTokens", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceRefreshRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceRefreshResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [] + } + }, + "/auth/onboarding": { + "get": { + "tags": [ + "Authentication" + ], + "summary": "Get Onboarding State", + "description": "Return the caller's onboarding resume pointer.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 60/min\n\n**Notes**: The pointer self-expires 24h after the last write. Empty\n(`step: null`) means nothing to resume \u2014 never started, expired, or\ncompleted. Whether the account has *finished* onboarding is\n`user.onboarded_at` on `/auth/me`, not part of this cache.", + "operationId": "getOnboardingState", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingStateResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "Authentication" + ], + "summary": "Update Onboarding State", + "description": "Persist the caller's resume pointer (refreshes the 24h TTL).\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 30/min\n\n**Notes**: Best-effort \u2014 if Redis is unavailable the response echoes\nthe submitted state without persisting it; clients keep their local\ncopy as the fallback source of truth.", + "operationId": "putOnboardingState", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingStateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingStateResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/auth/onboarding/complete": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Complete Onboarding", + "description": "Mark onboarding finished for this account.\n\nStamps `onboarded_at` on the user document (first completion wins \u2014\nrepeat calls are idempotent and keep the original timestamp), records\nthe optional HDYHAU answer, and drops the resume pointer.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 30/min", + "operationId": "completeOnboarding", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingCompleteResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/oauth/providers": { + "get": { + "tags": [ + "OAuth" + ], + "summary": "List OAuth Providers", + "description": "List all OAuth providers linked to the authenticated user's account.\n\nReturns each linked provider's name, email, and link date, plus whether\nthe user has a password set (needed by the UI to decide if unlinking\nthe last provider is allowed).\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 60/min", + "operationId": "listOAuthProviders", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthProvidersResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/oauth/providers/{provider_name}/unlink": { + "delete": { + "tags": [ + "OAuth" + ], + "summary": "Unlink OAuth Provider", + "description": "Remove an OAuth provider link from the authenticated user's account.\n\nFails if the provider is the user's only authentication method (i.e.,\nno password set and no other providers linked).\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 5/min", + "operationId": "unlinkOAuthProvider", + "parameters": [ + { + "name": "provider_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Provider Name" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/oauth/{provider}": { + "get": { + "tags": [ + "OAuth" + ], + "summary": "OAuth Login", + "description": "Initiate the OAuth authorization flow for the given provider.\n\nRedirects the user to the provider's consent screen (e.g., Google,\nGitHub). After the user grants access, the provider redirects back\nto ``GET /oauth/{provider}/callback``.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 10/min\n\n**Supported providers**: google, github (configurable)", + "operationId": "initiateOAuthLogin", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Provider" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [] + } + }, + "/oauth/{provider}/callback": { + "get": { + "tags": [ + "OAuth" + ], + "summary": "OAuth Callback", + "description": "Handle the OAuth provider callback after user authorization.\n\nValidates the CSRF state parameter, exchanges the authorization code for\nan access token, fetches the user's profile from the provider, and then\neither logs in an existing user or creates a new account. On success,\nredirects to ``/dashboard`` with JWT cookies set.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 20/min\n\n**Notes**: This endpoint is called by the OAuth provider, not directly\nby the client. The ``state`` query parameter is required for CSRF protection.", + "operationId": "oauthCallback", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Provider" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [] + } + }, + "/oauth/{provider}/link": { + "get": { + "tags": [ + "OAuth" + ], + "summary": "Link OAuth Provider", + "description": "Initiate an OAuth flow to link a provider to the authenticated account.\n\nSimilar to ``GET /oauth/{provider}`` but includes the user's ID in the\nstate token so the callback knows to link rather than log in. The user\nmust already be authenticated.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 5/min", + "operationId": "linkOAuthProvider", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Provider" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/shorten": { + "post": { + "tags": [ + "URL Shortening" + ], + "summary": "Create Shortened URL", + "description": "Create a new shortened URL.\n\nCreate a shortened URL with optional customization including password\nprotection, expiration, click limits, and bot blocking. Authenticated\nusers may target an owned, ACTIVE custom domain via the ``domain`` field.\n\n**Emoji aliases**: ``alias`` also accepts an emoji-only short code\n(e.g. ``\ud83d\ude80\ud83d\udd25``) \u2014 1-15 fully-qualified emoji; ZWJ sequences, flags,\nkeycaps, and text-style symbols are rejected (they render or copy\ninconsistently across platforms). Set ``alias_type: \"emoji\"`` to\nauto-generate an emoji code instead of an alphanumeric one. Stored\nand returned in canonical form (variation selectors stripped).\n\n**Authentication**: Optional \u2014 higher rate limits when authenticated.\nRequired if ``domain`` is supplied.\n\n**API Key Scope**: `shorten:create` or `admin:all`\n\n**Rate Limits**:\n\n- Authenticated: 60/min, 5,000/day\n- Anonymous: 20/min, 1,000/day\n\n**Anonymous Usage Consequences**:\n\n- Lower rate limits\n- Cannot manage or view URLs later\n- Cannot use private stats\n- URLs not linked to any account\n- Cannot use custom domains\n- Cannot use geo targeting\n- Cannot use custom meta tags", + "operationId": "shortenUrl", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUrlRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UrlResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + {}, + { + "ApiKeyAuth": [] + }, + { + "JWTAuth": [] + } + ] + } + }, + "/api/v1/shorten/check-alias": { + "get": { + "tags": [ + "URL Shortening" + ], + "summary": "Check Alias Availability", + "description": "Check whether a proposed alias would be accepted by POST /api/v1/shorten.\n\nReason codes on a negative result (``length``/``format``/``reserved``/\n``taken``/``emoji_policy``) let the UI render precise inline feedback\nwithout duplicating the validation rules. Emoji aliases are checked in\ncanonical form (variation selectors stripped).\n\nPass ``domain`` to scope the check to a custom-domain tenant \u2014 required\nfor the create modal's live availability indicator when the user has\npicked a non-default domain. Authz mirrors the shorten endpoint: anon\ncallers can't probe custom domains; authed callers must own the target.\n\n**Authentication**: Optional \u2014 higher rate limits when authenticated.\nRequired if ``domain`` is supplied.", + "operationId": "checkAliasAvailability", + "parameters": [ + { + "name": "alias", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Candidate alias to check (alphanumeric or emoji-only).", + "examples": [ + "mylink", + "\ud83d\ude80\ud83d\udd25" + ], + "title": "Alias" + }, + "description": "Candidate alias to check (alphanumeric or emoji-only)." + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 253 + }, + { + "type": "null" + } + ], + "description": "Scope the availability check to a custom domain fqdn. Requires authentication + ownership of an ACTIVE custom domain. Omit for the system default namespace.", + "examples": [ + "links.acme.com" + ], + "title": "Domain" + }, + "description": "Scope the availability check to a custom domain fqdn. Requires authentication + ownership of an ACTIVE custom domain. Omit for the system default namespace." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AliasCheckResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + {}, + { + "ApiKeyAuth": [] + }, + { + "JWTAuth": [] + } + ] + } + }, + "/api/v1/emoji-set": { + "get": { + "tags": [ + "URL Shortening" + ], + "summary": "Accepted Emoji Set", + "description": "Return the accepted emoji catalogue and its policy caps.\n\n``emoji`` lists every single-codepoint emoji a custom alias may use\n(the picker's list), ordered by canonical Unicode group and within-group\norder so a picker opens on Smileys rather than symbols. Each entry\ncarries ``c`` (the raw canonical character), ``n`` (a searchable name like\n\"rocket\"), ``g`` (its canonical Unicode category, e.g. \"Smileys &\nEmotion\", for category tabs), ``gen`` (whether it is in the server's\nauto-generation pool), and an optional ``k`` (extra search aliases when\nthe source lists any). Skin tone is a client-side modifier appended to a\nbase emoji, so skin-tone variants are not enumerated.\n\n**Authentication**: None. The response is identical for everyone.\n\n**Caching**: Fresh for a day, then served stale for a week while\nrevalidating. A content-derived ``ETag`` is returned; send it back as\n``If-None-Match`` to get a ``304`` when the set is unchanged.\n\n**Rate Limits**: 60/min, 2,000/day.", + "operationId": "getEmojiSet", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmojiSetResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [] + } + }, + "/api/v1/urls": { + "get": { + "tags": [ + "Link Management" + ], + "summary": "List Your URLs", + "description": "List all URLs owned by the authenticated user.\n\nReturns a paginated list of shortened URLs with support for filtering,\nsorting, and full-text search on aliases and destination URLs.\n\n**Authentication**: Required.\n\n**API Key Scope**: `urls:manage`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 60/min, 5,000/day\n\n**Pagination**: Use `page` and `pageSize` query params. Response includes\n`hasNext` boolean and `total` count.\n\n**Sorting**: Sort by `created_at`, `last_click`, or `total_clicks` in\nascending or descending order.\n\n**Filtering**: Pass a JSON-encoded `filter` parameter with fields like\n`status`, `createdAfter`, `createdBefore`, `passwordSet`, `maxClicksSet`,\nand `search`.", + "operationId": "listUrls", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "Page number (default: 1)", + "examples": [ + 1 + ], + "default": 1, + "title": "Page" + }, + "description": "Page number (default: 1)" + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Items per page (default: 20, max: 100)", + "examples": [ + 20 + ], + "default": 20, + "title": "Pagesize" + }, + "description": "Items per page (default: 20, max: 100)" + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "schema": { + "enum": [ + "created_at", + "last_click", + "total_clicks" + ], + "type": "string", + "description": "Field to sort by", + "default": "created_at", + "title": "Sortby" + }, + "description": "Field to sort by" + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "schema": { + "enum": [ + "ascending", + "asc", + "1", + "descending", + "desc", + "-1" + ], + "type": "string", + "description": "Sort direction", + "default": "descending", + "title": "Sortorder" + }, + "description": "Sort direction" + }, + { + "name": "filter", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "description": "JSON string containing filter criteria for URLs. Format: `{\"field\": value}`\n\n**Available filter fields:**\n\n- **status** \u2014 Filter by URL status (`\"ACTIVE\"` or `\"INACTIVE\"`)\n- **createdAfter** \u2014 Filter URLs created after this date (ISO 8601 datetime or Unix timestamp)\n- **createdBefore** \u2014 Filter URLs created before this date (ISO 8601 datetime or Unix timestamp)\n- **passwordSet** \u2014 Filter by password protection (boolean: `true`/`false`)\n- **maxClicksSet** \u2014 Filter by click limit presence (boolean: `true`/`false`)\n- **search** \u2014 Search in alias or long_url (case-insensitive string)\n\n**Value formats:**\n\n- **status**: String \u2014 `\"ACTIVE\"` or `\"INACTIVE\"` (case-sensitive)\n- **createdAfter / createdBefore**: ISO 8601 datetime string (e.g., `\"2024-01-01T00:00:00Z\"`) or Unix timestamp (e.g., `1704067200`)\n- **passwordSet / maxClicksSet**: Boolean \u2014 `true` or `false`\n- **search**: String \u2014 case-insensitive search term\n\n**Examples:**\n\n- `{\"status\": \"ACTIVE\"}` \u2014 Only active URLs\n- `{\"passwordSet\": true}` \u2014 Only password-protected URLs\n- `{\"createdAfter\": \"2024-01-01T00:00:00Z\"}` \u2014 URLs created after Jan 1, 2024\n- `{\"status\": \"ACTIVE\", \"maxClicksSet\": true}` \u2014 Active URLs with click limits\n- `{\"search\": \"example\"}` \u2014 URLs containing \"example\" in alias or long_url\n- `{\"createdAfter\": \"2024-01-01\", \"createdBefore\": \"2024-12-31\", \"status\": \"ACTIVE\"}` \u2014 Active URLs from 2024", + "examples": [ + "{\"status\":\"ACTIVE\"}", + "{\"passwordSet\": true}", + "{\"createdAfter\": \"2024-01-01T00:00:00Z\"}", + "{\"status\": \"ACTIVE\", \"maxClicksSet\": true}", + "{\"search\": \"example\"}", + "{\"createdAfter\": \"2024-01-01\", \"createdBefore\": \"2024-12-31\", \"status\": \"ACTIVE\"}" + ], + "title": "Filter" + }, + "description": "JSON string containing filter criteria for URLs. Format: `{\"field\": value}`\n\n**Available filter fields:**\n\n- **status** \u2014 Filter by URL status (`\"ACTIVE\"` or `\"INACTIVE\"`)\n- **createdAfter** \u2014 Filter URLs created after this date (ISO 8601 datetime or Unix timestamp)\n- **createdBefore** \u2014 Filter URLs created before this date (ISO 8601 datetime or Unix timestamp)\n- **passwordSet** \u2014 Filter by password protection (boolean: `true`/`false`)\n- **maxClicksSet** \u2014 Filter by click limit presence (boolean: `true`/`false`)\n- **search** \u2014 Search in alias or long_url (case-insensitive string)\n\n**Value formats:**\n\n- **status**: String \u2014 `\"ACTIVE\"` or `\"INACTIVE\"` (case-sensitive)\n- **createdAfter / createdBefore**: ISO 8601 datetime string (e.g., `\"2024-01-01T00:00:00Z\"`) or Unix timestamp (e.g., `1704067200`)\n- **passwordSet / maxClicksSet**: Boolean \u2014 `true` or `false`\n- **search**: String \u2014 case-insensitive search term\n\n**Examples:**\n\n- `{\"status\": \"ACTIVE\"}` \u2014 Only active URLs\n- `{\"passwordSet\": true}` \u2014 Only password-protected URLs\n- `{\"createdAfter\": \"2024-01-01T00:00:00Z\"}` \u2014 URLs created after Jan 1, 2024\n- `{\"status\": \"ACTIVE\", \"maxClicksSet\": true}` \u2014 Active URLs with click limits\n- `{\"search\": \"example\"}` \u2014 URLs containing \"example\" in alias or long_url\n- `{\"createdAfter\": \"2024-01-01\", \"createdBefore\": \"2024-12-31\", \"status\": \"ACTIVE\"}` \u2014 Active URLs from 2024" + }, + { + "name": "filterBy", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "description": "Alias for filter parameter.", + "title": "Filterby" + }, + "description": "Alias for filter parameter." + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 253 + }, + { + "type": "null" + } + ], + "description": "Filter URLs by exact custom domain fqdn.", + "examples": [ + "links.acme.com" + ], + "title": "Domain" + }, + "description": "Filter URLs by exact custom domain fqdn." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UrlListResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Link Management" + ], + "summary": "Bulk Delete URLs on a Custom Domain", + "description": "Bulk-delete all URLs the caller owns on the given custom domain.\n\n**Authentication**: Required.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 5/min, 50/day.\n\n**Restrictions**: refuses the system-default domain \u2014 protects against\naccidental nuke of the user's entire spoo.me URL inventory. Caller must\nown the domain.", + "operationId": "bulkDeleteUrls", + "parameters": [ + { + "name": "domain", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "description": "Custom domain fqdn whose URLs should be deleted.", + "examples": [ + "links.acme.com" + ], + "title": "Domain" + }, + "description": "Custom domain fqdn whose URLs should be deleted." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkDeleteUrlsResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/urls/{url_id}": { + "get": { + "tags": [ + "Link Management" + ], + "summary": "Get URL by ID", + "description": "Fetch a single URL you own by its id.\n\nReturns the same item shape as one element of `GET /urls` \u2014 including\nderived `status`, so expired/disabled/blocked links return normally\nwith their status field (you are reading your inventory, not following\na redirect).\n\n**Authentication**: Required \u2014 you must own the URL.\n\n**API Key Scope**: `urls:manage`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 60/min, 5,000/day\n\n**Errors**:\n\n- `400` \u2014 malformed id (not a valid ObjectId)\n- `404` \u2014 no URL with that id in your account. A URL owned by someone\n else answers identically; this endpoint never confirms foreign ids.", + "operationId": "getUrl", + "parameters": [ + { + "name": "url_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Unique identifier of the URL (MongoDB ObjectId).", + "title": "Url Id" + }, + "description": "Unique identifier of the URL (MongoDB ObjectId)." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UrlListItem" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Link Management" + ], + "summary": "Update URL", + "description": "Update an existing URL's properties.\n\nPartially update a shortened URL. Only provided fields are modified; omitted\nfields remain unchanged. Pass `null` to remove optional settings like\n`password`, `max_clicks`, or `expire_after`.\n\n**Authentication**: Required \u2014 you must own the URL.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 120/min, 2,000/day\n\n**Updatable Fields**: `long_url`, `alias`, `password`, `block_bots`,\n`max_clicks`, `expire_after`, `private_stats`, `status`, `domain`,\n`geo_rules`, `meta_tags`\n\n**Notes**:\n\n- Setting `max_clicks` to `0` or `null` removes the click limit\n- Changing the `alias` checks availability and may fail with 409 Conflict\n- Setting `domain` moves the URL to a different tenant; caller must own\n the target as an ACTIVE custom domain, or pass `null` to move back to\n the system default. Alias collision is verified on the target.\n- `geo_rules` replaces the whole map; pass `null` or `{}` to remove all\n rules\n- The `url_id` is the MongoDB ObjectId, not the alias", + "operationId": "updateUrl", + "parameters": [ + { + "name": "url_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9a-f]{24}$", + "description": "Unique identifier of the URL", + "title": "Url Id" + }, + "description": "Unique identifier of the URL" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUrlRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUrlResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Link Management" + ], + "summary": "Delete URL", + "description": "Delete a URL permanently.\n\n**This action is IRREVERSIBLE.** The URL, its alias, and all associated\nclick analytics data will be permanently deleted. The alias may be reclaimed\nby another user afterward.\n\n**Authentication**: Required \u2014 you must own the URL.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 60/min, 1,000/day\n\n**Recommendation**: Consider setting the URL status to `INACTIVE` via\n`PATCH /urls/{url_id}/status` instead if you may want to restore it later.", + "operationId": "deleteUrl", + "parameters": [ + { + "name": "url_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9a-f]{24}$", + "description": "Unique identifier of the URL", + "title": "Url Id" + }, + "description": "Unique identifier of the URL" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteUrlResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/urls/{domain}/{alias}": { + "get": { + "tags": [ + "Link Management" + ], + "summary": "Get URL by Domain and Alias", + "description": "Fetch a single URL you own by its natural key: domain + alias.\n\nThe address every short link is already known by \u2014 no id lookup\nround-trip needed. Returns the same item shape as one element of\n`GET /urls`, status-blind for the owner (expired/disabled/blocked\nlinks return with their status field).\n\n**Authentication**: Required \u2014 you must own the URL.\n\n**API Key Scope**: `urls:manage`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 60/min, 5,000/day\n\n**Errors**:\n\n- `404` \u2014 no URL at that address in your account. Unknown domains and\n links owned by someone else answer identically; this endpoint never\n confirms what exists outside your account.\n\n**Note**: only current-generation links resolve here \u2014 that is all the\nmanaged collection holds.", + "operationId": "getUrlByAddress", + "parameters": [ + { + "name": "domain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 253, + "description": "Domain the short link lives on. Always explicit: pass the system domain (e.g. `spoo.me`) for default-namespace links or an owned custom domain fqdn. Case and `:port` are ignored.", + "examples": [ + "spoo.me", + "links.acme.com" + ], + "title": "Domain" + }, + "description": "Domain the short link lives on. Always explicit: pass the system domain (e.g. `spoo.me`) for default-namespace links or an owned custom domain fqdn. Case and `:port` are ignored." + }, + { + "name": "alias", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Short code of the URL. Emoji aliases arrive percent-encoded.", + "examples": [ + "mylink" + ], + "title": "Alias" + }, + "description": "Short code of the URL. Emoji aliases arrive percent-encoded." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UrlListItem" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/urls/bulk/delete": { + "post": { + "tags": [ + "Link Management" + ], + "summary": "Bulk Delete URLs", + "description": "Permanently delete up to 100 URLs you own, addressed by id.\n\n**This action is irreversible.** Deleted aliases become available\nfor anyone to claim. Historical analytics are not removed.\n\nNot to be confused with `DELETE /api/v1/urls?domain=` (bulk delete\nby custom domain): that operation answers \"everything on this\ndomain\", this one answers \"exactly these links\".\n\n**Per-item verdicts** (`error_code`): `not_found` \u2014 no such URL in\nyour account (an id you don't own answers the same); `forbidden` \u2014\nthe URL is admin-blocked and cannot be deleted.\n\n**Retry semantics**: re-sending the batch after a timeout is safe;\nalready-deleted ids report `not_found`, which a client should treat\nas success-equivalent for delete.\n\n**Rate Limits**: 30/min, 100/day \u2014 counted per request, not per id.", + "operationId": "bulkDeleteUrlsByIds", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkDeleteUrlsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUrlOperationResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/urls/bulk/status": { + "post": { + "tags": [ + "Link Management" + ], + "summary": "Bulk Update URL Status", + "description": "Activate or deactivate up to 100 URLs you own in one request.\n\nSemantics match the single-item status endpoint exactly: `ACTIVE`\nenables redirects, `INACTIVE` disables them, `EXPIRED` URLs set to\n`ACTIVE` reactivate, and setting the status a link already has is a\nsuccess no-op.\n\n**Per-item verdicts** (`error_code`): `not_found` \u2014 no such URL in\nyour account; `forbidden` \u2014 the URL is admin-blocked and cannot be\nmodified.\n\n**Rate Limits**: 60/min, 200/day \u2014 counted per request, not per id.", + "operationId": "bulkUpdateUrlStatus", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUpdateStatusRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUrlOperationResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/urls/bulk/expiry": { + "post": { + "tags": [ + "Link Management" + ], + "summary": "Bulk Update URL Expiry", + "description": "Set or clear the expiration on up to 100 URLs you own.\n\nOne value for the whole batch \u2014 ISO 8601 or epoch seconds, must be\nin the future (a past value rejects the whole request; it could\nnever be right for any item). `null` clears expiry. `EXPIRED` URLs\nwhose expiry is extended or cleared reactivate, exactly like the\nsingle-item update.\n\n**Per-item verdicts** (`error_code`): `not_found` \u2014 no such URL in\nyour account; `forbidden` \u2014 the URL is admin-blocked and cannot be\nmodified.\n\n**Rate Limits**: 60/min, 200/day \u2014 counted per request, not per id.", + "operationId": "bulkUpdateUrlExpiry", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUpdateExpiryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUrlOperationResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/urls/bulk/domain": { + "post": { + "tags": [ + "Link Management" + ], + "summary": "Bulk Move URL Domain", + "description": "Move up to 100 URLs you own to a different domain in one request.\n\nOne target for the whole batch: a custom domain you own (it must be\nverified and ACTIVE \u2014 an unusable target rejects the whole request,\nsince it could never be right for any item), or `null` to move back\nto the system default.\n\nSemantics match the single-item domain update exactly, per item:\nalready on the target is a success no-op, a reserved alias cannot\nmove onto the system default, and the alias must be free on the\ntarget namespace. Within one batch, items are processed in request\norder \u2014 two links with the same alias moving to one target means the\nfirst wins and the second reports `conflict`, deterministically.\n\n**Per-item verdicts** (`error_code`): `not_found` \u2014 no such URL in\nyour account; `forbidden` \u2014 the URL is admin-blocked; `conflict` \u2014\nalias already taken on the target; `validation_error` \u2014 reserved\nalias on the system default.\n\n**Retry semantics**: re-sending the batch is safe \u2014 items that\nalready moved report success no-ops.\n\n**Rate Limits**: 60/min, 200/day \u2014 counted per request, not per id.", + "operationId": "bulkUpdateUrlDomain", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkMoveDomainRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUrlOperationResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/urls/claim": { + "post": { + "tags": [ + "Link Management" + ], + "summary": "Claim anonymous URLs", + "description": "Claim anonymously-created URLs into your account.\n\nEach item pairs a URL id with the one-time `claim_token` returned by\nthe anonymous shorten call \u2014 the bearer proof of creation. Items\nresolve independently and the batch never hard-fails: `claimed`\n(ownership transferred, token burned), `already_yours` (idempotent\nrepeat), or `invalid` (unknown id, wrong token, or a link that is not\nclaimable \u2014 deliberately indistinguishable).\n\nThe claim_token is single-use and is invalidated immediately on\nsuccess. Claimed links join the account like any owned link:\ndashboard, management, and full stats history included.\n\n**Authentication**: Required.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 30/min, 500/day", + "operationId": "claimUrls", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimUrlsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimUrlsResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/urls/{url_id}/status": { + "patch": { + "tags": [ + "Link Management" + ], + "summary": "Update URL Status", + "description": "Update only the status of a URL (ACTIVE / INACTIVE).\n\nToggle a URL between active and inactive without modifying other properties.\n\n**Authentication**: Required \u2014 you must own the URL.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 120/min, 2,000/day\n\n**Status Values** (user-editable via this endpoint):\n\n- `ACTIVE` \u2014 URL is accessible and redirects normally\n- `INACTIVE` \u2014 URL is disabled and returns an error page\n\n**Note**: `BLOCKED` is an admin-set status \u2014 blocked URLs cannot be modified\nor deleted by the owner. `EXPIRED` URLs (auto-set on max clicks or expiry\ntime) can be reactivated by setting status back to `ACTIVE` \u2014 but only\nafter the expiry condition is lifted (raise/clear `max_clicks`, extend/\nclear `expire_after` via PATCH), otherwise the URL immediately reads\nas expired again.\n\n**Use Cases**:\n\n- Set `INACTIVE` to temporarily disable redirects without deleting the URL\n- Set `ACTIVE` to re-enable a previously disabled URL", + "operationId": "updateUrlStatus", + "parameters": [ + { + "name": "url_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9a-f]{24}$", + "description": "Unique identifier of the URL", + "title": "Url Id" + }, + "description": "Unique identifier of the URL" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUrlStatusRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUrlResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stats": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "URL Statistics", + "description": "Get aggregated click statistics across all URLs you own.\n\nRetrieve click analytics with flexible grouping, filtering, and\ntime-range options.\n\n**Authentication**: Required.\n\n**API Key Scope**: `stats:read`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 60/min, 5,000/day\n\n**Grouping Dimensions**: `time`, `browser`, `os`, `device`, `country`,\n`city`, `referrer`, `short_code`, `utm_source`, `utm_medium`,\n`utm_campaign`\n\n**Metrics**: `clicks`, `unique_clicks`\n\n**Filtering**: Filter by `browser`, `os`, `device`, `country`, `city`,\n`referrer`, `short_code`, `url_id`, or the `utm_*` tags using query\nparams or a JSON `filters` object. Filters slice your own aggregate \u2014\n`url_id` values you do not own simply match nothing. For statistics on\na single link, prefer `GET /stats/links/{url_id}`.", + "operationId": "getStats", + "parameters": [ + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to 7 days before `end_date`.", + "examples": [ + "2025-01-01T00:00:00Z" + ], + "title": "Start Date" + }, + "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to 7 days before `end_date`." + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now.", + "examples": [ + "2025-12-31T23:59:59Z" + ], + "title": "End Date" + }, + "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now." + }, + { + "name": "group_by", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `device` \u2014 group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `short_code` \u2014 group by URL alias\n- `utm_source` \u2014 group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` \u2014 group by the `utm_medium` tag\n- `utm_campaign` \u2014 group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser.", + "examples": [ + "time,browser", + "country", + "time,country,browser" + ], + "title": "Group By" + }, + "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `device` \u2014 group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `short_code` \u2014 group by URL alias\n- `utm_source` \u2014 group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` \u2014 group by the `utm_medium` tag\n- `utm_campaign` \u2014 group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser." + }, + { + "name": "metrics", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)", + "examples": [ + "clicks,unique_clicks", + "clicks" + ], + "title": "Metrics" + }, + "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)" + }, + { + "name": "timezone", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 50, + "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`.", + "examples": [ + "UTC", + "America/New_York" + ], + "default": "UTC", + "title": "Timezone" + }, + "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`." + }, + { + "name": "filters", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 5000 + }, + { + "type": "null" + } + ], + "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` \u2014 Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` \u2014 Filter by URL alias (e.g., mylink, promo2024)\n- `url_id` \u2014 Filter by URL id (MongoDB ObjectId); ids you do not own match nothing\n- `utm_source` / `utm_medium` / `utm_campaign` \u2014 Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` \u2014 Stats for specific URLs\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below).", + "examples": [ + "{\"browser\":[\"Chrome\",\"Firefox\"]}", + "{\"country\":[\"United States\",\"Canada\"],\"browser\":[\"Chrome\"]}" + ], + "title": "Filters" + }, + "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` \u2014 Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` \u2014 Filter by URL alias (e.g., mylink, promo2024)\n- `url_id` \u2014 Filter by URL id (MongoDB ObjectId); ids you do not own match nothing\n- `utm_source` / `utm_medium` / `utm_campaign` \u2014 Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` \u2014 Stats for specific URLs\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below)." + }, + { + "name": "browser", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "Chrome,Firefox" + ], + "title": "Browser" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "os", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "Windows,macOS" + ], + "title": "Os" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "device", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "mobile,desktop" + ], + "title": "Device" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "country", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "United States,Germany" + ], + "title": "Country" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "city", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "San Francisco,Berlin" + ], + "title": "City" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "referrer", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "https://google.com,https://twitter.com" + ], + "title": "Referrer" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_source", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "newsletter,twitter" + ], + "title": "Utm Source" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_medium", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "email,social" + ], + "title": "Utm Medium" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_campaign", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "summer-launch" + ], + "title": "Utm Campaign" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "short_code", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "Comma-separated URL aliases to filter stats to specific URLs you own. Slices your own aggregate \u2014 aliases you do not own simply match nothing.\n\nFor statistics on a single link, prefer `GET /api/v1/stats/links/{url_id}`.", + "examples": [ + "mylink" + ], + "title": "Short Code" + }, + "description": "Comma-separated URL aliases to filter stats to specific URLs you own. Slices your own aggregate \u2014 aliases you do not own simply match nothing.\n\nFor statistics on a single link, prefer `GET /api/v1/stats/links/{url_id}`." + }, + { + "name": "url_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "Comma-separated URL ids (MongoDB ObjectIds) to filter stats to specific URLs you own. Slices your own aggregate \u2014 ids you do not own simply match nothing.\n\nFor statistics on a single link, prefer `GET /api/v1/stats/links/{url_id}`.", + "examples": [ + "686cbf34cc37ed6bbcd82ab9" + ], + "title": "Url Id" + }, + "description": "Comma-separated URL ids (MongoDB ObjectIds) to filter stats to specific URLs you own. Slices your own aggregate \u2014 ids you do not own simply match nothing.\n\nFor statistics on a single link, prefer `GET /api/v1/stats/links/{url_id}`." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatsResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stats/links/{url_id}": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Link Statistics", + "description": "Get click statistics for a single URL you own.\n\nThe same aggregated analytics as `GET /stats`, pre-scoped to one link \u2014\nthe response additionally echoes the link's `url_id` and `alias`.\nCustom-domain links are safe here: clicks are matched by URL id, so a\nsame-alias link on another domain can never bleed in.\n\n**Authentication**: Required \u2014 you must own the URL.\n\n**API Key Scope**: `stats:read`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 60/min, 5,000/day\n\n**Grouping Dimensions**: `time`, `browser`, `os`, `device`, `country`,\n`city`, `referrer`, `utm_source`, `utm_medium`, `utm_campaign`\n\n**Metrics**: `clicks`, `unique_clicks`\n\n**Filtering**: Filter by `browser`, `os`, `device`, `country`, `city`,\n`referrer`, or the `utm_*` tags using query params or a JSON `filters`\nobject. Link-identity filters (`short_code`, `url_id`) do not exist\nhere \u2014 the path already selects the link.\n\n**Errors**:\n\n- `400` \u2014 malformed id (not a valid ObjectId)\n- `404` \u2014 no URL with that id in your account. A URL owned by someone\n else answers identically; this endpoint never confirms foreign ids.", + "operationId": "getLinkStats", + "parameters": [ + { + "name": "url_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Unique identifier of the URL (MongoDB ObjectId).", + "title": "Url Id" + }, + "description": "Unique identifier of the URL (MongoDB ObjectId)." + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to 7 days before `end_date`.", + "examples": [ + "2025-01-01T00:00:00Z" + ], + "title": "Start Date" + }, + "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to 7 days before `end_date`." + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now.", + "examples": [ + "2025-12-31T23:59:59Z" + ], + "title": "End Date" + }, + "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now." + }, + { + "name": "group_by", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `device` \u2014 group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `utm_source` \u2014 group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` \u2014 group by the `utm_medium` tag\n- `utm_campaign` \u2014 group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser.", + "examples": [ + "time,browser", + "country", + "time,country,browser" + ], + "title": "Group By" + }, + "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `device` \u2014 group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `utm_source` \u2014 group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` \u2014 group by the `utm_medium` tag\n- `utm_campaign` \u2014 group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser." + }, + { + "name": "metrics", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)", + "examples": [ + "clicks,unique_clicks", + "clicks" + ], + "title": "Metrics" + }, + "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)" + }, + { + "name": "timezone", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 50, + "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`.", + "examples": [ + "UTC", + "America/New_York" + ], + "default": "UTC", + "title": "Timezone" + }, + "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`." + }, + { + "name": "filters", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 5000 + }, + { + "type": "null" + } + ], + "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` \u2014 Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `utm_source` / `utm_medium` / `utm_campaign` \u2014 Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below).", + "examples": [ + "{\"browser\":[\"Chrome\",\"Firefox\"]}", + "{\"country\":[\"United States\",\"Canada\"],\"browser\":[\"Chrome\"]}" + ], + "title": "Filters" + }, + "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` \u2014 Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `utm_source` / `utm_medium` / `utm_campaign` \u2014 Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below)." + }, + { + "name": "browser", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "Chrome,Firefox" + ], + "title": "Browser" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "os", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "Windows,macOS" + ], + "title": "Os" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "device", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "mobile,desktop" + ], + "title": "Device" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "country", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "United States,Germany" + ], + "title": "Country" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "city", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "San Francisco,Berlin" + ], + "title": "City" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "referrer", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "https://google.com,https://twitter.com" + ], + "title": "Referrer" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_source", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "newsletter,twitter" + ], + "title": "Utm Source" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_medium", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "email,social" + ], + "title": "Utm Medium" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_campaign", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "summer-launch" + ], + "title": "Utm Campaign" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkStatsResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/public/stats/{short_code}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Public URL Statistics", + "description": "Get public click statistics for a single short link.\n\nResolves both URL generations (plus emoji aliases) on the system\ndefault domain and returns link facts plus the standard stats wire.\n\n**Authentication**: Optional \u2014 an owner session additionally sees\nprivate-stats links and skips the password gate.\n\n**Privacy**: A link with private stats answers exactly like a missing\ncode (404, byte-identical). Password-protected links answer 401\n`password_required`; send the password via POST \u2014 never in the URL.\n\n**Rate Limits**:\n\n- Authenticated: 60/min, 2,000/day\n- Anonymous: 20/min, 500/day", + "operationId": "getPublicStats", + "parameters": [ + { + "name": "short_code", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Short Code" + } + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "Range start (ISO 8601). Defaults to 7 days before end_date.", + "examples": [ + "2025-01-01T00:00:00Z" + ], + "title": "Start Date" + }, + "description": "Range start (ISO 8601). Defaults to 7 days before end_date." + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "Range end (ISO 8601). Defaults to now.", + "examples": [ + "2025-12-31T23:59:59Z" + ], + "title": "End Date" + }, + "description": "Range end (ISO 8601). Defaults to now." + }, + { + "name": "timezone", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 50, + "description": "IANA timezone for time bucketing and formatting.", + "examples": [ + "UTC", + "America/New_York" + ], + "default": "UTC", + "title": "Timezone" + }, + "description": "IANA timezone for time bucketing and formatting." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicStatsResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + {}, + { + "ApiKeyAuth": [] + }, + { + "JWTAuth": [] + } + ] + }, + "post": { + "tags": [ + "Public" + ], + "summary": "Public URL Statistics (password unlock)", + "description": "Same as the GET variant, carrying a password in the JSON body.\n\nThe body is the ONLY way a password travels to this endpoint \u2014\nquery-string passwords are ignored so they can't land in URLs, logs,\nor referrers. Wrong passwords answer 401 `invalid_password`\n(retryable). The body may be absent or empty.", + "operationId": "getPublicStatsWithPassword", + "parameters": [ + { + "name": "short_code", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Short Code" + } + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "Range start (ISO 8601). Defaults to 7 days before end_date.", + "examples": [ + "2025-01-01T00:00:00Z" + ], + "title": "Start Date" + }, + "description": "Range start (ISO 8601). Defaults to 7 days before end_date." + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "Range end (ISO 8601). Defaults to now.", + "examples": [ + "2025-12-31T23:59:59Z" + ], + "title": "End Date" + }, + "description": "Range end (ISO 8601). Defaults to now." + }, + { + "name": "timezone", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 50, + "description": "IANA timezone for time bucketing and formatting.", + "examples": [ + "UTC", + "America/New_York" + ], + "default": "UTC", + "title": "Timezone" + }, + "description": "IANA timezone for time bucketing and formatting." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PublicStatsBody" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicStatsResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + {}, + { + "ApiKeyAuth": [] + }, + { + "JWTAuth": [] + } + ] + } + }, + "/api/v1/export": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Export Statistics", + "description": "Export click statistics across all URLs you own as a downloadable file.\n\nGenerate a file export of click analytics data in the specified format.\nThe response is a binary download with appropriate `Content-Disposition` header.\n\n**Authentication**: Required.\n\n**API Key Scope**: `stats:read`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 30/min, 1,000/day\n\n**Export Formats**:\n\n- `json` \u2014 single JSON file\n- `xml` \u2014 single XML file\n- `xlsx` \u2014 Excel spreadsheet with multiple sheets\n- `csv` \u2014 **ZIP archive** containing `summary.csv` plus one CSV file per metrics dimension\n\n**Filtering**: Same as `GET /stats` \u2014 including the `url_id` filter to\nslice the export to specific URLs you own. To export a single link,\nprefer `GET /export/links/{url_id}`.\n\n**Note**: Export generation is resource-intensive. Lower rate limits apply\ncompared to other endpoints.", + "operationId": "exportStats", + "parameters": [ + { + "name": "format", + "in": "query", + "required": true, + "schema": { + "enum": [ + "csv", + "xlsx", + "json", + "xml" + ], + "type": "string", + "description": "Export file format.", + "title": "Format" + }, + "description": "Export file format." + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to 7 days before `end_date`.", + "examples": [ + "2025-01-01T00:00:00Z" + ], + "title": "Start Date" + }, + "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to 7 days before `end_date`." + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now.", + "examples": [ + "2025-12-31T23:59:59Z" + ], + "title": "End Date" + }, + "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now." + }, + { + "name": "group_by", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `device` \u2014 group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `short_code` \u2014 group by URL alias\n- `utm_source` \u2014 group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` \u2014 group by the `utm_medium` tag\n- `utm_campaign` \u2014 group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser.", + "examples": [ + "time,browser", + "country", + "time,country,browser" + ], + "title": "Group By" + }, + "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `device` \u2014 group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `short_code` \u2014 group by URL alias\n- `utm_source` \u2014 group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` \u2014 group by the `utm_medium` tag\n- `utm_campaign` \u2014 group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser." + }, + { + "name": "metrics", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)", + "examples": [ + "clicks,unique_clicks", + "clicks" + ], + "title": "Metrics" + }, + "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)" + }, + { + "name": "timezone", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 50, + "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`.", + "examples": [ + "UTC", + "America/New_York" + ], + "default": "UTC", + "title": "Timezone" + }, + "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`." + }, + { + "name": "filters", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 5000 + }, + { + "type": "null" + } + ], + "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` \u2014 Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` \u2014 Filter by URL alias (e.g., mylink, promo2024)\n- `url_id` \u2014 Filter by URL id (MongoDB ObjectId); ids you do not own match nothing\n- `utm_source` / `utm_medium` / `utm_campaign` \u2014 Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` \u2014 Stats for specific URLs\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below).", + "examples": [ + "{\"browser\":[\"Chrome\",\"Firefox\"]}", + "{\"country\":[\"United States\",\"Canada\"],\"browser\":[\"Chrome\"]}" + ], + "title": "Filters" + }, + "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` \u2014 Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` \u2014 Filter by URL alias (e.g., mylink, promo2024)\n- `url_id` \u2014 Filter by URL id (MongoDB ObjectId); ids you do not own match nothing\n- `utm_source` / `utm_medium` / `utm_campaign` \u2014 Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` \u2014 Stats for specific URLs\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below)." + }, + { + "name": "browser", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "Chrome,Firefox" + ], + "title": "Browser" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "os", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "Windows,macOS" + ], + "title": "Os" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "device", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "mobile,desktop" + ], + "title": "Device" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "country", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "United States,Germany" + ], + "title": "Country" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "city", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "San Francisco,Berlin" + ], + "title": "City" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "referrer", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "https://google.com,https://twitter.com" + ], + "title": "Referrer" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_source", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "newsletter,twitter" + ], + "title": "Utm Source" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_medium", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "email,social" + ], + "title": "Utm Medium" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_campaign", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "summer-launch" + ], + "title": "Utm Campaign" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "short_code", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "Comma-separated URL aliases to filter stats to specific URLs you own. Slices your own aggregate \u2014 aliases you do not own simply match nothing.\n\nFor statistics on a single link, prefer `GET /api/v1/stats/links/{url_id}`.", + "examples": [ + "mylink" + ], + "title": "Short Code" + }, + "description": "Comma-separated URL aliases to filter stats to specific URLs you own. Slices your own aggregate \u2014 aliases you do not own simply match nothing.\n\nFor statistics on a single link, prefer `GET /api/v1/stats/links/{url_id}`." + }, + { + "name": "url_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "Comma-separated URL ids (MongoDB ObjectIds) to filter stats to specific URLs you own. Slices your own aggregate \u2014 ids you do not own simply match nothing.\n\nFor statistics on a single link, prefer `GET /api/v1/stats/links/{url_id}`.", + "examples": [ + "686cbf34cc37ed6bbcd82ab9" + ], + "title": "Url Id" + }, + "description": "Comma-separated URL ids (MongoDB ObjectIds) to filter stats to specific URLs you own. Slices your own aggregate \u2014 ids you do not own simply match nothing.\n\nFor statistics on a single link, prefer `GET /api/v1/stats/links/{url_id}`." + } + ], + "responses": { + "200": { + "description": "Export file download", + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/xml": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + }, + "x-description": "CSV export \u2014 ZIP archive containing summary.csv plus one file per dimension" + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" + }, + "x-description": "XLSX export \u2014 Excel workbook with multiple sheets" + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error \u2014 export generation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/export/links/{url_id}": { + "get": { + "tags": [ + "Statistics" + ], + "summary": "Export Link Statistics", + "description": "Export click statistics for a single URL you own.\n\nThe export twin of `GET /stats/links/{url_id}` \u2014 the same formats as\n`GET /export`, pre-scoped to one link. The suggested filename carries\nthe link's alias.\n\n**Authentication**: Required \u2014 you must own the URL.\n\n**API Key Scope**: `stats:read`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 30/min, 1,000/day\n\n**Export Formats**:\n\n- `json` \u2014 single JSON file\n- `xml` \u2014 single XML file\n- `xlsx` \u2014 Excel spreadsheet with multiple sheets\n- `csv` \u2014 **ZIP archive** containing `summary.csv` plus one CSV file per metrics dimension\n\n**Errors**:\n\n- `400` \u2014 malformed id (not a valid ObjectId)\n- `404` \u2014 no URL with that id in your account. A URL owned by someone\n else answers identically; this endpoint never confirms foreign ids.\n\n**Note**: Export generation is resource-intensive. Lower rate limits apply\ncompared to other endpoints.", + "operationId": "exportLinkStats", + "parameters": [ + { + "name": "url_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Unique identifier of the URL (MongoDB ObjectId).", + "title": "Url Id" + }, + "description": "Unique identifier of the URL (MongoDB ObjectId)." + }, + { + "name": "format", + "in": "query", + "required": true, + "schema": { + "enum": [ + "csv", + "xlsx", + "json", + "xml" + ], + "type": "string", + "description": "Export file format.", + "title": "Format" + }, + "description": "Export file format." + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to 7 days before `end_date`.", + "examples": [ + "2025-01-01T00:00:00Z" + ], + "title": "Start Date" + }, + "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to 7 days before `end_date`." + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now.", + "examples": [ + "2025-12-31T23:59:59Z" + ], + "title": "End Date" + }, + "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now." + }, + { + "name": "group_by", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `device` \u2014 group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `utm_source` \u2014 group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` \u2014 group by the `utm_medium` tag\n- `utm_campaign` \u2014 group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser.", + "examples": [ + "time,browser", + "country", + "time,country,browser" + ], + "title": "Group By" + }, + "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `device` \u2014 group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `utm_source` \u2014 group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` \u2014 group by the `utm_medium` tag\n- `utm_campaign` \u2014 group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser." + }, + { + "name": "metrics", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)", + "examples": [ + "clicks,unique_clicks", + "clicks" + ], + "title": "Metrics" + }, + "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)" + }, + { + "name": "timezone", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 50, + "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`.", + "examples": [ + "UTC", + "America/New_York" + ], + "default": "UTC", + "title": "Timezone" + }, + "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`." + }, + { + "name": "filters", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 5000 + }, + { + "type": "null" + } + ], + "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` \u2014 Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `utm_source` / `utm_medium` / `utm_campaign` \u2014 Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below).", + "examples": [ + "{\"browser\":[\"Chrome\",\"Firefox\"]}", + "{\"country\":[\"United States\",\"Canada\"],\"browser\":[\"Chrome\"]}" + ], + "title": "Filters" + }, + "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` \u2014 Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `utm_source` / `utm_medium` / `utm_campaign` \u2014 Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below)." + }, + { + "name": "browser", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "Chrome,Firefox" + ], + "title": "Browser" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "os", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "Windows,macOS" + ], + "title": "Os" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "device", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "mobile,desktop" + ], + "title": "Device" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "country", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "United States,Germany" + ], + "title": "Country" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "city", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "San Francisco,Berlin" + ], + "title": "City" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "referrer", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "https://google.com,https://twitter.com" + ], + "title": "Referrer" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_source", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "newsletter,twitter" + ], + "title": "Utm Source" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_medium", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "email,social" + ], + "title": "Utm Medium" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + }, + { + "name": "utm_campaign", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.", + "examples": [ + "summer-launch" + ], + "title": "Utm Campaign" + }, + "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined." + } + ], + "responses": { + "200": { + "description": "Export file download", + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/xml": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + }, + "x-description": "CSV export \u2014 ZIP archive containing summary.csv plus one file per dimension" + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" + }, + "x-description": "XLSX export \u2014 Excel workbook with multiple sheets" + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error \u2014 export generation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/keys": { + "get": { + "tags": [ + "API Keys" + ], + "summary": "List API Keys", + "description": "List all API keys for the authenticated user.\n\nReturns metadata for all API keys (both active and revoked) belonging to the\nauthenticated user. The full token value is **never** returned in this\nendpoint for security reasons \u2014 only the `token_prefix` is shown.\n\n**Authentication**: Required \u2014 interactive session or an app token with\nthe `keys:manage` scope (API keys cannot be used to manage API keys).\n\n**Rate Limits**: 60/min", + "operationId": "listApiKeys", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeysListResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "API Keys" + ], + "summary": "Create API Key", + "description": "Create a new API key for programmatic access.\n\nGenerate a new API key with the specified name and scopes. The full token\n(prefixed with `spoo_`) is returned **only in this response** and cannot be\nretrieved again.\n\n**Authentication**: Required \u2014 interactive session only. Connected apps\nand API keys cannot create keys; minting a credential is a first-party\naction. Email must be verified.\n\n**Rate Limits**: 5/hour\n\n**Available Scopes**: `shorten:create`, `urls:manage`, `urls:read`,\n`stats:read`, `domains:manage`, `domains:read`, `reports:create`,\n`admin:all`\n\n**Notes**:\n\n- Store the returned `token` securely \u2014 it will not be shown again\n- Set `expires_at` to limit the key's lifetime (ISO 8601 or Unix epoch)\n- Omit `expires_at` for a non-expiring key", + "operationId": "createApiKey", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyCreatedResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/keys/{key_id}": { + "delete": { + "tags": [ + "API Keys" + ], + "summary": "Delete/Revoke API Key", + "description": "Delete or revoke an API key.\n\nRemove an API key either permanently (hard delete) or by marking it as\nrevoked (soft delete). Revoked keys stop working immediately but remain\nvisible in the key list for audit purposes.\n\n**Authentication**: Required \u2014 interactive session or an app token with\nthe `keys:manage` scope (API keys cannot be used to manage API keys).\n\n**Rate Limits**: 30/min\n\n**Modes**:\n\n- `?revoke=false` (default) \u2014 **permanently deletes** the key record\n- `?revoke=true` \u2014 marks the key as revoked but preserves the record;\n the key appears with `revoked: true` in the list endpoint", + "operationId": "deleteApiKey", + "parameters": [ + { + "name": "key_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9a-f]{24}$", + "title": "Key Id" + } + }, + { + "name": "revoke", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Revoke" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyActionResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/apps": { + "get": { + "tags": [ + "Apps" + ], + "summary": "List Connected Apps", + "description": "List the apps connected to the authenticated user's account.\n\nReturns one entry per active device-auth grant (revoked grants are\nexcluded), newest first. `app` is the registry key from\n`config/apps.yaml` \u2014 pass it as `app_id` to `POST /auth/device/revoke`\nto disconnect an app. An empty `items` array means nothing is\nconnected.\n\n**Authentication**: Required \u2014 JWT Bearer only (API keys cannot list\nthe account's connected apps).\n\n**Rate Limits**: 60/min", + "operationId": "listAppGrants", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppGrantsListResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/custom-domains": { + "post": { + "tags": [ + "Custom Domains" + ], + "summary": "Register Custom Domain", + "description": "Register a new custom domain for branded short links.\n\nThe domain is born in `PENDING` state. The user must publish the DNS\nrecords returned in `dns_records` at their DNS provider, then call\n`POST /custom-domains/{id}/verify` to trigger verification. Cloudflare\nauto-verifies via HTTP DCV once the CNAME is live and propagated.\n\n**Authentication**: Required (JWT Bearer or API key with `domains:manage`).\n\n**Email verification**: Required (applies to API key callers too).\n\n**Feature gate**: Must be enabled for the calling user.\n\n**Rate Limits**: 10/hour (route, failed attempts count) + `max_per_user`\nowned domains (service quota, any status).", + "operationId": "createCustomDomain", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomDomainRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomDomainResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "Custom Domains" + ], + "summary": "List Custom Domains", + "description": "List custom domains owned by the authenticated user.\n\nReads bypass the feature flag so owners can still see state during a\nrollback.\n\n**Authentication**: Required (JWT or API key with `domains:read`).\n\n**Rate Limits**: 60/min.\n\n**Pagination**: `page` (default 1) + `pageSize` (default 20, max 100).", + "operationId": "listCustomDomains", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 20, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomDomainListResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/custom-domains/{domain_id}/verify": { + "post": { + "tags": [ + "Custom Domains" + ], + "summary": "Verify Custom Domain", + "description": "Trigger a fresh verification check for a custom domain.\n\nThe verifier dispatched depends on the chosen DCV method (CF HTTP DCV on\nCF SaaS deployments, CNAME/A/TXT on self-host). On success the domain\ntransitions to `ACTIVE` and short links on the domain start resolving.\n\n**Authentication**: Required (JWT or API key with `domains:manage`).\n\n**Rate Limits**: 10/min (route) + 60/hour per domain (service quota).", + "operationId": "verifyCustomDomain", + "parameters": [ + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "MongoDB ObjectId of the domain.", + "title": "Domain Id" + }, + "description": "MongoDB ObjectId of the domain." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomDomainResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/custom-domains/{domain_id}": { + "get": { + "tags": [ + "Custom Domains" + ], + "summary": "Get Custom Domain", + "description": "Fetch a single custom domain owned by the caller.\n\nUsed by the dashboard detail view + auto-poll after Verify. Bypasses the\nfeature flag like other read endpoints.\n\n**Authentication**: Required (JWT or API key with `domains:read`).\n\n**Rate Limits**: 60/min.", + "operationId": "getCustomDomain", + "parameters": [ + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "MongoDB ObjectId of the domain.", + "title": "Domain Id" + }, + "description": "MongoDB ObjectId of the domain." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomDomainResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Custom Domains" + ], + "summary": "Update Custom Domain Routing", + "description": "Update the per-domain routing config.\n\nPartial update \u2014 send only the fields you want to change. Omit a field to\nleave it untouched. Send explicit `null` to clear a stored value. The\nconfig takes effect only when the domain is `ACTIVE`; non-ACTIVE domains\nreturn 422.\n\n**Configurable fields**:\n- `root_redirect` \u2014 destination for `GET /` (302).\n- `not_found_redirect` \u2014 fallback for any path that doesn't match an alias.\n- `custom_robots_txt` \u2014 body served at `/robots.txt` (\u22644096 chars).\n\n**Authentication**: Required (JWT or API key with `domains:manage`).\n\n**Rate Limits**: 30/min.\n\n**Responses**:\n- 200 \u2014 updated; full domain response returned\n- 403 \u2014 caller does not own this domain\n- 404 \u2014 domain not found (or invalid id)\n- 422 \u2014 domain isn't ACTIVE, or body fails validation", + "operationId": "updateCustomDomain", + "parameters": [ + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "MongoDB ObjectId of the domain.", + "title": "Domain Id" + }, + "description": "MongoDB ObjectId of the domain." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCustomDomainRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomDomainResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Custom Domains" + ], + "summary": "Revoke Custom Domain", + "description": "Revoke a custom domain. `REVOKED` is terminal.\n\nWith `?cascade=true`, all URLs owned by the caller on the domain are\nbulk-deleted. With `?cascade=false` (default), URLs remain in the\ndatabase and the domain stops serving \u2014 the URLs effectively become\norphans until the domain is re-registered or garbage-collected.\n\n**Authentication**: Required (JWT or API key with `domains:manage`).\n\n**Rate Limits**: 10/min.", + "operationId": "deleteCustomDomain", + "parameters": [ + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "MongoDB ObjectId of the domain.", + "title": "Domain Id" + }, + "description": "MongoDB ObjectId of the domain." + }, + { + "name": "cascade", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "When true, all URLs on this domain are deleted alongside the domain revoke. When false (default), URLs remain in the database but become unreachable (the domain stops resolving).", + "default": false, + "title": "Cascade" + }, + "description": "When true, all URLs on this domain are deleted alongside the domain revoke. When false (default), URLs remain in the database but become unreachable (the domain stops resolving)." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomDomainDeleteResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/custom-domains/{domain_id}/permanent": { + "delete": { + "tags": [ + "Custom Domains" + ], + "summary": "Remove Revoked Custom Domain", + "description": "Permanently delete a revoked custom domain to free the account slot.\n\nSoft-deleted (REVOKED) domains continue to occupy the caller's per-account\nquota so revoke is reversible and the audit trail stays intact. To free\nthe slot, the caller explicitly removes the doc via this endpoint. Only\nREVOKED docs are removable \u2014 call DELETE first to revoke an active one.\n\n**Authentication**: Required (JWT or API key with `domains:manage`).\n\n**Rate Limits**: 10/min.\n\n**Responses**:\n- 204 \u2014 removed\n- 403 \u2014 caller does not own this domain\n- 404 \u2014 domain not found (or invalid id)\n- 422 \u2014 domain isn't REVOKED", + "operationId": "removeCustomDomain", + "parameters": [ + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "MongoDB ObjectId of the domain.", + "title": "Domain Id" + }, + "description": "MongoDB ObjectId of the domain." + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/metadata": { + "get": { + "tags": [ + "Metadata" + ], + "summary": "Fetch Destination Meta Tags", + "description": "Fetch a destination page and return its existing meta tags.\n\nUse this to prefill ``meta_tags`` before customizing a link's social\npreview. Returns normalized best-pick fields (og \u2192 twitter \u2192 html\nfallbacks) plus the raw ``og``/``twitter`` tag families.\n\n**Authentication**: Required. **API Key Scope**: `urls:read`,\n`urls:manage`, or `admin:all`.\n\n**Feature gate**: `custom_meta_tags` must be enabled for the calling\naccount \u2014 this endpoint only exists to feed that feature.\n\n**Rate Limits**: 20/min, 500/day \u2014 results are cached ~1h server-side,\nso repeat calls for the same URL are cheap and don't refetch.", + "operationId": "getUrlMetadata", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "maxLength": 2048, + "description": "Destination https URL to fetch and parse.", + "examples": [ + "https://example.com/article" + ], + "title": "Url" + }, + "description": "Destination https URL to fetch and parse." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/me/features": { + "get": { + "tags": [ + "Me" + ], + "summary": "Get Feature Availability", + "description": "Return the availability state of every gated feature for this account.\n\nStates: `enabled` (render it), `hidden` (the feature doesn't exist for\nthis account), `locked` (reserved \u2014 render as upgrade-gated once plans\nship). Treat features missing from the map as `hidden`. Never used for\nenforcement \u2014 the write endpoints enforce the same gates server-side.\n\n**Authentication**: Required.", + "operationId": "getMyFeatures", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeaturesResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/me/layouts/{page}": { + "get": { + "tags": [ + "Me" + ], + "summary": "Get Page Layout", + "description": "Fetch the saved dashboard layout for a page.\n\nReturns `layout: null` when the user has never customized this page \u2014\nclients render their built-in default in that case.\n\n**Authentication**: Required.", + "operationId": "getPageLayout", + "parameters": [ + { + "name": "page", + "in": "path", + "required": true, + "schema": { + "enum": [ + "analytics", + "overview" + ], + "type": "string", + "description": "Layout slot, e.g. `analytics`", + "title": "Page" + }, + "description": "Layout slot, e.g. `analytics`" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LayoutResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Me" + ], + "summary": "Save Page Layout", + "description": "Save the layout document for a page.\n\nThe document is stored verbatim (last write wins) and echoed back.\nVersioning and validation are the client's responsibility; the body is\ncapped at 32 KiB.\n\n**Authentication**: Required.", + "operationId": "putPageLayout", + "parameters": [ + { + "name": "page", + "in": "path", + "required": true, + "schema": { + "enum": [ + "analytics", + "overview" + ], + "type": "string", + "description": "Layout slot, e.g. `analytics`", + "title": "Page" + }, + "description": "Layout slot, e.g. `analytics`" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutLayoutRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LayoutResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Me" + ], + "summary": "Reset Page Layout", + "description": "Remove the saved layout so the page falls back to the client default.\n\nIdempotent: returns 204 whether or not an override existed.\n\n**Authentication**: Required.", + "operationId": "deletePageLayout", + "parameters": [ + { + "name": "page", + "in": "path", + "required": true, + "schema": { + "enum": [ + "analytics", + "overview" + ], + "type": "string", + "description": "Layout slot, e.g. `analytics`", + "title": "Page" + }, + "description": "Layout slot, e.g. `analytics`" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/me/profile-pictures": { + "get": { + "tags": [ + "Me" + ], + "summary": "Get Available Profile Pictures", + "description": "List the profile pictures available to this account.\n\nOne entry per linked OAuth provider picture, with `is_current` marking\nthe active one.\n\n**Authentication**: Required.", + "operationId": "getMyProfilePictures", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AvailablePicturesResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Me" + ], + "summary": "Set Profile Picture", + "description": "Set the profile picture to one of the available provider pictures.\n\n`picture_id` must be an id returned by the GET endpoint; unknown ids\nyield 404.\n\n**Authentication**: Required.", + "operationId": "setMyProfilePicture", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetProfilePictureRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfilePictureMessageResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Me" + ], + "summary": "Remove Profile Picture", + "description": "Unset the profile picture so the account falls back to the default.\n\nIdempotent: returns 200 whether or not a picture was set.\n\n**Authentication**: Required.", + "operationId": "deleteMyProfilePicture", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfilePictureMessageResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/me/profile-pictures/upload": { + "post": { + "tags": [ + "Me" + ], + "summary": "Upload Profile Picture", + "description": "Upload a custom profile picture as a base64 data URI.\n\nAccepts image/png, image/jpeg and image/webp; size and content-type\nvalidation happens server-side against the configured upload cap.\n\n**Authentication**: Required.", + "operationId": "uploadMyProfilePicture", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadProfilePictureRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfilePictureMessageResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/public/preview/{short_code}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Public Link Preview", + "description": "Preview where a short link leads before following it.\n\nReturns the link's resolved facts \u2014 status, creation date, whether a\npassword gates it, and (only while the link is active and unlocked)\nthe destination plus every geo-targeted destination, grouped by URL.\n\n**Authentication**: None. The preview shows the same resolved facts to\neveryone; owner-set social meta never appears here.\n\n**Rate Limits**: 30/min, 2,000/day.", + "operationId": "getPublicPreview", + "parameters": [ + { + "name": "short_code", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Short Code" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicPreviewResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [] + } + }, + "/api/v1/reports": { + "post": { + "tags": [ + "Reports & Contact" + ], + "summary": "Report URLs", + "description": "Report shortened URLs for abuse \u2014 one or many per request.\n\nAccepts bare codes or full short URLs (custom domains included) and\nreturns a per-item accepted/rejected breakdown \u2014 bad codes don't sink\nthe batch. Re-reports of a code increment its counter rather than\nfiling duplicates.\n\n**Authentication**: Optional. Anonymous submissions are captcha-gated\nand capped at **25 items/request**; authenticated callers (session or\nAPI key) skip the captcha and may send **100 items/request**.\n\n**API Key Scope**: `reports:create` or `admin:all`\n\n**Rate Limits** (per submission, not per item):\n\n- Authenticated: 30/min, 500/day\n- Anonymous: 5/min, 40/day", + "operationId": "submitReports", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReportsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportSubmissionResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Not configured \u2014 the required webhook is unset on this instance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + {}, + { + "ApiKeyAuth": [] + }, + { + "JWTAuth": [] + } + ] + } + }, + "/api/v1/contact": { + "post": { + "tags": [ + "Reports & Contact" + ], + "summary": "Contact", + "description": "Send a message to the site operators.\n\nJSON twin of the ``/contact`` form \u2014 same service, same webhook, same\nbudget values.\n\n**Authentication**: None.\n\n**Rate Limits**: 5/min, 20/hour, 50/day", + "operationId": "submitContactMessage", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactOkResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Not configured \u2014 the required webhook is unset on this instance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [] + } + }, + "/api/v1/webhooks/event-types": { + "get": { + "tags": [ + "Webhooks" + ], + "summary": "List Webhook Event Types", + "description": "The public webhook event catalog \u2014 documentation as API.\n\nEvery subscribable event type with its category, firing frequency, and\nan exact sample payload (the same fixture test sends deliver).\n\n**Authentication**: None.", + "operationId": "listWebhookEventTypes", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypesResponse" + } + } + } + } + } + } + }, + "/api/v1/webhooks": { + "get": { + "tags": [ + "Webhooks" + ], + "summary": "List Webhook Endpoints", + "description": "List all webhook endpoints on the account (secrets never included).", + "operationId": "listWebhookEndpoints", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointsListResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Webhooks" + ], + "summary": "Create Webhook Endpoint", + "description": "Register an HTTPS endpoint to receive event deliveries.\n\nThe `signing_secret` is returned **only in this response** \u2014 store it;\nit is what your server uses to verify `webhook-signature` headers\n(Standard Webhooks, HMAC-SHA256).\n\n**Authentication**: Required (verified email). Feature-flagged.\n\n**Rate Limits**: 10/hour", + "operationId": "createWebhookEndpoint", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhookEndpointRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointCreatedResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/webhooks/{endpoint_id}": { + "get": { + "tags": [ + "Webhooks" + ], + "summary": "Get Webhook Endpoint", + "operationId": "getWebhookEndpoint", + "parameters": [ + { + "name": "endpoint_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Endpoint Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Webhooks" + ], + "summary": "Update Webhook Endpoint", + "description": "Update url, events, scope, flavor, description, or pause/resume.\n\nA new `url` is re-checked (https + public address) before it takes\neffect. `status` accepts `active`/`paused`; `disabled` is system-set \u2014\nre-activating a disabled endpoint clears its failure bookkeeping.", + "operationId": "updateWebhookEndpoint", + "parameters": [ + { + "name": "endpoint_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Endpoint Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhookEndpointRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Webhooks" + ], + "summary": "Delete Webhook Endpoint", + "operationId": "deleteWebhookEndpoint", + "parameters": [ + { + "name": "endpoint_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Endpoint Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/webhooks/{endpoint_id}/secret": { + "get": { + "tags": [ + "Webhooks" + ], + "summary": "Reveal Signing Secret", + "description": "The full signing secret, for the endpoint owner. Secrets are stored\nencrypted and readable on demand; treat the response like a password.\n\n**Authentication**: Interactive session only \u2014 API keys are refused,\nlike every operation that exposes or mints credential material.", + "operationId": "revealWebhookSecret", + "parameters": [ + { + "name": "endpoint_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Endpoint Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookSecretResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/webhooks/{endpoint_id}/test": { + "post": { + "tags": [ + "Webhooks" + ], + "summary": "Send Test Event", + "description": "Send a sample of any catalog event through the real pipeline \u2014\nrendered in the endpoint's flavor, signed with its real secret \u2014 and\nreturn the delivery outcome synchronously.", + "operationId": "testWebhookEndpoint", + "parameters": [ + { + "name": "endpoint_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Endpoint Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestWebhookRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/webhooks/{endpoint_id}/deliveries": { + "get": { + "tags": [ + "Webhooks" + ], + "summary": "List Webhook Deliveries", + "description": "The delivery log: what was sent, when, each attempt's outcome, and\nthe exact rendered body (30-day retention).", + "operationId": "listWebhookDeliveries", + "parameters": [ + { + "name": "endpoint_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Endpoint Id" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 25, + "title": "Page Size" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeliveryStatus" + }, + { + "type": "null" + } + ], + "title": "Status" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveriesListResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/webhooks/{endpoint_id}/deliveries/{delivery_id}/retry": { + "post": { + "tags": [ + "Webhooks" + ], + "summary": "Retry Delivery", + "description": "Redeliver a completed delivery: same `webhook-id`, same body, fresh\nattempt \u2014 consumers dedup on `webhook-id`.", + "operationId": "retryWebhookDelivery", + "parameters": [ + { + "name": "endpoint_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Endpoint Id" + } + }, + { + "name": "delivery_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Delivery Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryResponse" + } + } + } + }, + "400": { + "description": "Bad Request \u2014 invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized \u2014 missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden \u2014 insufficient permissions or scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict \u2014 resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AliasCheckResponse": { + "properties": { + "available": { + "type": "boolean", + "title": "Available", + "description": "Whether the alias is free to use." + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason", + "description": "When unavailable: 'length', 'format', 'reserved', 'taken', or 'emoji_policy' (emoji alias contains sequences outside the accepted set \u2014 ZWJ, flags, keycaps, or too-new emoji).", + "examples": [ + "taken" + ] + } + }, + "type": "object", + "required": [ + "available" + ], + "title": "AliasCheckResponse", + "description": "Response body for GET /api/v1/shorten/check-alias.\n\n``available`` is true only when the alias passes format/length validation\nAND is not already taken. When false, ``reason`` explains why so the UI\ncan render a precise, non-generic message." + }, + "ApiKeyActionResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "description": "Whether the action completed successfully" + }, + "action": { + "type": "string", + "title": "Action", + "description": "Action that was performed", + "examples": [ + "deleted" + ] + } + }, + "type": "object", + "required": [ + "success", + "action" + ], + "title": "ApiKeyActionResponse", + "description": "Response body for DELETE /api/v1/keys/{key_id}." + }, + "ApiKeyCreatedResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "API key ID", + "examples": [ + "507f1f77bcf86cd799439011" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "Human-readable key name", + "examples": [ + "My Production Key" + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "Optional description", + "examples": [ + "Used by the mobile app" + ] + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Scopes", + "description": "Permission scopes granted to this key", + "examples": [ + [ + "shorten:create", + "stats:read" + ] + ] + }, + "created_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Created At", + "description": "Creation time as Unix timestamp", + "examples": [ + 1704067200 + ] + }, + "expires_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expires At", + "description": "Expiration time as Unix timestamp, or null if no expiration", + "examples": [ + 1735689600 + ] + }, + "revoked": { + "type": "boolean", + "title": "Revoked", + "description": "Whether the key has been revoked" + }, + "token_prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Prefix", + "description": "First characters of the token for identification", + "examples": [ + "spoo_abc1" + ] + }, + "last_used_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Used At", + "description": "Last time this key authenticated a request, as Unix timestamp. Null if the key has never been used. Updated at most once per hour.", + "examples": [ + 1704067200 + ] + }, + "token": { + "type": "string", + "title": "Token", + "description": "Full API key token (only returned once at creation time)", + "examples": [ + "spoo_abc123def456ghi789" + ] + } + }, + "type": "object", + "required": [ + "id", + "name", + "scopes", + "revoked", + "token" + ], + "title": "ApiKeyCreatedResponse", + "description": "Response for POST /api/v1/keys (201).\n\nExtends ApiKeyResponse by adding the full ``token``. This is the ONLY time\nthe token is returned \u2014 it is hashed before storage." + }, + "ApiKeyResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "API key ID", + "examples": [ + "507f1f77bcf86cd799439011" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "Human-readable key name", + "examples": [ + "My Production Key" + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "Optional description", + "examples": [ + "Used by the mobile app" + ] + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Scopes", + "description": "Permission scopes granted to this key", + "examples": [ + [ + "shorten:create", + "stats:read" + ] + ] + }, + "created_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Created At", + "description": "Creation time as Unix timestamp", + "examples": [ + 1704067200 + ] + }, + "expires_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expires At", + "description": "Expiration time as Unix timestamp, or null if no expiration", + "examples": [ + 1735689600 + ] + }, + "revoked": { + "type": "boolean", + "title": "Revoked", + "description": "Whether the key has been revoked" + }, + "token_prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Prefix", + "description": "First characters of the token for identification", + "examples": [ + "spoo_abc1" + ] + }, + "last_used_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Used At", + "description": "Last time this key authenticated a request, as Unix timestamp. Null if the key has never been used. Updated at most once per hour.", + "examples": [ + 1704067200 + ] + } + }, + "type": "object", + "required": [ + "id", + "name", + "scopes", + "revoked" + ], + "title": "ApiKeyResponse", + "description": "A single API key entry as returned by the list endpoint.\n\nThe full token is never returned here \u2014 only the ``token_prefix`` for display." + }, + "ApiKeysListResponse": { + "properties": { + "keys": { + "items": { + "$ref": "#/components/schemas/ApiKeyResponse" + }, + "type": "array", + "title": "Keys", + "description": "List of API keys for the authenticated user" + } + }, + "type": "object", + "required": [ + "keys" + ], + "title": "ApiKeysListResponse", + "description": "Response body for GET /api/v1/keys." + }, + "AppGrantResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "Grant ID (accepted as `grant_id` by POST /auth/device/revoke)", + "examples": [ + "665f1c2ab7e94d0c8a1f2b3c" + ] + }, + "app": { + "type": "string", + "title": "App", + "description": "App registry key (config/apps.yaml). Shares a namespace with the frontend catalogue slugs and is the `app_id` handle for POST /auth/device/revoke.", + "examples": [ + "spoo-cli" + ] + }, + "app_name": { + "type": "string", + "title": "App Name", + "description": "Display name from the registry (falls back to `app`)", + "examples": [ + "Spoo CLI" + ] + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Icon", + "description": "Registry icon filename, or null when the entry is gone", + "examples": [ + "spoo-cli.svg" + ] + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Scopes", + "description": "Effective scope slugs the grant confers. Empty list means a legacy unrestricted grant (consented before scoped grants existed) \u2014 full account access, not zero access.", + "examples": [ + [ + "shorten:create", + "urls:read" + ] + ] + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Permissions", + "description": "Human-readable consent sentences derived from `scopes` (full-access sentence for legacy unrestricted grants)", + "examples": [ + [ + "Create short links", + "List and read links" + ] + ] + }, + "granted_at": { + "type": "string", + "title": "Granted At", + "description": "When access was granted (ISO 8601 UTC)" + }, + "last_used_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Used At", + "description": "Last token exchange/refresh by this app, null if never used" + } + }, + "type": "object", + "required": [ + "id", + "app", + "app_name", + "scopes", + "permissions", + "granted_at" + ], + "title": "AppGrantResponse", + "description": "A single connected app as returned by the list endpoint." + }, + "AppGrantsListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AppGrantResponse" + }, + "type": "array", + "title": "Items", + "description": "Active grants, newest granted_at first" + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "AppGrantsListResponse", + "description": "Response body for GET /api/v1/apps." + }, + "AuthProviderInfo": { + "properties": { + "provider": { + "anyOf": [ + { + "$ref": "#/components/schemas/OAuthProvider" + }, + { + "type": "null" + } + ], + "description": "OAuth provider name", + "examples": [ + "google" + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email", + "description": "Email address from the OAuth provider", + "examples": [ + "user@gmail.com" + ] + }, + "linked_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Linked At", + "description": "When the provider was linked", + "examples": [ + "2025-01-15T10:30:00+00:00" + ] + } + }, + "type": "object", + "title": "AuthProviderInfo", + "description": "Minimal OAuth provider entry returned inside UserProfileResponse." + }, + "AvailablePicture": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "Unique identifier (provider_providerUserId)" + }, + "url": { + "type": "string", + "title": "Url", + "description": "Picture URL" + }, + "source": { + "$ref": "#/components/schemas/OAuthProvider", + "description": "OAuth provider source" + }, + "is_current": { + "type": "boolean", + "title": "Is Current", + "description": "Whether this is the active picture" + } + }, + "type": "object", + "required": [ + "id", + "url", + "source", + "is_current" + ], + "title": "AvailablePicture", + "description": "A profile picture option from a linked OAuth provider." + }, + "AvailablePicturesResponse": { + "properties": { + "pictures": { + "items": { + "$ref": "#/components/schemas/AvailablePicture" + }, + "type": "array", + "title": "Pictures" + } + }, + "type": "object", + "required": [ + "pictures" + ], + "title": "AvailablePicturesResponse" + }, + "BulkDeleteUrlsRequest": { + "properties": { + "ids": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Ids", + "description": "URL ids (MongoDB ObjectIds, as returned by the list endpoint). 1 to 100 per request; duplicates are deduplicated server-side (first occurrence wins). One malformed id rejects the whole request \u2014 nothing is attempted.", + "examples": [ + [ + "665f0c2f9e7a4b1d2c3d4e5f", + "665f0c2f9e7a4b1d2c3d4e60" + ] + ] + } + }, + "type": "object", + "required": [ + "ids" + ], + "title": "BulkDeleteUrlsRequest", + "description": "Request body for bulk delete \u2014 ids only, no parameters." + }, + "BulkDeleteUrlsResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message", + "description": "Confirmation message.", + "examples": [ + "deleted 42 URLs on links.acme.com" + ] + }, + "count": { + "type": "integer", + "title": "Count", + "description": "Number of URLs deleted.", + "examples": [ + 42 + ] + }, + "domain": { + "type": "string", + "title": "Domain", + "description": "Domain whose URLs were deleted.", + "examples": [ + "links.acme.com" + ] + } + }, + "type": "object", + "required": [ + "message", + "count", + "domain" + ], + "title": "BulkDeleteUrlsResponse", + "description": "Response body for DELETE /api/v1/urls?domain= (bulk delete)." + }, + "BulkMoveDomainRequest": { + "properties": { + "ids": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Ids", + "description": "URL ids (MongoDB ObjectIds, as returned by the list endpoint). 1 to 100 per request; duplicates are deduplicated server-side (first occurrence wins). One malformed id rejects the whole request \u2014 nothing is attempted.", + "examples": [ + [ + "665f0c2f9e7a4b1d2c3d4e5f", + "665f0c2f9e7a4b1d2c3d4e60" + ] + ] + }, + "domain": { + "anyOf": [ + { + "type": "string", + "maxLength": 253 + }, + { + "type": "null" + } + ], + "title": "Domain", + "description": "Target domain for every id \u2014 a custom domain you own (must be ACTIVE), or `null` to move back to the system default. One target for the whole batch.", + "examples": [ + "links.acme.com" + ] + } + }, + "type": "object", + "required": [ + "ids", + "domain" + ], + "title": "BulkMoveDomainRequest", + "description": "Request body for bulk domain move." + }, + "BulkOperationSummary": { + "properties": { + "total": { + "type": "integer", + "title": "Total", + "description": "Unique ids in the request (after dedupe)." + }, + "succeeded": { + "type": "integer", + "title": "Succeeded", + "description": "Rows with ok=true." + }, + "failed": { + "type": "integer", + "title": "Failed", + "description": "Rows with ok=false." + } + }, + "type": "object", + "required": [ + "total", + "succeeded", + "failed" + ], + "title": "BulkOperationSummary", + "description": "Counts derived from the result rows." + }, + "BulkUpdateExpiryRequest": { + "properties": { + "ids": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Ids", + "description": "URL ids (MongoDB ObjectIds, as returned by the list endpoint). 1 to 100 per request; duplicates are deduplicated server-side (first occurrence wins). One malformed id rejects the whole request \u2014 nothing is attempted.", + "examples": [ + [ + "665f0c2f9e7a4b1d2c3d4e5f", + "665f0c2f9e7a4b1d2c3d4e60" + ] + ] + }, + "expire_after": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expire After", + "description": "Expiration applied to every id \u2014 ISO 8601 or epoch seconds, must be in the future. Pass `null` to clear expiry. One value for the whole batch.", + "examples": [ + 1767225600 + ] + } + }, + "type": "object", + "required": [ + "ids", + "expire_after" + ], + "title": "BulkUpdateExpiryRequest", + "description": "Request body for bulk set/clear expiry." + }, + "BulkUpdateStatusRequest": { + "properties": { + "ids": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Ids", + "description": "URL ids (MongoDB ObjectIds, as returned by the list endpoint). 1 to 100 per request; duplicates are deduplicated server-side (first occurrence wins). One malformed id rejects the whole request \u2014 nothing is attempted.", + "examples": [ + [ + "665f0c2f9e7a4b1d2c3d4e5f", + "665f0c2f9e7a4b1d2c3d4e60" + ] + ] + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE" + ], + "title": "Status", + "description": "Status applied to every id. `ACTIVE` enables redirects, `INACTIVE` disables them. `BLOCKED`/`EXPIRED` are not caller-settable, same as the single-item status endpoint.", + "examples": [ + "INACTIVE" + ] + } + }, + "type": "object", + "required": [ + "ids", + "status" + ], + "title": "BulkUpdateStatusRequest", + "description": "Request body for bulk activate/deactivate." + }, + "BulkUrlOperationResponse": { + "properties": { + "summary": { + "$ref": "#/components/schemas/BulkOperationSummary" + }, + "results": { + "items": { + "$ref": "#/components/schemas/BulkUrlResultRow" + }, + "type": "array", + "title": "Results", + "description": "One row per unique requested id, in request order." + } + }, + "type": "object", + "required": [ + "summary", + "results" + ], + "title": "BulkUrlOperationResponse", + "description": "Envelope for every bulk URL operation." + }, + "BulkUrlResultRow": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "The requested URL id." + }, + "alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Alias", + "description": "Echoed when the id resolved to a URL you own; null otherwise." + }, + "ok": { + "type": "boolean", + "title": "Ok", + "description": "Whether the operation succeeded for this id." + }, + "error_code": { + "anyOf": [ + { + "type": "string", + "enum": [ + "not_found", + "forbidden", + "conflict", + "validation_error", + "internal", + "not_attempted" + ] + }, + { + "type": "null" + } + ], + "title": "Error Code", + "description": "Machine-readable failure cause; null when ok." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error", + "description": "Human-readable failure message; null when ok." + } + }, + "type": "object", + "required": [ + "id", + "ok" + ], + "title": "BulkUrlResultRow", + "description": "Per-item verdict.\n\n``error_code`` reuses the API's error-code slugs so clients share\none mapping with the single-item routes: ``not_found`` (no such URL\nin your account \u2014 someone else's id answers the same, deliberately),\n``forbidden`` (blocked link), ``conflict``, ``validation_error``,\nplus ``internal`` (unexpected per-item failure, logged server-side)\nand ``not_attempted`` (processing aborted before this item).\n``error`` is display-safe but not stable; ``error_code`` is the key\nto branch on." + }, + "ClaimItemRequest": { + "properties": { + "url_id": { + "type": "string", + "maxLength": 24, + "minLength": 24, + "pattern": "^[0-9a-f]{24}$", + "title": "Url Id", + "description": "ObjectId of the URL to claim (from the shorten response).", + "examples": [ + "507f1f77bcf86cd799439011" + ] + }, + "token": { + "type": "string", + "maxLength": 256, + "minLength": 43, + "title": "Token", + "description": "The one-time claim_token returned by the anonymous shorten call \u2014 the bearer proof of creation." + } + }, + "type": "object", + "required": [ + "url_id", + "token" + ], + "title": "ClaimItemRequest", + "description": "One (url_id, token) pair in a claim batch." + }, + "ClaimResultItem": { + "properties": { + "url_id": { + "type": "string", + "title": "Url Id", + "description": "The url_id from the request item." + }, + "status": { + "type": "string", + "enum": [ + "claimed", + "already_yours", + "invalid" + ], + "title": "Status", + "description": "`claimed` \u2014 ownership transferred and the token burned; `already_yours` \u2014 you already own this URL (idempotent repeat); `invalid` \u2014 unknown id, wrong token, or a link that is not claimable (deliberately indistinguishable)." + } + }, + "type": "object", + "required": [ + "url_id", + "status" + ], + "title": "ClaimResultItem", + "description": "Per-item outcome of a claim batch." + }, + "ClaimUrlsRequest": { + "properties": { + "claims": { + "items": { + "$ref": "#/components/schemas/ClaimItemRequest" + }, + "type": "array", + "maxItems": 16, + "minItems": 1, + "title": "Claims", + "description": "URLs to claim. Each item resolves independently." + } + }, + "type": "object", + "required": [ + "claims" + ], + "title": "ClaimUrlsRequest", + "description": "Request body for POST /api/v1/urls/claim (bulk-first, like reports)." + }, + "ClaimUrlsResponse": { + "properties": { + "results": { + "items": { + "$ref": "#/components/schemas/ClaimResultItem" + }, + "type": "array", + "title": "Results", + "description": "One outcome per submitted item, in request order." + }, + "claimed": { + "type": "integer", + "title": "Claimed", + "description": "Convenience count of `claimed` results." + } + }, + "type": "object", + "required": [ + "results", + "claimed" + ], + "title": "ClaimUrlsResponse", + "description": "Response body for POST /api/v1/urls/claim.\n\nThe batch never hard-fails: every submitted item gets a result, in\nrequest order." + }, + "ComputedMetrics": { + "properties": { + "unique_click_rate": { + "type": "number", + "title": "Unique Click Rate" + }, + "repeat_click_rate": { + "type": "number", + "title": "Repeat Click Rate" + }, + "average_clicks_per_visitor": { + "type": "number", + "title": "Average Clicks Per Visitor" + } + }, + "type": "object", + "required": [ + "unique_click_rate", + "repeat_click_rate", + "average_clicks_per_visitor" + ], + "title": "ComputedMetrics", + "description": "Optional computed metrics added by format_stats_response_with_metadata." + }, + "ContactOkResponse": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok", + "default": true + } + }, + "type": "object", + "title": "ContactOkResponse", + "description": "Response body for POST /api/v1/contact." + }, + "ContactRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email", + "description": "Sender's email address" + }, + "message": { + "type": "string", + "maxLength": 4000, + "minLength": 1, + "title": "Message", + "description": "Message body (1-4000 characters)" + }, + "captcha_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Captcha Token", + "description": "hCaptcha response token \u2014 required when captcha is configured" + } + }, + "type": "object", + "required": [ + "email", + "message" + ], + "title": "ContactRequest", + "description": "Request body for POST /api/v1/contact." + }, + "CreateApiKeyRequest": { + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "title": "Name", + "description": "Human-readable key name", + "examples": [ + "My Production Key" + ] + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "Optional description of what this key is used for", + "examples": [ + "Used by the mobile app for URL shortening" + ] + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Scopes", + "description": "Permission scopes for the key", + "examples": [ + [ + "shorten:create", + "stats:read" + ] + ] + }, + "expires_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expires At", + "description": "Expiration time. ISO 8601 string (e.g. `2026-01-01T00:00:00Z`) or Unix epoch seconds (e.g. `1735689599`). Omit for non-expiring key.", + "examples": [ + "2026-01-01T00:00:00Z", + 1735689599 + ] + } + }, + "type": "object", + "required": [ + "name", + "scopes" + ], + "title": "CreateApiKeyRequest", + "description": "Request body for POST /api/v1/keys." + }, + "CreateCustomDomainRequest": { + "properties": { + "fqdn": { + "type": "string", + "maxLength": 253, + "title": "Fqdn", + "description": "The fully-qualified domain to register (e.g. links.acme.com).", + "examples": [ + "links.acme.com" + ] + } + }, + "type": "object", + "required": [ + "fqdn" + ], + "title": "CreateCustomDomainRequest", + "description": "Body for ``POST /api/v1/custom-domains``." + }, + "CreateReportsRequest": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ReportItemRequest" + }, + "type": "array", + "title": "Items", + "description": "Reported links \u2014 anonymous \u2264 25 per request, authenticated \u2264 100" + }, + "reporter_email": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "null" + } + ], + "title": "Reporter Email", + "description": "Optional contact for resolution follow-up" + }, + "reporter_org": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "title": "Reporter Org", + "description": "Optional organisation name" + }, + "captcha_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Captcha Token", + "description": "hCaptcha response token \u2014 required for anonymous submissions when captcha is configured; ignored for authenticated callers" + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "CreateReportsRequest", + "description": "Request body for POST /api/v1/reports.\n\n``items`` size is validated in the service (anonymous \u2264 25,\nauthenticated \u2264 100; empty \u2192 400) because the cap depends on the\ncaller's auth state." + }, + "CreateUrlRequest": { + "properties": { + "long_url": { + "type": "string", + "maxLength": 8192, + "title": "Long Url", + "description": "The destination URL to shorten. Must be a valid http:// or https:// URL.", + "examples": [ + "https://example.com/very/long/url/path" + ] + }, + "alias": { + "anyOf": [ + { + "type": "string", + "maxLength": 64, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Alias", + "description": "Custom short code. Either alphanumeric (a-z, A-Z, 0-9, `_`, `-`; 3-16 chars) or emoji-only (1-15 fully-qualified emoji \u2014 no ZWJ sequences, flags, or keycaps). Auto-generated if omitted.", + "examples": [ + "mylink", + "\ud83d\ude80\ud83d\udd25" + ] + }, + "alias_type": { + "type": "string", + "enum": [ + "alphanumeric", + "emoji" + ], + "title": "Alias Type", + "description": "Alias style to auto-generate when `alias` is omitted. Ignored when `alias` is provided.", + "default": "alphanumeric" + }, + "password": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "minLength": 8 + }, + { + "type": "null" + } + ], + "title": "Password", + "description": "Password to protect the URL. Min 8 chars, must contain letter + number + special char.", + "examples": [ + "secure@123" + ] + }, + "block_bots": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Block Bots", + "description": "Block known bot user agents from accessing the URL." + }, + "max_clicks": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Clicks", + "description": "Maximum clicks before the URL expires. Must be positive.", + "examples": [ + 100 + ] + }, + "expire_after": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expire After", + "description": "Expiration time. ISO 8601 string (e.g. `2025-12-31T23:59:59Z`) or Unix epoch seconds (e.g. `1735689599`).", + "examples": [ + "2025-12-31T23:59:59Z", + 1735689599 + ] + }, + "private_stats": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Private Stats", + "description": "Make statistics private (only owner can view). Requires authentication." + }, + "domain": { + "anyOf": [ + { + "type": "string", + "maxLength": 253 + }, + { + "type": "null" + } + ], + "title": "Domain", + "description": "Custom domain fqdn to scope the short link under (e.g. `links.acme.com`). Requires authentication and ownership of an ACTIVE custom domain. Omit for the default spoo.me namespace.", + "examples": [ + "links.acme.com" + ] + }, + "geo_rules": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Geo Rules", + "description": "Per-country destination overrides: ISO 3166-1 alpha-2 country code \u2192 destination URL (at most 50 entries by default). Visitors from a listed country are redirected to that URL; everyone else gets the default destination (`url`). Requires authentication.", + "examples": [ + { + "IN": "https://example.in/", + "US": "https://example.com/us" + } + ] + }, + "meta_tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetaTagsRequest" + }, + { + "type": "null" + } + ], + "description": "Custom social preview served to link-preview crawlers (WhatsApp, Discord, Slack, iMessage, \u2026). The object replaces the whole setting; on PATCH pass null to remove. Requires a verified account with the feature enabled. Note: platforms cache previews for ~7-30 days \u2014 edits propagate slowly (the Facebook Sharing Debugger, LinkedIn Post Inspector, and Telegram's @WebpageBot force a refresh)." + } + }, + "type": "object", + "required": [ + "long_url" + ], + "title": "CreateUrlRequest", + "description": "Request body for creating a new shortened URL.\n\nAccepts ``url`` as an alias for ``long_url`` \u2014 the existing API supports both." + }, + "CreateWebhookEndpointRequest": { + "properties": { + "url": { + "type": "string", + "maxLength": 2048, + "title": "Url", + "description": "HTTPS URL that receives event deliveries.", + "examples": [ + "https://example.com/hooks/spoo" + ] + }, + "events": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 32, + "minItems": 1, + "title": "Events", + "description": "Event types or patterns to subscribe to. Supports `link.*`-style category wildcards and `*` for everything.", + "examples": [ + [ + "link.clicked", + "link.expired" + ] + ] + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "scope_links": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 256 + }, + { + "type": "null" + } + ], + "title": "Scope Links", + "description": "Link IDs to scope deliveries to. Omit (or null) for all links, including ones created later." + }, + "flavor": { + "$ref": "#/components/schemas/WebhookFlavor", + "description": "Payload presentation. `raw` is the documented contract.", + "default": "raw" + } + }, + "type": "object", + "required": [ + "url", + "events" + ], + "title": "CreateWebhookEndpointRequest" + }, + "CustomDomainDeleteResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "ID of the revoked domain." + }, + "fqdn": { + "type": "string", + "title": "Fqdn", + "description": "The revoked fqdn." + }, + "cascade": { + "type": "boolean", + "title": "Cascade", + "description": "Whether URLs on the domain were also deleted." + }, + "urls_deleted": { + "type": "integer", + "title": "Urls Deleted", + "description": "URL count deleted by cascade (0 when cascade=false)." + } + }, + "type": "object", + "required": [ + "id", + "fqdn", + "cascade", + "urls_deleted" + ], + "title": "CustomDomainDeleteResponse", + "description": "Response for domain revoke. ``urls_deleted`` is 0 unless cascade was true." + }, + "CustomDomainListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/CustomDomainResponse" + }, + "type": "array", + "title": "Items" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "pageSize": { + "type": "integer", + "title": "Pagesize" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "hasNext": { + "type": "boolean", + "title": "Hasnext" + } + }, + "type": "object", + "required": [ + "items", + "page", + "pageSize", + "total", + "hasNext" + ], + "title": "CustomDomainListResponse", + "description": "Paginated list of the caller's custom domains." + }, + "CustomDomainResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "Server-generated domain ID." + }, + "fqdn": { + "type": "string", + "title": "Fqdn", + "description": "Canonical fqdn (lowercased, trailing dot stripped)." + }, + "status": { + "$ref": "#/components/schemas/DomainStatus" + }, + "verification_method": { + "$ref": "#/components/schemas/VerificationMethod" + }, + "dns_records": { + "items": { + "$ref": "#/components/schemas/DnsRecord" + }, + "type": "array", + "title": "Dns Records", + "description": "DNS records the user must publish at their DNS provider." + }, + "setup_notes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Setup Notes", + "description": "Human-readable setup warnings/instructions specific to this domain." + }, + "created_at": { + "type": "string", + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + }, + "last_verified_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Verified At" + }, + "last_verification_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Verification Error" + }, + "root_redirect": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Root Redirect", + "description": "Destination for `GET /` on this domain. Honored only when status=ACTIVE." + }, + "not_found_redirect": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Not Found Redirect", + "description": "Fallback for non-alias paths. Honored only when status=ACTIVE." + }, + "custom_robots_txt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Custom Robots Txt", + "description": "Override body served at /robots.txt. Honored only when status=ACTIVE." + } + }, + "type": "object", + "required": [ + "id", + "fqdn", + "status", + "verification_method", + "created_at" + ], + "title": "CustomDomainResponse", + "description": "A single custom domain \u2014 covers create, get, verify, and list responses." + }, + "DeleteUrlResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message", + "description": "Confirmation message.", + "examples": [ + "URL deleted" + ] + }, + "id": { + "type": "string", + "title": "Id", + "description": "ID of the deleted URL.", + "examples": [ + "507f1f77bcf86cd799439011" + ] + } + }, + "type": "object", + "required": [ + "message", + "id" + ], + "title": "DeleteUrlResponse", + "description": "Response body for DELETE /api/v1/urls/{url_id}." + }, + "DeliveriesListResponse": { + "properties": { + "deliveries": { + "items": { + "$ref": "#/components/schemas/WebhookDeliveryResponse" + }, + "type": "array", + "title": "Deliveries" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "deliveries", + "total", + "page", + "page_size" + ], + "title": "DeliveriesListResponse" + }, + "DeliveryAttemptResponse": { + "properties": { + "attempted_at": { + "type": "integer", + "title": "Attempted At" + }, + "status_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Status Code" + }, + "duration_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Duration Ms" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "response_body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Body" + } + }, + "type": "object", + "required": [ + "attempted_at" + ], + "title": "DeliveryAttemptResponse" + }, + "DeliveryStatus": { + "type": "string", + "enum": [ + "pending", + "success", + "failed" + ], + "title": "DeliveryStatus" + }, + "DeviceRefreshRequest": { + "properties": { + "refresh_token": { + "type": "string", + "minLength": 1, + "title": "Refresh Token", + "description": "JWT refresh token issued by /auth/device/token" + } + }, + "type": "object", + "required": [ + "refresh_token" + ], + "title": "DeviceRefreshRequest", + "description": "Request body for POST /auth/device/refresh." + }, + "DeviceRefreshResponse": { + "properties": { + "access_token": { + "type": "string", + "title": "Access Token", + "description": "New JWT access token" + }, + "refresh_token": { + "type": "string", + "title": "Refresh Token", + "description": "New JWT refresh token" + } + }, + "type": "object", + "required": [ + "access_token", + "refresh_token" + ], + "title": "DeviceRefreshResponse", + "description": "Response body for POST /auth/device/refresh (200)." + }, + "DeviceTokenRequest": { + "properties": { + "code": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "title": "Code", + "description": "One-time auth code from the device callback page" + }, + "code_verifier": { + "type": "string", + "maxLength": 128, + "minLength": 43, + "pattern": "^[A-Za-z0-9\\-._~]+$", + "title": "Code Verifier", + "description": "PKCE code verifier (RFC 7636). Must hash (S256) to the code_challenge sent at /auth/device/login." + } + }, + "type": "object", + "required": [ + "code", + "code_verifier" + ], + "title": "DeviceTokenRequest", + "description": "Request body for POST /auth/device/token." + }, + "DeviceTokenResponse": { + "properties": { + "access_token": { + "type": "string", + "title": "Access Token", + "description": "JWT access token" + }, + "refresh_token": { + "type": "string", + "title": "Refresh Token", + "description": "JWT refresh token" + }, + "user": { + "$ref": "#/components/schemas/UserProfileResponse", + "description": "User profile" + } + }, + "type": "object", + "required": [ + "access_token", + "refresh_token", + "user" + ], + "title": "DeviceTokenResponse", + "description": "Response body for POST /auth/device/token (200)." + }, + "DnsRecord": { + "properties": { + "type": { + "type": "string", + "enum": [ + "CNAME", + "TXT", + "A" + ], + "title": "Type", + "description": "Record type." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Record name (subdomain or @ for apex)." + }, + "value": { + "type": "string", + "title": "Value", + "description": "Record value." + }, + "purpose": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Purpose", + "description": "Human-readable note explaining why this record is needed." + } + }, + "type": "object", + "required": [ + "type", + "name", + "value" + ], + "title": "DnsRecord", + "description": "One DNS record the user must publish to complete setup." + }, + "DomainStatus": { + "type": "string", + "enum": [ + "pending", + "verifying", + "active", + "suspended", + "revoked" + ], + "title": "DomainStatus", + "description": "Lifecycle state of a custom domain registration." + }, + "EmojiEntry": { + "properties": { + "c": { + "type": "string", + "title": "C", + "description": "Raw canonical emoji character (no U+FE0F variation selector), matching how aliases are stored and echoed." + }, + "n": { + "type": "string", + "title": "N", + "description": "Human-readable name, lowercased with spaces (e.g. \"rocket\"). The primary search key." + }, + "g": { + "type": "string", + "title": "G", + "description": "Canonical Unicode category display name (e.g. \"Smileys & Emotion\"), for the picker's category tabs. Entries in the array are already sorted by canonical group and within-group order, so a picker opens on Smileys rather than symbols." + }, + "gen": { + "type": "boolean", + "title": "Gen", + "description": "Whether this emoji is in the auto-generation pool. Filter gen=true for the subset the server auto-generates." + }, + "k": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "K", + "description": "Extra search aliases when the source lists any (e.g. \"tada\" for the party popper); omitted otherwise. Name search is the floor; these only widen it." + } + }, + "type": "object", + "required": [ + "c", + "n", + "g", + "gen" + ], + "title": "EmojiEntry", + "description": "One accepted emoji, enriched for client-side search.\n\nNames and aliases come from the same pinned ``emoji`` package the set\nitself is derived from, so there is no second dataset to keep in sync." + }, + "EmojiSetResponse": { + "properties": { + "accept_max_version": { + "type": "number", + "title": "Accept Max Version", + "description": "Newest Unicode emoji version a custom alias may use." + }, + "generate_max_version": { + "type": "number", + "title": "Generate Max Version", + "description": "Cap for auto-generated emoji aliases (lower, for older platform coverage)." + }, + "max_graphemes": { + "type": "integer", + "title": "Max Graphemes", + "description": "Maximum number of emoji graphemes allowed in one alias." + }, + "emoji": { + "items": { + "$ref": "#/components/schemas/EmojiEntry" + }, + "type": "array", + "title": "Emoji", + "description": "Every single-codepoint emoji a user may choose, at the acceptance cap, each with its name and whether it is in the generation pool. This is the picker's list. Skin-tone variants are NOT enumerated: the base emoji suffices and skin tone is a client-side modifier appended to the base." + } + }, + "type": "object", + "required": [ + "accept_max_version", + "generate_max_version", + "max_graphemes", + "emoji" + ], + "title": "EmojiSetResponse", + "description": "The accepted emoji catalogue and its policy caps.\n\nEmoji values are RAW characters in canonical form (no ``U+FE0F``). Each\ncarries its canonical Unicode category (``g``) and the array is ordered by\ncanonical group and within-group order, so a picker can render category\ntabs and open on Smileys rather than symbols." + }, + "ErrorResponse": { + "properties": { + "error": { + "type": "string", + "title": "Error" + }, + "code": { + "type": "string", + "title": "Code" + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Field" + }, + "details": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Details" + } + }, + "type": "object", + "required": [ + "error", + "code" + ], + "title": "ErrorResponse", + "description": "Standard error JSON body produced by the AppError exception handler." + }, + "EventTypeInfoResponse": { + "properties": { + "type": { + "type": "string", + "title": "Type" + }, + "category": { + "type": "string", + "title": "Category" + }, + "description": { + "type": "string", + "title": "Description" + }, + "frequency": { + "type": "string", + "title": "Frequency" + }, + "sample": { + "additionalProperties": true, + "type": "object", + "title": "Sample" + } + }, + "type": "object", + "required": [ + "type", + "category", + "description", + "frequency", + "sample" + ], + "title": "EventTypeInfoResponse" + }, + "EventTypesResponse": { + "properties": { + "event_types": { + "items": { + "$ref": "#/components/schemas/EventTypeInfoResponse" + }, + "type": "array", + "title": "Event Types" + } + }, + "type": "object", + "required": [ + "event_types" + ], + "title": "EventTypesResponse" + }, + "FeatureState": { + "type": "string", + "enum": [ + "enabled", + "locked", + "hidden" + ], + "title": "FeatureState", + "description": "What a client should do with a gated feature's UI.\n\nENABLED \u2014 render it; the account has the feature.\nHIDDEN \u2014 render nothing; the feature does not exist for this account.\nLOCKED \u2014 render it in a locked/upsell state. Reserved: no backend\n policy emits it until paid plans exist, but it is part of the\n contract from day one so entitlements later are a data change,\n not an API version bump." + }, + "FeaturesResponse": { + "properties": { + "features": { + "additionalProperties": { + "$ref": "#/components/schemas/FeatureState" + }, + "type": "object", + "title": "Features" + } + }, + "type": "object", + "required": [ + "features" + ], + "title": "FeaturesResponse", + "description": "Per-feature availability for the authenticated account.\n\nKeys are the exposed feature names (``services.feature_flag_service.\nEXPOSED_FEATURES``); values tell the client what to render. Clients\nmust treat unknown keys as informational and missing keys as HIDDEN,\nso the feature list can grow without breaking older frontends." + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "HealthChecks": { + "properties": { + "mongodb": { + "type": "string", + "enum": [ + "ok", + "error", + "not_configured" + ], + "title": "Mongodb" + }, + "redis": { + "type": "string", + "enum": [ + "ok", + "error", + "not_configured" + ], + "title": "Redis" + } + }, + "type": "object", + "required": [ + "mongodb", + "redis" + ], + "title": "HealthChecks", + "description": "Individual service check statuses inside HealthResponse." + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "version": { + "type": "string", + "title": "Version" + }, + "checks": { + "$ref": "#/components/schemas/HealthChecks" + } + }, + "type": "object", + "required": [ + "status", + "version", + "checks" + ], + "title": "HealthResponse", + "description": "Response body for GET /health." + }, + "LayoutResponse": { + "properties": { + "layout": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Layout", + "description": "Saved layout doc, or null when no override exists" + } + }, + "type": "object", + "title": "LayoutResponse" + }, + "LinkStatsResponse": { + "properties": { + "scope": { + "$ref": "#/components/schemas/StatsScope" + }, + "filters": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object", + "title": "Filters" + }, + "group_by": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Group By" + }, + "timezone": { + "type": "string", + "title": "Timezone" + }, + "time_range": { + "$ref": "#/components/schemas/StatsTimeRange" + }, + "summary": { + "$ref": "#/components/schemas/StatsSummary" + }, + "metrics": { + "additionalProperties": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "type": "object", + "title": "Metrics", + "description": "Keyed by '{metric}_by_{dimension}' (e.g. 'clicks_by_browser', 'unique_clicks_by_time'). Each value is a list of data-point objects whose keys are the dimension name, the metric name, and '{metric}_percentage'." + }, + "generated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Generated At" + }, + "api_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Version" + }, + "short_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Short Code" + }, + "time_bucket_info": { + "anyOf": [ + { + "$ref": "#/components/schemas/TimeBucketInfo" + }, + { + "type": "null" + } + ] + }, + "computed_metrics": { + "anyOf": [ + { + "$ref": "#/components/schemas/ComputedMetrics" + }, + { + "type": "null" + } + ] + }, + "url_id": { + "type": "string", + "title": "Url Id" + }, + "alias": { + "type": "string", + "title": "Alias" + } + }, + "type": "object", + "required": [ + "scope", + "filters", + "group_by", + "timezone", + "time_range", + "summary", + "url_id", + "alias" + ], + "title": "LinkStatsResponse", + "description": "Response body for GET /api/v1/stats/links/{url_id}.\n\nThe standard stats wire plus the identity of the selected link.\n``scope`` stays ``all`` \u2014 the link is part of the owner's aggregate." + }, + "LoginRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email", + "description": "Account email address", + "examples": [ + "user@example.com" + ] + }, + "password": { + "type": "string", + "maxLength": 255, + "title": "Password", + "description": "Account password", + "examples": [ + "MySecurePass123!" + ] + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "title": "LoginRequest", + "description": "Request body for POST /auth/login." + }, + "LoginResponse": { + "properties": { + "access_token": { + "type": "string", + "title": "Access Token", + "description": "JWT access token", + "examples": [ + "eyJhbGciOiJIUzI1NiIs..." + ] + }, + "user": { + "$ref": "#/components/schemas/UserProfileResponse", + "description": "Authenticated user's profile" + } + }, + "type": "object", + "required": [ + "access_token", + "user" + ], + "title": "LoginResponse", + "description": "Response body for POST /auth/login (200)." + }, + "LogoutResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "description": "Always true on successful logout" + } + }, + "type": "object", + "required": [ + "success" + ], + "title": "LogoutResponse", + "description": "Response body for POST /auth/logout (200)." + }, + "MeResponse": { + "properties": { + "user": { + "$ref": "#/components/schemas/UserProfileResponse", + "description": "Current authenticated user's profile" + } + }, + "type": "object", + "required": [ + "user" + ], + "title": "MeResponse", + "description": "Response body for GET /auth/me (200)." + }, + "MessageResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + } + }, + "type": "object", + "required": [ + "success" + ], + "title": "MessageResponse", + "description": "Generic success/message response returned by several endpoints." + }, + "MetaTagsRequest": { + "properties": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Title", + "description": "Preview headline (og:title). Required when meta_tags is set.", + "examples": [ + "We just launched \ud83c\udf89" + ] + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 240 + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "og:description \u2014 roughly 200 chars render on most platforms." + }, + "image": { + "anyOf": [ + { + "type": "string", + "maxLength": 700000 + }, + { + "type": "null" + } + ], + "title": "Image", + "description": "og:image \u2014 an https URL, or a `data:image/png|jpeg|webp;base64,` URI which is validated and stored on spoo's CDN. 1200x630 recommended; keep it under 300KB or WhatsApp silently drops it; SVG is rejected (no preview crawler renders it).", + "examples": [ + "https://example.com/og.png" + ] + }, + "color": { + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + { + "type": "null" + } + ], + "title": "Color", + "description": "Accent color shown on Discord embeds (theme-color).", + "examples": [ + "#FF5733" + ] + } + }, + "type": "object", + "required": [ + "title" + ], + "title": "MetaTagsRequest", + "description": "Custom social preview (og:title / og:description / og:image / theme-color)." + }, + "MetaTagsResponse": { + "properties": { + "title": { + "type": "string", + "title": "Title", + "description": "og:title.", + "examples": [ + "We just launched \ud83c\udf89" + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "og:description." + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Image", + "description": "og:image URL." + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Color", + "description": "Discord embed accent color.", + "examples": [ + "#FF5733" + ] + }, + "warnings": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Warnings", + "description": "Non-fatal quality notes, e.g. an image WhatsApp may drop." + } + }, + "type": "object", + "required": [ + "title" + ], + "title": "MetaTagsResponse", + "description": "Custom social-preview settings on a URL (client-visible fields only)." + }, + "MetadataResponse": { + "properties": { + "url": { + "type": "string", + "title": "Url", + "description": "The URL that was requested." + }, + "final_url": { + "type": "string", + "title": "Final Url", + "description": "URL after following redirects." + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Image", + "description": "Absolute https URL." + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Color", + "description": "theme-color if #RRGGBB." + }, + "site_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Name" + }, + "og": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Og" + }, + "twitter": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Twitter" + }, + "fetched_at": { + "type": "string", + "format": "date-time", + "title": "Fetched At" + } + }, + "type": "object", + "required": [ + "url", + "final_url", + "fetched_at" + ], + "title": "MetadataResponse", + "description": "Parsed meta tags of a destination page.\n\n``title``/``description``/``image``/``color``/``site_name`` are the\nnormalized best-picks (og \u2192 twitter \u2192 html fallbacks) ready to prefill\na link's ``meta_tags``; ``og``/``twitter`` carry the raw families." + }, + "OAuthProvider": { + "type": "string", + "enum": [ + "google", + "github", + "discord" + ], + "title": "OAuthProvider", + "description": "Supported OAuth providers." + }, + "OAuthProviderDetail": { + "properties": { + "provider": { + "$ref": "#/components/schemas/OAuthProvider", + "description": "Provider name", + "examples": [ + "google" + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email", + "description": "Email from provider" + }, + "email_verified": { + "type": "boolean", + "title": "Email Verified", + "description": "Email verified by provider", + "default": false + }, + "linked_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Linked At", + "description": "When the provider was linked" + }, + "profile": { + "$ref": "#/components/schemas/ProviderProfile", + "description": "Provider profile (name, picture)" + } + }, + "type": "object", + "required": [ + "provider" + ], + "title": "OAuthProviderDetail", + "description": "Detailed OAuth provider entry for the providers list endpoint." + }, + "OAuthProvidersResponse": { + "properties": { + "providers": { + "items": { + "$ref": "#/components/schemas/OAuthProviderDetail" + }, + "type": "array", + "title": "Providers", + "description": "List of linked OAuth providers with name, email, and linked_at" + }, + "password_set": { + "type": "boolean", + "title": "Password Set", + "description": "Whether the user has a password set (affects unlink eligibility)" + } + }, + "type": "object", + "required": [ + "providers", + "password_set" + ], + "title": "OAuthProvidersResponse", + "description": "Response body for GET /oauth/providers (200)." + }, + "OnboardingCompleteRequest": { + "properties": { + "heard_from": { + "anyOf": [ + { + "type": "string", + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "Heard From", + "description": "HDYHAU attribution answer, captured once at completion", + "examples": [ + "GitHub" + ] + } + }, + "type": "object", + "title": "OnboardingCompleteRequest", + "description": "Request body for POST /auth/onboarding/complete." + }, + "OnboardingCompleteResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "description": "Always true on success" + }, + "onboarded_at": { + "type": "string", + "title": "Onboarded At", + "description": "When onboarding was completed (first completion wins)" + } + }, + "type": "object", + "required": [ + "success", + "onboarded_at" + ], + "title": "OnboardingCompleteResponse", + "description": "Response body for POST /auth/onboarding/complete (200)." + }, + "OnboardingStateRequest": { + "properties": { + "step": { + "type": "string", + "enum": [ + "welcome", + "path", + "link", + "api", + "domain", + "claim", + "apps", + "recap" + ], + "title": "Step", + "description": "Wizard step the user is currently on", + "examples": [ + "domain" + ] + }, + "path": { + "anyOf": [ + { + "type": "string", + "enum": [ + "links", + "api" + ] + }, + { + "type": "null" + } + ], + "title": "Path", + "description": "Chosen onboarding path, once the user has forked", + "examples": [ + "links" + ] + } + }, + "type": "object", + "required": [ + "step" + ], + "title": "OnboardingStateRequest", + "description": "Request body for PUT /auth/onboarding." + }, + "OnboardingStateResponse": { + "properties": { + "step": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Step", + "description": "Stored wizard step", + "examples": [ + "link" + ] + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path", + "description": "Chosen path (links or api)", + "examples": [ + "links" + ] + } + }, + "type": "object", + "title": "OnboardingStateResponse", + "description": "Response body for GET/PUT /auth/onboarding (200).\n\nA resume pointer, nothing more. Empty (step=null) means nothing to\nresume: never started, expired, or already completed \u2014 completion is\na permanent account fact exposed as ``user.onboarded_at`` on\n/auth/me, not part of this cache." + }, + "PreviewDestination": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "domain": { + "type": "string", + "title": "Domain" + }, + "path": { + "type": "string", + "title": "Path" + }, + "is_https": { + "type": "boolean", + "title": "Is Https" + } + }, + "type": "object", + "required": [ + "url", + "domain", + "path", + "is_https" + ], + "title": "PreviewDestination", + "description": "A destination URL split into display parts." + }, + "PreviewGeoDestination": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "domain": { + "type": "string", + "title": "Domain" + }, + "path": { + "type": "string", + "title": "Path" + }, + "is_https": { + "type": "boolean", + "title": "Is Https" + }, + "countries": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Countries" + } + }, + "type": "object", + "required": [ + "url", + "domain", + "path", + "is_https", + "countries" + ], + "title": "PreviewGeoDestination", + "description": "One geo-rule destination group.\n\n``countries`` are ISO 3166-1 alpha-2 codes, sorted ascending \u2014 every\nrule is listed, nothing summarized (anti-cloaking transparency)." + }, + "ProfilePictureMessageResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "ProfilePictureMessageResponse" + }, + "ProviderProfile": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "picture": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Picture" + } + }, + "type": "object", + "title": "ProviderProfile", + "description": "Nested profile data stored per OAuth provider." + }, + "PublicLinkFacts": { + "properties": { + "alias": { + "type": "string", + "title": "Alias" + }, + "short_url": { + "type": "string", + "title": "Short Url" + }, + "long_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Long Url" + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive", + "expired", + "blocked" + ], + "title": "Status" + }, + "max_clicks": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Clicks" + }, + "block_bots": { + "type": "boolean", + "title": "Block Bots" + }, + "password_protected": { + "type": "boolean", + "title": "Password Protected" + } + }, + "type": "object", + "required": [ + "alias", + "short_url", + "status", + "block_bots", + "password_protected" + ], + "title": "PublicLinkFacts", + "description": "Public facts about the link shown above the charts." + }, + "PublicPreviewResponse": { + "properties": { + "generation": { + "type": "string", + "enum": [ + "v1", + "v2" + ], + "title": "Generation" + }, + "alias": { + "type": "string", + "title": "Alias" + }, + "short_url": { + "type": "string", + "title": "Short Url" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive", + "expired", + "blocked" + ], + "title": "Status" + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "password_protected": { + "type": "boolean", + "title": "Password Protected" + }, + "destination": { + "anyOf": [ + { + "$ref": "#/components/schemas/PreviewDestination" + }, + { + "type": "null" + } + ] + }, + "geo_destinations": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PreviewGeoDestination" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Geo Destinations" + } + }, + "type": "object", + "required": [ + "generation", + "alias", + "short_url", + "status", + "created_at", + "password_protected", + "destination", + "geo_destinations" + ], + "title": "PublicPreviewResponse", + "description": "Response body for the public link preview endpoint.\n\n``destination`` and ``geo_destinations`` are non-null only while the\nlink is active and not password-protected \u2014 the preview never reveals\na destination the redirect would refuse to serve." + }, + "PublicStatsBody": { + "properties": { + "password": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "title": "Password", + "description": "Password for a password-protected link's stats." + } + }, + "type": "object", + "title": "PublicStatsBody", + "description": "Optional JSON body carrying the stats-page password." + }, + "PublicStatsResponse": { + "properties": { + "generation": { + "type": "string", + "enum": [ + "v1", + "v2" + ], + "title": "Generation" + }, + "link": { + "$ref": "#/components/schemas/PublicLinkFacts" + }, + "stats": { + "additionalProperties": true, + "type": "object", + "title": "Stats", + "description": "The modern stats wire shape (same as GET /api/v1/stats): summary, metrics keyed '{metric}_by_{dimension}', time_range, time_bucket_info, computed_metrics. v1 links carry a 'clicks_by_bots' dimension and no 'city'/'device'; v2 the reverse." + } + }, + "type": "object", + "required": [ + "generation", + "link", + "stats" + ], + "title": "PublicStatsResponse", + "description": "Response body for GET|POST /api/v1/public/stats/{short_code}." + }, + "PutLayoutRequest": { + "properties": { + "layout": { + "additionalProperties": true, + "type": "object", + "title": "Layout", + "description": "Opaque layout document owned by the client. The server stores it verbatim; versioning happens inside the document.", + "examples": [ + { + "version": 1, + "widgets": [] + } + ] + } + }, + "type": "object", + "required": [ + "layout" + ], + "title": "PutLayoutRequest" + }, + "RefreshResponse": { + "properties": { + "access_token": { + "type": "string", + "title": "Access Token", + "description": "New JWT access token", + "examples": [ + "eyJhbGciOiJIUzI1NiIs..." + ] + } + }, + "type": "object", + "required": [ + "access_token" + ], + "title": "RefreshResponse", + "description": "Response body for POST /auth/refresh (200)." + }, + "RegisterRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email", + "description": "Email address for the new account", + "examples": [ + "newuser@example.com" + ] + }, + "password": { + "type": "string", + "maxLength": 128, + "title": "Password", + "description": "Password (min 8 chars, must contain letter + number + special char)", + "examples": [ + "MySecurePass123!" + ] + }, + "user_name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "User Name", + "description": "Display name (optional)", + "examples": [ + "Jane Doe" + ] + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "title": "RegisterRequest", + "description": "Request body for POST /auth/register." + }, + "RegisterResponse": { + "properties": { + "access_token": { + "type": "string", + "title": "Access Token", + "description": "JWT access token", + "examples": [ + "eyJhbGciOiJIUzI1NiIs..." + ] + }, + "user": { + "$ref": "#/components/schemas/UserProfileResponse", + "description": "Newly created user's profile" + }, + "requires_verification": { + "type": "boolean", + "title": "Requires Verification", + "description": "Whether email verification is required before accessing protected resources" + }, + "verification_sent": { + "type": "boolean", + "title": "Verification Sent", + "description": "Whether the verification email was sent successfully" + } + }, + "type": "object", + "required": [ + "access_token", + "user", + "requires_verification", + "verification_sent" + ], + "title": "RegisterResponse", + "description": "Response body for POST /auth/register (201)." + }, + "RejectedReportItem": { + "properties": { + "index": { + "type": "integer", + "title": "Index" + }, + "input": { + "type": "string", + "title": "Input" + }, + "code": { + "type": "string", + "enum": [ + "invalid_input", + "not_found", + "duplicate_in_batch" + ], + "title": "Code" + } + }, + "type": "object", + "required": [ + "index", + "input", + "code" + ], + "title": "RejectedReportItem", + "description": "One rejected entry in the per-item breakdown.\n\n``index`` refers to the item's position in the submitted ``items``\narray; ``input`` echoes the raw ``code_or_url`` so bulk clients can\nline results up without keeping their own index map." + }, + "ReportItemRequest": { + "properties": { + "code_or_url": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "title": "Code Or Url", + "description": "Bare short code or full short URL \u2014 custom-domain URLs included (e.g. `abc123`, `spoo.me/abc123`, `https://go.customer.com/deal`)", + "examples": [ + "abc123", + "https://spoo.me/abc123" + ] + }, + "reason": { + "$ref": "#/components/schemas/ReportReason", + "description": "Reporter-claimed reason (triage hint)" + }, + "details": { + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ], + "title": "Details", + "description": "Optional free-text context" + }, + "vector": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReportVector" + }, + { + "type": "null" + } + ], + "description": "How the link reached the reporter" + } + }, + "type": "object", + "required": [ + "code_or_url", + "reason" + ], + "title": "ReportItemRequest", + "description": "One reported link inside POST /api/v1/reports." + }, + "ReportReason": { + "type": "string", + "enum": [ + "phishing", + "malware", + "spam", + "illegal_content", + "other" + ], + "title": "ReportReason", + "description": "Reporter-claimed reason, aligned with the safety framework's harm\ntiers so intake slots into the funnel without a translation layer." + }, + "ReportSubmissionResponse": { + "properties": { + "submission_id": { + "type": "string", + "title": "Submission Id", + "description": "Opaque submission reference" + }, + "accepted": { + "type": "integer", + "title": "Accepted", + "description": "Number of items stored" + }, + "rejected": { + "items": { + "$ref": "#/components/schemas/RejectedReportItem" + }, + "type": "array", + "title": "Rejected", + "description": "Per-item rejections (empty when everything was accepted)" + } + }, + "type": "object", + "required": [ + "submission_id", + "accepted", + "rejected" + ], + "title": "ReportSubmissionResponse", + "description": "Response body for POST /api/v1/reports.\n\nBad codes don't sink the batch: accepted items are stored even when\nothers are rejected, and the breakdown says which and why." + }, + "ReportVector": { + "type": "string", + "enum": [ + "sms", + "email", + "dm", + "social", + "web", + "other" + ], + "title": "ReportVector", + "description": "How the link reached the reporter \u2014 the delivery-vector hint the\n451 page's scam-awareness story needs." + }, + "RequestPasswordResetRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email", + "description": "Email address of the account to reset", + "examples": [ + "user@example.com" + ] + } + }, + "type": "object", + "required": [ + "email" + ], + "title": "RequestPasswordResetRequest", + "description": "Request body for POST /auth/request-password-reset." + }, + "ResetPasswordRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email", + "description": "Email address of the account", + "examples": [ + "user@example.com" + ] + }, + "code": { + "type": "string", + "pattern": "^[0-9]{6}$", + "title": "Code", + "description": "6-digit OTP from password reset email", + "examples": [ + "123456" + ] + }, + "password": { + "type": "string", + "maxLength": 128, + "title": "Password", + "description": "New password (min 8 chars, must contain letter + number + special char)", + "examples": [ + "NewSecurePass456!" + ] + } + }, + "type": "object", + "required": [ + "email", + "code", + "password" + ], + "title": "ResetPasswordRequest", + "description": "Request body for POST /auth/reset-password." + }, + "SendVerificationResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "description": "Whether the verification email was sent" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable status message" + }, + "expires_in": { + "type": "integer", + "title": "Expires In", + "description": "OTP expiry duration in seconds", + "examples": [ + 600 + ] + } + }, + "type": "object", + "required": [ + "success", + "message", + "expires_in" + ], + "title": "SendVerificationResponse", + "description": "Response body for POST /auth/send-verification (200)." + }, + "SetPasswordRequest": { + "properties": { + "password": { + "type": "string", + "maxLength": 128, + "title": "Password", + "description": "New password (min 8 chars, must contain letter + number + special char)", + "examples": [ + "MySecurePass123!" + ] + } + }, + "type": "object", + "required": [ + "password" + ], + "title": "SetPasswordRequest", + "description": "Request body for POST /auth/set-password.\n\nOnly applies to OAuth-only users who have not yet set a password." + }, + "SetProfilePictureRequest": { + "properties": { + "picture_id": { + "type": "string", + "maxLength": 200, + "minLength": 1, + "title": "Picture Id" + } + }, + "type": "object", + "required": [ + "picture_id" + ], + "title": "SetProfilePictureRequest" + }, + "StatsResponse": { + "properties": { + "scope": { + "$ref": "#/components/schemas/StatsScope" + }, + "filters": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object", + "title": "Filters" + }, + "group_by": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Group By" + }, + "timezone": { + "type": "string", + "title": "Timezone" + }, + "time_range": { + "$ref": "#/components/schemas/StatsTimeRange" + }, + "summary": { + "$ref": "#/components/schemas/StatsSummary" + }, + "metrics": { + "additionalProperties": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "type": "object", + "title": "Metrics", + "description": "Keyed by '{metric}_by_{dimension}' (e.g. 'clicks_by_browser', 'unique_clicks_by_time'). Each value is a list of data-point objects whose keys are the dimension name, the metric name, and '{metric}_percentage'." + }, + "generated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Generated At" + }, + "api_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Version" + }, + "short_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Short Code" + }, + "time_bucket_info": { + "anyOf": [ + { + "$ref": "#/components/schemas/TimeBucketInfo" + }, + { + "type": "null" + } + ] + }, + "computed_metrics": { + "anyOf": [ + { + "$ref": "#/components/schemas/ComputedMetrics" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "scope", + "filters", + "group_by", + "timezone", + "time_range", + "summary" + ], + "title": "StatsResponse", + "description": "Response body for GET /api/v1/stats.\n\n``metrics`` uses dynamic keys ({metric}_by_{dimension}), each mapping to a\nlist of data-point dicts. Optional fields (``short_code``,\n``time_bucket_info``, ``computed_metrics``) are absent when not applicable." + }, + "StatsScope": { + "type": "string", + "enum": [ + "all", + "anon" + ], + "title": "StatsScope", + "description": "Stats wire scope.\n\nResponse-only: the ``scope`` request parameter no longer exists (auth\nis mandatory), but the response wire keeps its ``scope`` key and the\npublic stats endpoint's frozen contract still carries ``anon``." + }, + "StatsSummary": { + "properties": { + "total_clicks": { + "type": "integer", + "title": "Total Clicks" + }, + "unique_clicks": { + "type": "integer", + "title": "Unique Clicks" + }, + "first_click": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "First Click" + }, + "last_click": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Click" + }, + "avg_redirection_time": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Avg Redirection Time" + } + }, + "type": "object", + "required": [ + "total_clicks", + "unique_clicks" + ], + "title": "StatsSummary", + "description": "Summary statistics block inside StatsResponse." + }, + "StatsTimeRange": { + "properties": { + "start_date": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Start Date" + }, + "end_date": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + "type": "object", + "title": "StatsTimeRange", + "description": "Time range metadata inside StatsResponse." + }, + "TestWebhookRequest": { + "properties": { + "event_type": { + "type": "string", + "title": "Event Type", + "description": "Any catalog event type \u2014 its documented sample payload is sent through the real pipeline \u2014 or `webhook.test`.", + "default": "webhook.test", + "examples": [ + "link.clicked" + ] + } + }, + "type": "object", + "title": "TestWebhookRequest" + }, + "TimeBucketInfo": { + "properties": { + "strategy": { + "type": "string", + "title": "Strategy" + }, + "mongo_format": { + "type": "string", + "title": "Mongo Format" + }, + "display_format": { + "type": "string", + "title": "Display Format" + }, + "timezone": { + "type": "string", + "title": "Timezone" + }, + "interval_minutes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Interval Minutes" + } + }, + "type": "object", + "required": [ + "strategy", + "mongo_format", + "display_format", + "timezone" + ], + "title": "TimeBucketInfo", + "description": "Time bucketing metadata \u2014 only present when 'time' is in group_by." + }, + "UpdateCustomDomainRequest": { + "properties": { + "root_redirect": { + "anyOf": [ + { + "type": "string", + "maxLength": 2083, + "minLength": 1, + "format": "uri" + }, + { + "type": "null" + } + ], + "title": "Root Redirect", + "description": "Destination URL for `GET /` on the custom domain. Returns 302. Pass `null` to clear. Field omitted leaves current value.", + "examples": [ + "https://acme.com/landing" + ] + }, + "not_found_redirect": { + "anyOf": [ + { + "type": "string", + "maxLength": 2083, + "minLength": 1, + "format": "uri" + }, + { + "type": "null" + } + ], + "title": "Not Found Redirect", + "description": "Fallback URL for any path not matching an alias. Returns 302 instead of the default 404. Pass `null` to clear.", + "examples": [ + "https://acme.com/404" + ] + }, + "custom_robots_txt": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "title": "Custom Robots Txt", + "description": "Override the default `User-agent: *\\nDisallow: /` body served at /robots.txt. Capped at 4096 chars. Pass `null` to clear. Note: responses to alias redirects always carry `X-Robots-Tag: noindex, nofollow, noarchive` regardless of this field \u2014 short URLs are pure redirects with no indexable content." + } + }, + "type": "object", + "title": "UpdateCustomDomainRequest", + "description": "Body for ``PATCH /api/v1/custom-domains/{id}``.\n\nPartial update: callers send only the fields they want to change.\nField omitted = leave doc value as-is. Field explicitly ``null`` = clear\nthe stored value. The service distinguishes the two via ``model_fields_set``." + }, + "UpdateProfileRequest": { + "properties": { + "user_name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "User Name", + "description": "New display name, or null to clear it", + "examples": [ + "Jane Doe" + ] + } + }, + "type": "object", + "required": [ + "user_name" + ], + "title": "UpdateProfileRequest", + "description": "Request body for PATCH /auth/me.\n\n``user_name`` is required but nullable: an explicit ``null`` clears\nthe display name (an accidental ``{}`` must not). Bounds mirror\nRegisterRequest \u2014 the two write paths for the same field." + }, + "UpdateUrlRequest": { + "properties": { + "long_url": { + "anyOf": [ + { + "type": "string", + "maxLength": 8192 + }, + { + "type": "null" + } + ], + "title": "Long Url", + "description": "New destination URL. Must be a valid http:// or https:// URL.", + "examples": [ + "https://example.com/updated/url" + ] + }, + "alias": { + "anyOf": [ + { + "type": "string", + "maxLength": 64, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Alias", + "description": "New custom short code (alphanumeric 3-16 chars, or emoji-only 1-15 emoji). Pass `null` to keep existing. Must be unique and available.", + "examples": [ + "newlink", + "\ud83d\ude80\ud83d\udd25" + ] + }, + "password": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "minLength": 8 + }, + { + "type": "null" + } + ], + "title": "Password", + "description": "New password. Pass `null` to remove password protection.", + "examples": [ + "newPass@456" + ] + }, + "block_bots": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Block Bots", + "description": "Block known bot user agents. Pass `null` to keep existing setting." + }, + "max_clicks": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Clicks", + "description": "New click limit. Pass `0` or `null` to remove the limit.", + "examples": [ + 500 + ] + }, + "expire_after": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expire After", + "description": "Expiration time. ISO 8601 string (e.g. `2025-12-31T23:59:59Z`) or Unix epoch seconds (e.g. `1735689599`). Pass `null` to remove.", + "examples": [ + "2025-12-31T23:59:59Z", + 1735689599 + ] + }, + "private_stats": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Private Stats", + "description": "Make statistics private (only owner can view). Pass `null` to keep existing." + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE" + ] + }, + { + "type": "null" + } + ], + "title": "Status", + "description": "URL status. ACTIVE enables redirects, INACTIVE disables them.", + "examples": [ + "ACTIVE" + ] + }, + "domain": { + "anyOf": [ + { + "type": "string", + "maxLength": 253 + }, + { + "type": "null" + } + ], + "title": "Domain", + "description": "Move the URL to a different domain namespace. Pass an owned ACTIVE custom-domain fqdn (e.g. `links.acme.com`) to move it there, or `null`/empty to move it back to the system default. Alias must be available on the target domain.", + "examples": [ + "links.acme.com" + ] + }, + "geo_rules": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Geo Rules", + "description": "Per-country destination overrides: ISO 3166-1 alpha-2 country code \u2192 destination URL (at most 50 entries by default). The map replaces any existing rules in full. Pass `null` or `{}` to remove all rules; omit to keep existing rules unchanged.", + "examples": [ + { + "IN": "https://example.in/", + "US": "https://example.com/us" + } + ] + }, + "meta_tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetaTagsRequest" + }, + { + "type": "null" + } + ], + "description": "Custom social preview served to link-preview crawlers (WhatsApp, Discord, Slack, iMessage, \u2026). The object replaces the whole setting; on PATCH pass null to remove. Requires a verified account with the feature enabled. Note: platforms cache previews for ~7-30 days \u2014 edits propagate slowly (the Facebook Sharing Debugger, LinkedIn Post Inspector, and Telegram's @WebpageBot force a refresh)." + } + }, + "type": "object", + "title": "UpdateUrlRequest", + "description": "Request body for partially updating an existing shortened URL.\n\nAll fields are optional; only provided fields are updated.\nPass ``max_clicks=0`` or ``max_clicks=null`` to remove the limit.\nPass ``password=null`` (or omit) to remove password protection." + }, + "UpdateUrlResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "MongoDB ObjectId of the URL.", + "examples": [ + "507f1f77bcf86cd799439011" + ] + }, + "alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Alias", + "description": "Short code.", + "examples": [ + "mylink" + ] + }, + "long_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Long Url", + "description": "Destination URL.", + "examples": [ + "https://example.com/long/url" + ] + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/UrlStatus" + }, + { + "type": "null" + } + ], + "description": "URL status. Derived \u2014 reflects time and max-click expiry even before the stored flip is persisted.", + "examples": [ + "ACTIVE" + ] + }, + "password_set": { + "type": "boolean", + "title": "Password Set", + "description": "Whether the URL is password-protected." + }, + "max_clicks": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Clicks", + "description": "Click limit, or null if unlimited.", + "examples": [ + 100 + ] + }, + "expire_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expire After", + "description": "Expiration as Unix timestamp, or null.", + "examples": [ + 1735689599 + ] + }, + "block_bots": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Block Bots", + "description": "Whether bot blocking is enabled." + }, + "private_stats": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Private Stats", + "description": "Whether statistics are private." + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain", + "description": "Domain fqdn the URL is served on. Null for the system default.", + "examples": [ + "links.acme.com" + ] + }, + "geo_rules": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Geo Rules", + "description": "Per-country destination overrides (ISO alpha-2 code \u2192 URL), or null.", + "examples": [ + { + "IN": "https://example.in/" + } + ] + }, + "updated_at": { + "type": "integer", + "title": "Updated At", + "description": "Last update time as Unix timestamp.", + "examples": [ + 1704067200 + ] + }, + "meta_tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetaTagsResponse" + }, + { + "type": "null" + } + ], + "description": "Custom social preview, if configured." + } + }, + "type": "object", + "required": [ + "id", + "password_set", + "updated_at" + ], + "title": "UpdateUrlResponse", + "description": "Response body after a successful URL update (PATCH /api/v1/urls/{url_id})." + }, + "UpdateUrlStatusRequest": { + "properties": { + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE" + ], + "title": "Status", + "description": "New status for the URL. `ACTIVE` enables redirects, `INACTIVE` disables them.", + "examples": [ + "ACTIVE" + ] + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "UpdateUrlStatusRequest", + "description": "Request body for updating only the status of a shortened URL." + }, + "UpdateWebhookEndpointRequest": { + "properties": { + "url": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ], + "title": "Url" + }, + "events": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 32, + "minItems": 1 + }, + { + "type": "null" + } + ], + "title": "Events" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "scope_links": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 256 + }, + { + "type": "null" + } + ], + "title": "Scope Links" + }, + "flavor": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookFlavor" + }, + { + "type": "null" + } + ] + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebhookStatus" + }, + { + "type": "null" + } + ], + "description": "`active` or `paused`. `disabled` is system-set." + } + }, + "type": "object", + "title": "UpdateWebhookEndpointRequest", + "description": "All fields optional; only provided fields change. `scope_links: null`\nis 'all links' \u2014 to keep an existing scope, omit the field." + }, + "UploadProfilePictureRequest": { + "properties": { + "image": { + "type": "string", + "minLength": 1, + "title": "Image", + "description": "base64 data URI (image/png, image/jpeg, image/webp)" + } + }, + "type": "object", + "required": [ + "image" + ], + "title": "UploadProfilePictureRequest" + }, + "UrlListItem": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Alias" + }, + "long_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Long Url" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/UrlStatus" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "expire_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expire After" + }, + "max_clicks": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Clicks" + }, + "private_stats": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Private Stats" + }, + "block_bots": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Block Bots" + }, + "password_set": { + "type": "boolean", + "title": "Password Set" + }, + "total_clicks": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total Clicks" + }, + "last_click": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Click" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + }, + "geo_rules": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Geo Rules" + }, + "meta_tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetaTagsResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "id", + "password_set" + ], + "title": "UrlListItem", + "description": "A single URL entry inside UrlListResponse.items.\n\n``created_at`` and ``last_click`` are ISO 8601 strings (e.g. \"2024-01-01T00:00:00Z\").\n``expire_after`` is a Unix timestamp integer or null.\nThese formats match the existing endpoint exactly.\n``status`` is derived \u2014 it reflects time and max-click expiry even\nbefore the stored flip is persisted." + }, + "UrlListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/UrlListItem" + }, + "type": "array", + "title": "Items" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "pageSize": { + "type": "integer", + "title": "Pagesize" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "hasNext": { + "type": "boolean", + "title": "Hasnext" + }, + "sortBy": { + "type": "string", + "title": "Sortby" + }, + "sortOrder": { + "type": "string", + "title": "Sortorder" + } + }, + "type": "object", + "required": [ + "items", + "page", + "pageSize", + "total", + "hasNext", + "sortBy", + "sortOrder" + ], + "title": "UrlListResponse", + "description": "Response body for GET /api/v1/urls.\n\nUses camelCase field names to match the existing Flask endpoint exactly.\nField names are camelCase here (not snake_case + alias) because this is a\nresponse-only model \u2014 we build it explicitly in the route handler." + }, + "UrlResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "MongoDB ObjectId of the URL \u2014 the identifier the management endpoints (`/urls/{url_id}`) address it by.", + "examples": [ + "507f1f77bcf86cd799439011" + ] + }, + "alias": { + "type": "string", + "title": "Alias", + "description": "Short code for the URL.", + "examples": [ + "mylink" + ] + }, + "short_url": { + "type": "string", + "title": "Short Url", + "description": "Full shortened URL ready for sharing. Emoji aliases appear unencoded (clients/browsers percent-encode on use).", + "examples": [ + "https://spoo.me/mylink", + "https://spoo.me/\ud83d\ude80\ud83d\udd25" + ] + }, + "long_url": { + "type": "string", + "title": "Long Url", + "description": "Original destination URL.", + "examples": [ + "https://example.com/long/url" + ] + }, + "owner_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Owner Id", + "description": "User ID if authenticated, null for anonymous URLs.", + "examples": [ + "507f1f77bcf86cd799439011" + ] + }, + "created_at": { + "type": "integer", + "title": "Created At", + "description": "Creation time as Unix timestamp.", + "examples": [ + 1704067200 + ] + }, + "status": { + "$ref": "#/components/schemas/UrlStatus", + "description": "URL status. Derived \u2014 reflects time and max-click expiry even before the stored flip is persisted.", + "examples": [ + "ACTIVE" + ] + }, + "private_stats": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Private Stats", + "description": "Whether statistics are private (owner-only)." + }, + "geo_rules": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Geo Rules", + "description": "Per-country destination overrides (ISO alpha-2 code \u2192 URL), or null.", + "examples": [ + { + "IN": "https://example.in/" + } + ] + }, + "meta_tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetaTagsResponse" + }, + { + "type": "null" + } + ], + "description": "Custom social preview, if configured." + }, + "claim_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Claim Token", + "description": "One-time bearer proof of creation \u2014 your deed to this link. Present only on anonymous creates, shown exactly once (only its hash is retained server-side). Store it to later attach the link to an account via POST /api/v1/urls/claim; null for authenticated creates." + } + }, + "type": "object", + "required": [ + "id", + "alias", + "short_url", + "long_url", + "created_at", + "status" + ], + "title": "UrlResponse", + "description": "Response body for a newly created shortened URL (POST /api/v1/shorten).\n\n``created_at`` is a Unix timestamp integer \u2014 matching the existing endpoint." + }, + "UrlStatus": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE", + "EXPIRED", + "BLOCKED" + ], + "title": "UrlStatus", + "description": "Status values for v2 URLs." + }, + "UserPfp": { + "properties": { + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url", + "description": "Profile picture URL", + "examples": [ + "https://lh3.googleusercontent.com/a/photo" + ] + }, + "source": { + "anyOf": [ + { + "$ref": "#/components/schemas/OAuthProvider" + }, + { + "type": "string", + "const": "upload" + }, + { + "type": "null" + } + ], + "title": "Source", + "description": "Source of the profile picture \u2014 an OAuth provider or `upload`", + "examples": [ + "google" + ] + } + }, + "type": "object", + "title": "UserPfp", + "description": "Profile picture info returned inside UserProfileResponse." + }, + "UserProfileResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "User ID", + "examples": [ + "507f1f77bcf86cd799439011" + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email", + "description": "User's email address", + "examples": [ + "user@example.com" + ] + }, + "email_verified": { + "type": "boolean", + "title": "Email Verified", + "description": "Whether the email address has been verified" + }, + "user_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Name", + "description": "Display name", + "examples": [ + "Jane Doe" + ] + }, + "plan": { + "type": "string", + "title": "Plan", + "description": "Subscription plan", + "examples": [ + "free" + ] + }, + "password_set": { + "type": "boolean", + "title": "Password Set", + "description": "Whether the user has set a password" + }, + "onboarded_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Onboarded At", + "description": "When the user completed onboarding (null = never)" + }, + "auth_providers": { + "items": { + "$ref": "#/components/schemas/AuthProviderInfo" + }, + "type": "array", + "title": "Auth Providers", + "description": "Linked OAuth providers" + }, + "pfp": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserPfp" + }, + { + "type": "null" + } + ], + "description": "Profile picture (absent when not set)" + } + }, + "type": "object", + "required": [ + "id", + "email_verified", + "plan", + "password_set", + "auth_providers" + ], + "title": "UserProfileResponse", + "description": "User profile shape \u2014 used in login/register/me responses." + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "VerificationMethod": { + "type": "string", + "enum": [ + "cname", + "a_record", + "txt_challenge", + "system", + "cf_delegated_dcv", + "cf_http_dcv" + ], + "title": "VerificationMethod", + "description": "How ownership of the fqdn is proven before activation." + }, + "VerifyEmailRequest": { + "properties": { + "code": { + "type": "string", + "pattern": "^[0-9]{6}$", + "title": "Code", + "description": "6-digit OTP from verification email", + "examples": [ + "123456" + ] + } + }, + "type": "object", + "required": [ + "code" + ], + "title": "VerifyEmailRequest", + "description": "Request body for POST /auth/verify-email.\n\n``code`` is the 6-digit OTP sent to the user's email address." + }, + "VerifyEmailResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "description": "Whether verification succeeded" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable status message", + "examples": [ + "email verified successfully" + ] + }, + "email_verified": { + "type": "boolean", + "title": "Email Verified", + "description": "Updated email verification status (always true on success)" + } + }, + "type": "object", + "required": [ + "success", + "message", + "email_verified" + ], + "title": "VerifyEmailResponse", + "description": "Response body for POST /auth/verify-email (200)." + }, + "WebhookDeliveryResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "webhook_id": { + "type": "string", + "title": "Webhook Id" + }, + "event_type": { + "type": "string", + "title": "Event Type" + }, + "is_test": { + "type": "boolean", + "title": "Is Test" + }, + "status": { + "$ref": "#/components/schemas/DeliveryStatus" + }, + "attempt_count": { + "type": "integer", + "title": "Attempt Count" + }, + "attempts": { + "items": { + "$ref": "#/components/schemas/DeliveryAttemptResponse" + }, + "type": "array", + "title": "Attempts" + }, + "next_attempt_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Next Attempt At" + }, + "rendered_body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rendered Body" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "webhook_id", + "event_type", + "is_test", + "status", + "attempt_count", + "attempts", + "created_at" + ], + "title": "WebhookDeliveryResponse" + }, + "WebhookEndpointCreatedResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "url": { + "type": "string", + "title": "Url" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "events": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Events" + }, + "scope_links": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Scope Links", + "description": "Null means all links, including future ones." + }, + "flavor": { + "$ref": "#/components/schemas/WebhookFlavor" + }, + "status": { + "$ref": "#/components/schemas/WebhookStatus" + }, + "disabled_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Disabled Reason" + }, + "signing_secret_prefix": { + "type": "string", + "title": "Signing Secret Prefix" + }, + "consecutive_failures": { + "type": "integer", + "title": "Consecutive Failures" + }, + "total_deliveries": { + "type": "integer", + "title": "Total Deliveries" + }, + "total_successes": { + "type": "integer", + "title": "Total Successes" + }, + "last_delivery_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Delivery At" + }, + "last_success_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Success At" + }, + "last_failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Failure Reason" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "signing_secret": { + "type": "string", + "title": "Signing Secret", + "description": "The full signing secret \u2014 shown ONCE, never retrievable again. Store it; you need it to verify webhook signatures." + } + }, + "type": "object", + "required": [ + "id", + "url", + "events", + "flavor", + "status", + "signing_secret_prefix", + "consecutive_failures", + "total_deliveries", + "total_successes", + "created_at", + "signing_secret" + ], + "title": "WebhookEndpointCreatedResponse" + }, + "WebhookEndpointResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "url": { + "type": "string", + "title": "Url" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "events": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Events" + }, + "scope_links": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Scope Links", + "description": "Null means all links, including future ones." + }, + "flavor": { + "$ref": "#/components/schemas/WebhookFlavor" + }, + "status": { + "$ref": "#/components/schemas/WebhookStatus" + }, + "disabled_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Disabled Reason" + }, + "signing_secret_prefix": { + "type": "string", + "title": "Signing Secret Prefix" + }, + "consecutive_failures": { + "type": "integer", + "title": "Consecutive Failures" + }, + "total_deliveries": { + "type": "integer", + "title": "Total Deliveries" + }, + "total_successes": { + "type": "integer", + "title": "Total Successes" + }, + "last_delivery_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Delivery At" + }, + "last_success_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Success At" + }, + "last_failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Failure Reason" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "url", + "events", + "flavor", + "status", + "signing_secret_prefix", + "consecutive_failures", + "total_deliveries", + "total_successes", + "created_at" + ], + "title": "WebhookEndpointResponse" + }, + "WebhookEndpointsListResponse": { + "properties": { + "endpoints": { + "items": { + "$ref": "#/components/schemas/WebhookEndpointResponse" + }, + "type": "array", + "title": "Endpoints" + } + }, + "type": "object", + "required": [ + "endpoints" + ], + "title": "WebhookEndpointsListResponse" + }, + "WebhookFlavor": { + "type": "string", + "enum": [ + "raw", + "discord", + "slack" + ], + "title": "WebhookFlavor", + "description": "Presentation of the payload \u2014 never a second schema. ``raw`` is the\nversioned contract; other flavors are lossy renderings of it." + }, + "WebhookSecretResponse": { + "properties": { + "signing_secret": { + "type": "string", + "title": "Signing Secret", + "description": "The full signing secret, revealed to the endpoint owner." + } + }, + "type": "object", + "required": [ + "signing_secret" + ], + "title": "WebhookSecretResponse" + }, + "WebhookStatus": { + "type": "string", + "enum": [ + "active", + "paused", + "disabled" + ], + "title": "WebhookStatus" + } + }, + "securitySchemes": { + "ApiKeyAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "spoo_", + "description": "API key authentication. Pass your key as: `Bearer spoo_`" + }, + "JWTAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT access token from /auth/login. Pass as: `Bearer `" + } + } + }, + "tags": [ + { + "name": "URL Shortening", + "description": "Create new shortened URLs" + }, + { + "name": "Me", + "description": "Per-user preferences and dashboard layouts" + }, + { + "name": "Link Management", + "description": "List, update, and delete your shortened URLs" + }, + { + "name": "Statistics", + "description": "Click analytics and data export" + }, + { + "name": "API Keys", + "description": "Create and manage API keys for programmatic access" + }, + { + "name": "Authentication", + "description": "Login, register, password management, and email verification" + }, + { + "name": "OAuth", + "description": "OAuth provider login, linking, and unlinking" + }, + { + "name": "Reports & Contact", + "description": "Report abusive URLs and contact the site operators" + }, + { + "name": "System", + "description": "Health checks and server metrics" + } + ], + "servers": [ + { + "url": "https://spoo.me", + "description": "Production" + } + ], + "security": [ + { + "ApiKeyAuth": [] + }, + { + "JWTAuth": [] + } + ] +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index e27504f..0000000 --- a/src/client.rs +++ /dev/null @@ -1,618 +0,0 @@ -/// A client for the URL shortener API. -use crate::{ - errors::{ApiError, UrlShortenerError, ValidationError}, - requests::{ - EmojiRequest, EmojiResponse, ExportRequest, ExportResponse, ShortenRequest, - ShortenResponse, StatsRequest, StatsResponse, - }, - utils::{is_valid_alias, is_valid_max_clicks, is_valid_password, is_valid_url}, -}; - -/// A client for the URL shortener API. -/// -/// This client can be used in both async and blocking modes, depending on the feature flags. -/// -/// # Example usage: -/// ```rust -/// use spoo_me::client::UrlShortenerClient; -/// use spoo_me::requests::ShortenRequest; -/// use spoo_me::errors::UrlShortenerError; -/// -/// #[cfg(not(feature = "blocking"))] -/// #[tokio::main] -/// async fn main() -> Result<(), UrlShortenerError> { -/// let client = UrlShortenerClient::new(); -/// let request = ShortenRequest::new("https://example.com/long/url") -/// .password("Example@123") -/// .max_clicks(100) -/// .block_bots(true); -/// -/// let response = client.shorten(request).await?; -/// println!("Shortened URL: {}", response.short_url); -/// Ok(()) -/// } -/// -/// #[cfg(feature = "blocking")] -/// fn main() -> Result<(), UrlShortenerError> { -/// let client = UrlShortenerClient::new(); -/// let request = ShortenRequest::new("https://example.com/long/url") -/// .password("Example@123") -/// .max_clicks(100) -/// .block_bots(true); -/// -/// let response = client.shorten_blocking(request)?; -/// println!("Shortened URL: {}", response.short_url); -/// Ok(()) -/// } -#[derive(Debug, Clone)] -pub struct UrlShortenerClient { - base_url: String, - #[cfg(not(feature = "blocking"))] - client: reqwest::Client, - #[cfg(feature = "blocking")] - client: reqwest::blocking::Client, -} - -impl UrlShortenerClient { - /// Create a new client - pub fn new() -> Self { - UrlShortenerClient { - base_url: "https://spoo.me".to_string(), - #[cfg(not(feature = "blocking"))] - client: reqwest::Client::new(), - #[cfg(feature = "blocking")] - client: reqwest::blocking::Client::new(), - } - } - - /// Create a new client with a custom base URL - /// - /// Requires the `custom_url` feature to be enabled. - #[cfg(feature = "custom_url")] - pub fn new_with_base_url>(url: S) -> Self { - UrlShortenerClient { - base_url: url.into(), - #[cfg(not(feature = "blocking"))] - client: reqwest::Client::new(), - #[cfg(feature = "blocking")] - client: reqwest::blocking::Client::new(), - } - } - - /// Set a custom base URL for the client. - /// - /// Requires the `custom_url` feature to be enabled. - #[cfg(feature = "custom_url")] - pub fn set_base_url>(&mut self, url: T) { - self.base_url = url.into(); - } - - /// Shorten a URL (async mode). - #[cfg(not(feature = "blocking"))] - pub async fn shorten(&self, req: ShortenRequest) -> Result { - if let Some(ref pw) = req.password { - if !is_valid_password(pw) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidPasswordFormat(pw.clone()), - )); - } - } - - #[cfg(feature = "custom_url")] - if !is_valid_url(&req.url, &self.base_url) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidUrlFormat(req.url.clone()), - )); - } - #[cfg(not(feature = "custom_url"))] - if !is_valid_url(&req.url) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidUrlFormat(req.url.clone()), - )); - } - - if let Some(ref alias) = req.alias { - if !is_valid_alias(alias) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidAliasFormat(alias.clone()), - )); - } - } - - if let Some(max_clicks) = req.max_clicks { - if !is_valid_max_clicks(max_clicks) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidMaxClicks(max_clicks), - )); - } - } - - let resp = self - .client - .post(format!("{}/", self.base_url)) - .header("Accept", "application/json") - .form(&req) - .send() - .await - .map_err(UrlShortenerError::Http)?; - - let status = resp.status(); - let text = resp.text().await.map_err(UrlShortenerError::Http)?; - if !status.is_success() { - if status.as_u16() == 429 { - return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded)); - } - - if let Ok(err_json) = serde_json::from_str::(&text) { - if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) { - return Err(UrlShortenerError::Api(match err { - "UrlError" => ApiError::UrlError, - "AliasError" => ApiError::AliasError, - "PasswordError" => ApiError::PasswordError, - "MaxClicksError" => ApiError::MaxClicksError, - "EmojiError" => ApiError::EmojiError, - _ => ApiError::UrlError, - })); - } - } - return Err(UrlShortenerError::Other(text)); - } - - let result = - serde_json::from_str::(&text).map_err(UrlShortenerError::Json)?; - - Ok(result) - } - - /// Shorten a URL (blocking mode). - #[cfg(feature = "blocking")] - pub fn shorten_blocking( - &self, - req: ShortenRequest, - ) -> Result { - if let Some(ref pw) = req.password { - if !is_valid_password(pw) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidPasswordFormat(pw.clone()), - )); - } - } - - #[cfg(feature = "custom_url")] - if !is_valid_url(&req.url, &self.base_url) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidUrlFormat(req.url.clone()), - )); - } - #[cfg(not(feature = "custom_url"))] - if !is_valid_url(&req.url) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidUrlFormat(req.url.clone()), - )); - } - - if let Some(ref alias) = req.alias { - if !is_valid_alias(alias) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidAliasFormat(alias.clone()), - )); - } - } - - if let Some(max_clicks) = req.max_clicks { - if !is_valid_max_clicks(max_clicks) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidMaxClicks(max_clicks), - )); - } - } - - let resp = self - .client - .post(format!("{}/", self.base_url)) - .header("Accept", "application/json") - .form(&req) - .send() - .map_err(UrlShortenerError::Http)?; - - let status = resp.status(); - let text = resp.text().map_err(UrlShortenerError::Http)?; - if !status.is_success() { - if status.as_u16() == 429 { - return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded)); - } - - if let Ok(err_json) = serde_json::from_str::(&text) { - if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) { - return Err(UrlShortenerError::Api(match err { - "UrlError" => ApiError::UrlError, - "AliasError" => ApiError::AliasError, - "PasswordError" => ApiError::PasswordError, - "MaxClicksError" => ApiError::MaxClicksError, - "EmojiError" => ApiError::EmojiError, - _ => ApiError::UrlError, - })); - } - } - return Err(UrlShortenerError::Other(text)); - } - - let result = - serde_json::from_str::(&text).map_err(UrlShortenerError::Json)?; - - Ok(result) - } - - /// Create an emoji URL (async mode). - #[cfg(not(feature = "blocking"))] - pub async fn emoji(&self, req: EmojiRequest) -> Result { - if let Some(ref pw) = req.password { - if !is_valid_password(pw) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidPasswordFormat(pw.clone()), - )); - } - } - - #[cfg(feature = "custom_url")] - if !is_valid_url(&req.url, &self.base_url) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidUrlFormat(req.url.clone()), - )); - } - #[cfg(not(feature = "custom_url"))] - if !is_valid_url(&req.url) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidUrlFormat(req.url.clone()), - )); - } - - if let Some(max_clicks) = req.max_clicks { - if !is_valid_max_clicks(max_clicks) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidMaxClicks(max_clicks), - )); - } - } - - let resp = self - .client - .post(format!("{}/emoji", self.base_url)) - .header("Accept", "application/json") - .form(&req) - .send() - .await - .map_err(UrlShortenerError::Http)?; - - let status = resp.status(); - let text = resp.text().await.map_err(UrlShortenerError::Http)?; - if !status.is_success() { - if status.as_u16() == 429 { - return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded)); - } - - if let Ok(err_json) = serde_json::from_str::(&text) { - if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) { - return Err(UrlShortenerError::Api(match err { - "UrlError" => ApiError::UrlError, - "AliasError" => ApiError::AliasError, - "PasswordError" => ApiError::PasswordError, - "MaxClicksError" => ApiError::MaxClicksError, - "EmojiError" => ApiError::EmojiError, - err => ApiError::Other(err.to_string()), - })); - } - } - return Err(UrlShortenerError::Other(text)); - } - - let result = - serde_json::from_str::(&text).map_err(UrlShortenerError::Json)?; - - Ok(result) - } - - /// Create an emoji URL (blocking mode). - #[cfg(feature = "blocking")] - pub fn emoji_blocking(&self, req: EmojiRequest) -> Result { - if let Some(ref pw) = req.password { - if !is_valid_password(pw) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidPasswordFormat(pw.clone()), - )); - } - } - - #[cfg(feature = "custom_url")] - if !is_valid_url(&req.url, &self.base_url) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidUrlFormat(req.url.clone()), - )); - } - #[cfg(not(feature = "custom_url"))] - if !is_valid_url(&req.url) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidUrlFormat(req.url.clone()), - )); - } - - if let Some(max_clicks) = req.max_clicks { - if !is_valid_max_clicks(max_clicks) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidMaxClicks(max_clicks), - )); - } - } - - let resp = self - .client - .post(format!("{}/emoji", self.base_url)) - .header("Accept", "application/json") - .form(&req) - .send() - .map_err(UrlShortenerError::Http)?; - - let status = resp.status(); - let text = resp.text().map_err(UrlShortenerError::Http)?; - if !status.is_success() { - if status.as_u16() == 429 { - return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded)); - } - - if let Ok(err_json) = serde_json::from_str::(&text) { - if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) { - return Err(UrlShortenerError::Api(match err { - "UrlError" => ApiError::UrlError, - "AliasError" => ApiError::AliasError, - "PasswordError" => ApiError::PasswordError, - "MaxClicksError" => ApiError::MaxClicksError, - "EmojiError" => ApiError::EmojiError, - _ => ApiError::UrlError, - })); - } - } - return Err(UrlShortenerError::Other(text)); - } - - let result = - serde_json::from_str::(&text).map_err(UrlShortenerError::Json)?; - - Ok(result) - } - - /// Get statistics for a shortened URL (async mode). - #[cfg(not(feature = "blocking"))] - pub async fn stats(&self, req: StatsRequest) -> Result { - if req.short_code.is_empty() { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidPasswordFormat("Short code cannot be empty".to_string()), - )); - } - - if let Some(ref pw) = req.password { - if !is_valid_password(pw) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidPasswordFormat(pw.clone()), - )); - } - } - - if !is_valid_alias(&req.short_code) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidAliasFormat(req.short_code.clone()), - )); - } - - let resp = self - .client - .post(format!("{}/stats/{}", self.base_url, req.short_code)) - .header("Accept", "application/json") - .form(&req) - .send() - .await - .map_err(UrlShortenerError::Http)?; - - let status = resp.status(); - let text = resp.text().await.map_err(UrlShortenerError::Http)?; - if !status.is_success() { - if status.as_u16() == 429 { - return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded)); - } - - if let Ok(err_json) = serde_json::from_str::(&text) { - if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) { - return Err(UrlShortenerError::Api(match err { - "UrlError" => ApiError::UrlError, - "AliasError" => ApiError::AliasError, - "PasswordError" => ApiError::PasswordError, - "MaxClicksError" => ApiError::MaxClicksError, - "EmojiError" => ApiError::EmojiError, - _ => ApiError::UrlError, - })); - } - } - return Err(UrlShortenerError::Other(text)); - } - - let result = - serde_json::from_str::(&text).map_err(UrlShortenerError::Json)?; - - Ok(result) - } - - /// Get statistics for a shortened URL (blocking mode). - #[cfg(feature = "blocking")] - pub fn stats_blocking(&self, req: StatsRequest) -> Result { - if req.short_code.is_empty() { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidPasswordFormat("Short code cannot be empty".to_string()), - )); - } - - if let Some(ref pw) = req.password { - if !is_valid_password(pw) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidPasswordFormat(pw.clone()), - )); - } - } - - if !is_valid_alias(&req.short_code) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidAliasFormat(req.short_code.clone()), - )); - } - - let resp = self - .client - .post(format!("{}/stats/{}", self.base_url, req.short_code)) - .header("Accept", "application/json") - .form(&req) - .send() - .map_err(UrlShortenerError::Http)?; - - let status = resp.status(); - let text = resp.text().map_err(UrlShortenerError::Http)?; - if !status.is_success() { - if status.as_u16() == 429 { - return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded)); - } - - if let Ok(err_json) = serde_json::from_str::(&text) { - if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) { - return Err(UrlShortenerError::Api(match err { - "UrlError" => ApiError::UrlError, - "AliasError" => ApiError::AliasError, - "PasswordError" => ApiError::PasswordError, - "MaxClicksError" => ApiError::MaxClicksError, - "EmojiError" => ApiError::EmojiError, - _ => ApiError::UrlError, - })); - } - } - return Err(UrlShortenerError::Other(text)); - } - - let result = - serde_json::from_str::(&text).map_err(UrlShortenerError::Json)?; - - Ok(result) - } - - /// Export data for a shortened URL (async mode). - #[cfg(not(feature = "blocking"))] - pub async fn export(&self, req: ExportRequest) -> Result { - if req.short_code.is_empty() { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidAliasFormat(req.short_code), - )); - } - - if !is_valid_alias(&req.short_code) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidAliasFormat(req.short_code.clone()), - )); - } - - let resp = self - .client - .post(format!( - "{}/export/{}/{}", - self.base_url, req.short_code, req.export_format - )) - .form(&req) - .send() - .await - .map_err(UrlShortenerError::Http)?; - - let status = resp.status(); - if !status.is_success() { - if status.as_u16() == 429 { - return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded)); - } - - let text = resp.text().await.map_err(UrlShortenerError::Http)?; - if let Ok(err_json) = serde_json::from_str::(&text) { - if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) { - return Err(UrlShortenerError::Api(match err { - "UrlError" => ApiError::UrlError, - "AliasError" => ApiError::AliasError, - "PasswordError" => ApiError::PasswordError, - "MaxClicksError" => ApiError::MaxClicksError, - "EmojiError" => ApiError::EmojiError, - _ => ApiError::Other(err.to_string()), - })); - } - } - return Err(UrlShortenerError::Other(text)); - } - - let data = resp.bytes().await.map_err(UrlShortenerError::Http)?; - let result = ExportResponse { - data: data.to_vec(), - }; - - Ok(result) - } - - /// Export data for a shortened URL (blocking mode). - #[cfg(feature = "blocking")] - pub fn export_blocking(&self, req: ExportRequest) -> Result { - if req.short_code.is_empty() { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidAliasFormat(req.short_code), - )); - } - - if !is_valid_alias(&req.short_code) { - return Err(UrlShortenerError::Validation( - ValidationError::InvalidAliasFormat(req.short_code.clone()), - )); - } - - let resp = self - .client - .post(format!( - "{}/export/{}/{}", - self.base_url, req.short_code, req.export_format - )) - .form(&req) - .send() - .map_err(UrlShortenerError::Http)?; - - let status = resp.status(); - if !status.is_success() { - if status.as_u16() == 429 { - return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded)); - } - - let text = resp.text().map_err(UrlShortenerError::Http)?; - if let Ok(err_json) = serde_json::from_str::(&text) { - if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) { - return Err(UrlShortenerError::Api(match err { - "UrlError" => ApiError::UrlError, - "AliasError" => ApiError::AliasError, - "PasswordError" => ApiError::PasswordError, - "MaxClicksError" => ApiError::MaxClicksError, - "EmojiError" => ApiError::EmojiError, - _ => ApiError::Other(err.to_string()), - })); - } - } - return Err(UrlShortenerError::Other(text)); - } - - let data = resp.bytes().map_err(UrlShortenerError::Http)?; - let result = ExportResponse { - data: data.to_vec(), - }; - - Ok(result) - } -} - -impl Default for UrlShortenerClient { - fn default() -> Self { - Self::new() - } -} diff --git a/src/errors.rs b/src/errors.rs deleted file mode 100644 index 0f6babd..0000000 --- a/src/errors.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::fmt::Display; - -use thiserror::Error; - -/// Errors that can occur when sending requests (client validation or HTTP errors). -#[derive(Debug, Error)] -pub enum ValidationError { - /// Password does not meet format requirements. - InvalidPasswordFormat(String), - /// Alias does not meet format requirements - InvalidAliasFormat(String), - /// URL does not meet format requirements. - InvalidUrlFormat(String), - /// Max-clicks must be a positive integer. - InvalidMaxClicks(u32), - /// Emoji sequence is invalid - InvalidEmojiSequence(String), -} - -impl Display for ValidationError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ValidationError::InvalidPasswordFormat(msg) => { - write!(f, "Invalid password format: {}", msg) - } - ValidationError::InvalidAliasFormat(msg) => write!(f, "Invalid alias format: {}", msg), - ValidationError::InvalidUrlFormat(msg) => write!(f, "Invalid URL format: {}", msg), - ValidationError::InvalidMaxClicks(value) => { - write!(f, "Max-clicks must be a positive integer, got: {}", value) - } - ValidationError::InvalidEmojiSequence(seq) => { - write!(f, "Invalid emoji sequence: {}", seq) - } - } - } -} - -/// Errors that can occur when interacting with the spoo.me API. -#[derive(Debug, Error)] -pub enum ApiError { - /// The URL does not match the expected format. - UrlError, - /// The alias is already in use or invalid. - AliasError, - /// The password provided is incorrect. - PasswordError, - /// The max clicks value is invalid. - MaxClicksError, - /// The emoji sequence is already in use or invalid. - EmojiError, - /// The rate limit for the API has been exceeded. - RateLimitExceeded, - /// Other unexpected errors from the API. - Other(String), -} - -impl Display for ApiError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ApiError::UrlError => write!(f, "Invalid URL format"), - ApiError::AliasError => write!(f, "Alias already in use or invalid"), - ApiError::PasswordError => write!(f, "Incorrect password provided"), - ApiError::MaxClicksError => write!(f, "Invalid max clicks value"), - ApiError::EmojiError => write!(f, "Invalid or already used emoji sequence"), - ApiError::RateLimitExceeded => write!(f, "Rate limit exceeded for the API"), - ApiError::Other(msg) => write!(f, "API error: {}", msg), - } - } -} - -/// Errors that can occur when using the URL shortener client. -#[derive(Debug, Error)] -pub enum UrlShortenerError { - /// Validation errors related to the request parameters. - Validation(ValidationError), - /// Errors returned by the spoo.me API. - Api(ApiError), - /// Errors related to the HTTP request, such as connection issues or timeouts. - Http(reqwest::Error), - /// Errors related to JSON serialization or deserialization. - Json(serde_json::Error), - /// Other unexpected status codes or errors. - Other(String), -} - -impl Display for UrlShortenerError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - UrlShortenerError::Validation(err) => write!(f, "Validation error: {}", err), - UrlShortenerError::Api(err) => write!(f, "API error: {:?}", err), - UrlShortenerError::Http(err) => write!(f, "HTTP error: {}", err), - UrlShortenerError::Json(err) => write!(f, "JSON error: {}", err), - UrlShortenerError::Other(msg) => write!(f, "Other error: {}", msg), - } - } -} diff --git a/src/requests.rs b/src/requests.rs deleted file mode 100644 index a0a4996..0000000 --- a/src/requests.rs +++ /dev/null @@ -1,266 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fmt::Display}; - -/// Response for URL-shortening endpoints (`/` and `/emoji`). -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ShortenResponse { - /// The resulting shortened URL (full URL). - pub short_url: String, - /// The domain name used for the short URL - currently always "spoo.me". - pub domain: String, - /// The URL that was shortened. - pub original_url: String, -} - -/// Request payload for `POST /` (shorten URL). -#[derive(Debug, Serialize, Default, Clone)] -pub struct ShortenRequest { - pub(crate) url: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) alias: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) password: Option, - #[serde(rename = "max-clicks", skip_serializing_if = "Option::is_none")] - pub(crate) max_clicks: Option, - #[serde(rename = "block-bots", skip_serializing_if = "Option::is_none")] - pub(crate) block_bots: Option, -} - -impl ShortenRequest { - /// Creates a new ShortenRequest with the mandatory `url`. - pub fn new>(url: U) -> Self { - ShortenRequest { - url: url.into(), - ..Default::default() - } - } - /// Optional custom alias (must follow API rules) - pub fn alias>(mut self, alias: A) -> Self { - self.alias = Some(alias.into()); - self - } - /// Optional password (must follow API rules). - pub fn password>(mut self, password: P) -> Self { - self.password = Some(password.into()); - self - } - /// Optional max-clicks (must be positive). - pub fn max_clicks(mut self, max: u32) -> Self { - self.max_clicks = Some(max); - self - } - /// Optional block bots flag. - pub fn block_bots(mut self, flag: bool) -> Self { - self.block_bots = Some(flag); - self - } -} - -/// Request payload for `POST /emoji` (uses emojis as slug). -#[derive(Debug, Serialize, Default, Clone)] -pub struct EmojiRequest { - pub(crate) url: String, - #[serde(rename = "emojies", skip_serializing_if = "Option::is_none")] - pub(crate) emojies: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) password: Option, - #[serde(rename = "max-clicks", skip_serializing_if = "Option::is_none")] - pub(crate) max_clicks: Option, - #[serde(rename = "block-bots", skip_serializing_if = "Option::is_none")] - pub(crate) block_bots: Option, -} - -impl EmojiRequest { - /// Creates a new EmojiRequest with the mandatory `url`. - pub fn new>(url: U) -> Self { - EmojiRequest { - url: url.into(), - ..Default::default() - } - } - /// Optional emoji sequence (must follow API rules). - pub fn emojies>(mut self, seq: E) -> Self { - self.emojies = Some(seq.into()); - self - } - /// Optional password (must follow API rules). - pub fn password>(mut self, password: P) -> Self { - self.password = Some(password.into()); - self - } - /// Optional max-clicks (must be positive). - pub fn max_clicks(mut self, max: u32) -> Self { - self.max_clicks = Some(max); - self - } - /// Optional block bots flag. - pub fn block_bots(mut self, flag: bool) -> Self { - self.block_bots = Some(flag); - self - } -} - -/// Response struct for `POST /emoji`, containing the shortened URL. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct EmojiResponse { - /// The resulting shortened URL (full URL). - pub short_url: String, - /// The domain name used for the short URL - currently always "spoo.me". - pub domain: String, - /// The URL that was shortened. - pub original_url: String, -} - -/// Request payload for `POST /stats/{shortCode}`. -#[derive(Debug, Serialize, Default, Clone)] -pub struct StatsRequest { - #[serde(skip_serializing)] - pub(crate) short_code: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) password: Option, -} - -impl StatsRequest { - /// Create a StatsRequest (optionally with password). - pub fn new(short_code: &str) -> Self { - StatsRequest { - short_code: short_code.to_string(), - password: None, - } - } - /// Optional password for accessing stats (if set on the short URL). - pub fn password>(mut self, password: P) -> Self { - self.password = Some(password.into()); - self - } -} - -/// Response struct for `POST /stats/{shortCode}`, containing URL statistics. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct StatsResponse { - /// The code of the short URL. - pub short_code: String, - /// The original long URL. - pub url: String, - /// Total clicks since creation. - #[serde(rename = "total-clicks")] - pub total_clicks: u32, - /// Total unique clicks. - pub total_unique_clicks: u32, - /// Creation date (string) of the short link, if available. - #[serde(rename = "creation-date")] - pub creation_date: Option, - /// Whether the link has expired. - pub expired: Option, - /// Last click timestamp (if any). - #[serde(rename = "last-click")] - pub last_click: Option, - /// Last browser used. - #[serde(rename = "last-click-browser")] - pub last_click_browser: Option, - /// Last OS used. - #[serde(rename = "last-click-os")] - pub last_click_os: Option, - /// Max clicks allowed (if set). - #[serde(rename = "max-clicks")] - pub max_clicks: Option, - /// The password set on the short URL (if any). - pub password: Option, - /// Whether bots were blocked. - pub block_bots: Option, - /// Click data per bot type. - pub bots: Option>, - /// Click data per browser. - pub browser: Option>, - /// Click data per country. - pub country: Option>, - /// Clicks per day. - pub counter: Option>, - /// Unique clicks per browser. - pub unique_browser: Option>, - /// Unique clicks per country. - pub unique_country: Option>, - /// Unique clicks per day. - pub unique_counter: Option>, - /// Unique clicks per OS name. - pub unique_os_name: Option>, - /// Unique clicks per referrer. - pub unique_referrer: Option>, -} - -/// Enum representing the available export formats. -#[derive(Debug, Deserialize, Clone)] -pub enum ExportFormat { - /// Export as JSON. - JSON, - /// Export as CSV, zipped together. - CSV, - /// Export as XLSX (Excel format). - XLSX, - /// Export as XML. - XML, -} - -impl Display for ExportFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ExportFormat::JSON => write!(f, "json"), - ExportFormat::CSV => write!(f, "csv"), - ExportFormat::XLSX => write!(f, "xlsx"), - ExportFormat::XML => write!(f, "xml"), - } - } -} - -/// Request payload for `POST /export/{shortCode}/{exportFormat}`. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ExportRequest { - /// The short code of the URL to export. - #[serde(skip_serializing)] - pub(crate) short_code: String, - #[serde(skip_serializing)] - pub(crate) export_format: ExportFormat, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) password: Option, -} - -impl ExportRequest { - /// Creates a new ExportRequest with the mandatory `short_code` and `export_format`. - pub fn new>(short_code: S, export_format: ExportFormat) -> Self { - ExportRequest { - short_code: short_code.into(), - export_format, - password: None, - } - } - - /// Optional password for accessing the export (if set on the short URL). - pub fn password>(mut self, password: P) -> Self { - self.password = Some(password.into()); - self - } -} - -/// Implementation for creating an export request. -#[derive(Debug, Clone)] -pub struct ExportResponse { - /// The raw data returned - pub(crate) data: Vec, -} - -impl ExportResponse { - /// Writes the export data to a file at the specified path. - pub fn save_to_file(&self, path: &str) -> std::io::Result<()> { - use std::fs::File; - use std::io::Write; - - let mut file = File::create(path)?; - file.write_all(&self.data)?; - Ok(()) - } - - /// Returns the raw data of the export. - pub fn data(&self) -> &[u8] { - &self.data - } -} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 923a280..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,38 +0,0 @@ -const URL_REGEX: &str = r#"^(ftp|http|https):\/\/[^ "]+$"#; -const ALIAS_REGEX: &str = r"^[a-zA-Z0-9_-]*$"; - -/// Validate password format (≥8 chars, contains letter, digit, '@' or '.', no consecutive special chars). -pub fn is_valid_password(pw: &str) -> bool { - let len_ok = pw.len() >= 8; - let has_letter = pw.chars().any(|c| c.is_alphabetic()); - let has_digit = pw.chars().any(|c| c.is_ascii_digit()); - let has_special = pw.chars().any(|c| c == '@' || c == '.'); - let no_consec = - !pw.contains("..") && !pw.contains("@@") && !pw.contains("@.") && !pw.contains(".@"); - len_ok && has_letter && has_digit && has_special && no_consec -} - -/// Validate URL format (http/https/ftp, no base url or ".." in path). -#[cfg(feature = "custom_url")] -pub fn is_valid_url(url: &str, base_url: &str) -> bool { - let re = regex::Regex::new(URL_REGEX).unwrap(); - re.is_match(url) && !url.contains(base_url) && !url.contains("..") -} - -/// Validate URL format (http/https/ftp, no "spoo.me" or ".." in path). -#[cfg(not(feature = "custom_url"))] -pub fn is_valid_url(url: &str) -> bool { - let re = regex::Regex::new(URL_REGEX).unwrap(); - re.is_match(url) && !url.contains("spoo.me") && !url.contains("..") -} - -/// Validate alias format (alphanumeric, underscores, hyphens, max 16 chars). -pub fn is_valid_alias(alias: &str) -> bool { - let re = regex::Regex::new(ALIAS_REGEX).unwrap(); - re.is_match(alias) && !alias.is_empty() && alias.len() <= 16 -} - -/// Validate max-clicks (must be a positive integer). -pub fn is_valid_max_clicks(max: u32) -> bool { - max > 0 -} diff --git a/tests/api.rs b/tests/api.rs deleted file mode 100644 index f62d1ee..0000000 --- a/tests/api.rs +++ /dev/null @@ -1,131 +0,0 @@ -#[cfg(not(feature = "blocking"))] -#[cfg(test)] -mod async_tests { - use spoo_me::{ - client::UrlShortenerClient, - requests::{EmojiRequest, ExportFormat, ExportRequest, ShortenRequest, StatsRequest}, - }; - - #[tokio::test] - async fn test_shorten() { - let client = UrlShortenerClient::new(); - let request = ShortenRequest::new("https://example.com") - .password("Test@123") - .max_clicks(10) - .block_bots(true); - - let response = client.shorten(request).await; - assert!( - response.is_ok(), - "Failed to shorten URL: {:?}", - response.err() - ); - - let shortened_url = response.unwrap(); - assert!( - shortened_url.short_url.starts_with("https://spoo.me/"), - "Shortened URL does not start with expected base URL" - ); - } - - #[tokio::test] - async fn test_emoji() { - let client = UrlShortenerClient::new(); - let request = EmojiRequest::new("https://example.com") - .password("Test@123") - .max_clicks(10) - .block_bots(true); - - let response = client.emoji(request).await; - assert!( - response.is_ok(), - "Failed to create emoji URL: {:?}", - response.err() - ); - - let emoji_url = response.unwrap(); - assert!( - emoji_url.short_url.starts_with("https://spoo.me/"), - "Emoji URL does not start with expected base URL" - ); - } - - #[tokio::test] - async fn test_stats() { - let client = UrlShortenerClient::new(); - let request = StatsRequest::new("ga"); // Code used for uptime tracking - - let response = client.stats(request).await; - assert!( - response.is_ok(), - "Failed to get stats: {:?}", - response.err() - ); - - let stats = response.unwrap(); - assert_eq!( - stats.url, "https://google.com", - "Stats URL does not match expected" - ); - assert!( - stats.total_clicks > 0, - "Stats should have positive click count" // Not all should, but this will - ); - assert!( - stats.creation_date.is_some(), - "Stats should have a creation date" - ); - } - - #[tokio::test] - async fn test_export_json() { - let client = UrlShortenerClient::new(); - let request = ExportRequest::new("ga", ExportFormat::JSON); - let response = client.export(request).await; - - assert!( - response.is_ok(), - "Failed to export stats as JSON: {:?}", - response.err() - ); - } - - #[tokio::test] - async fn test_export_csv() { - let client = UrlShortenerClient::new(); - let request = ExportRequest::new("ga", ExportFormat::CSV); - let response = client.export(request).await; - - assert!( - response.is_ok(), - "Failed to export stats as JSON: {:?}", - response.err() - ); - } - - #[tokio::test] - async fn test_export_xlsx() { - let client = UrlShortenerClient::new(); - let request = ExportRequest::new("ga", ExportFormat::XLSX); - let response = client.export(request).await; - - assert!( - response.is_ok(), - "Failed to export stats as JSON: {:?}", - response.err() - ); - } - - #[tokio::test] - async fn test_export_xml() { - let client = UrlShortenerClient::new(); - let request = ExportRequest::new("ga", ExportFormat::XML); - let response = client.export(request).await; - - assert!( - response.is_ok(), - "Failed to export stats as JSON: {:?}", - response.err() - ); - } -} diff --git a/tests/blocking_api.rs b/tests/blocking_api.rs deleted file mode 100644 index 49e0790..0000000 --- a/tests/blocking_api.rs +++ /dev/null @@ -1,131 +0,0 @@ -#[cfg(feature = "blocking")] -#[cfg(test)] -mod blocking_tests { - use spoo_me::{ - client::UrlShortenerClient, - requests::{EmojiRequest, ExportFormat, ExportRequest, ShortenRequest, StatsRequest}, - }; - - #[test] - fn test_shorten() { - let client = UrlShortenerClient::new(); - let request = ShortenRequest::new("https://example.com") - .password("Test@123") - .max_clicks(10) - .block_bots(true); - - let response = client.shorten_blocking(request); - assert!( - response.is_ok(), - "Failed to shorten URL: {:?}", - response.err() - ); - - let shortened_url = response.unwrap(); - assert!( - shortened_url.short_url.starts_with("https://spoo.me/"), - "Shortened URL does not start with expected base URL" - ); - } - - #[test] - fn test_emoji() { - let client = UrlShortenerClient::new(); - let request = EmojiRequest::new("https://example.com") - .password("Test@123") - .max_clicks(10) - .block_bots(true); - - let response = client.emoji_blocking(request); - assert!( - response.is_ok(), - "Failed to create emoji URL: {:?}", - response.err() - ); - - let emoji_url = response.unwrap(); - assert!( - emoji_url.short_url.starts_with("https://spoo.me/"), - "Emoji URL does not start with expected base URL" - ); - } - - #[test] - fn test_stats() { - let client = UrlShortenerClient::new(); - let request = StatsRequest::new("ga"); // Code used for uptime tracking - - let response = client.stats_blocking(request); - assert!( - response.is_ok(), - "Failed to get stats: {:?}", - response.err() - ); - - let stats = response.unwrap(); - assert_eq!( - stats.url, "https://google.com", - "Stats URL does not match expected" - ); - assert!( - stats.total_clicks > 0, - "Stats should have positive click count" // Not all should, but this will - ); - assert!( - stats.creation_date.is_some(), - "Stats should have a creation date" - ); - } - - #[test] - fn test_export_json() { - let client = UrlShortenerClient::new(); - let request = ExportRequest::new("ga", ExportFormat::JSON); - let response = client.export_blocking(request); - - assert!( - response.is_ok(), - "Failed to export stats as JSON: {:?}", - response.err() - ); - } - - #[test] - fn test_export_csv() { - let client = UrlShortenerClient::new(); - let request = ExportRequest::new("ga", ExportFormat::CSV); - let response = client.export_blocking(request); - - assert!( - response.is_ok(), - "Failed to export stats as JSON: {:?}", - response.err() - ); - } - - #[test] - fn test_export_xlsx() { - let client = UrlShortenerClient::new(); - let request = ExportRequest::new("ga", ExportFormat::XLSX); - let response = client.export_blocking(request); - - assert!( - response.is_ok(), - "Failed to export stats as JSON: {:?}", - response.err() - ); - } - - #[test] - fn test_export_xml() { - let client = UrlShortenerClient::new(); - let request = ExportRequest::new("ga", ExportFormat::XML); - let response = client.export_blocking(request); - - assert!( - response.is_ok(), - "Failed to export stats as JSON: {:?}", - response.err() - ); - } -} diff --git a/tests/utils.rs b/tests/utils.rs deleted file mode 100644 index 1a65827..0000000 --- a/tests/utils.rs +++ /dev/null @@ -1,38 +0,0 @@ -use spoo_me::utils::*; - -#[test] -fn test_valid_password() { - assert!(is_valid_password("Valid@123")); - assert!(!is_valid_password("short")); - assert!(!is_valid_password("NoSpecial123")); - assert!(!is_valid_password("NoDigit@")); - assert!(!is_valid_password("Invalid@@@")); -} - -#[cfg(feature = "custom_url")] -#[test] -fn test_valid_url() { - assert!(is_valid_url("https://example.com", "spoo.me")); - assert!(is_valid_url("ftp://example.com", "spoo.me")); - assert!(!is_valid_url("https://spoo.me/test", "spoo.me")); - assert!(!is_valid_url("https://example.com/..", "spoo.me")); -} - -#[cfg(not(feature = "custom_url"))] -#[test] -fn test_valid_url() { - assert!(is_valid_url("https://example.com")); - assert!(is_valid_url("ftp://example.com")); - assert!(is_valid_url("https://example.com/long/url")); - assert!(!is_valid_url("https://spoo.me/test")); - assert!(!is_valid_url("https://example.com/..")); -} - -#[test] -fn test_valid_alias() { - assert!(is_valid_alias("valid_alias")); - assert!(is_valid_alias("valid123")); - assert!(!is_valid_alias("invalid alias")); - assert!(!is_valid_alias("too_long_alias_12345")); - assert!(!is_valid_alias("")); -} From 20f3bf974ca1cc25d267e96db053c78b747ed88b Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 01:15:08 +0530 Subject: [PATCH 02/13] feat: transport core with retries and typed errors Auth injection, jittered backoff capped at 8s honoring both legal Retry-After forms, method-aware retry rules so POST never replays where the server may have done work, and error mapping that never promotes a non-envelope body into the message. Content-Disposition filenames are reduced to safe bare names before callers see them. --- src/error.rs | 192 ++++++++++++++++++++ src/http.rs | 487 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/page.rs | 127 ++++++++++++++ src/patch.rs | 75 ++++++++ 4 files changed, 881 insertions(+) create mode 100644 src/error.rs create mode 100644 src/http.rs create mode 100644 src/page.rs create mode 100644 src/patch.rs diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..bc3a723 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,192 @@ +//! Error types returned by the client. + +use std::time::Duration; + +use chrono::{DateTime, Utc}; + +/// Rate-limit state parsed from the `X-RateLimit-*` and `Retry-After` +/// response headers. The backend reports the shortest rate-limit window +/// that applies to the endpoint. +#[derive(Debug, Clone, Default, PartialEq)] +#[non_exhaustive] +pub struct RateLimit { + /// Request budget of the reported window. + pub limit: Option, + /// How much of the budget is left. + pub remaining: Option, + /// When the reported window resets. + pub reset: Option>, + /// Server-mandated wait, sent on 429 responses. + pub retry_after: Option, +} + +/// The backend's error envelope (`{error, code, field, details}`) plus the +/// response metadata that matters for handling a failure programmatically. +#[derive(Debug)] +#[non_exhaustive] +pub struct ApiError { + /// HTTP status of the response. + pub status: u16, + /// The backend's machine-readable error code: an open string enum in + /// lowercase snake_case such as `conflict`, `authentication_error`, + /// `not_found`, `rate_limit_exceeded`, `payload_too_large`, `blocked`, + /// `gone` (the one uppercase outlier is `EMAIL_NOT_VERIFIED`). Read from + /// the body, with the `X-Error-Code` header as fallback for + /// edge-composed responses whose bodies carry no envelope. + pub code: String, + /// Human-readable error message. When the response body is not the JSON + /// error envelope (for example proxy-composed HTML), this is + /// `HTTP {status}` and the raw text is preserved in [`ApiError::body`]. + pub message: String, + /// Names the offending request field on validation errors. + pub field: Option, + /// Structured context for the error, when the backend attaches any. + pub details: Option, + /// The `X-Request-ID` header, for support correlation. + pub request_id: Option, + /// Parsed rate-limit headers. + pub rate_limit: RateLimit, + /// Raw response text, preserved only when the body was not the JSON + /// error envelope. + pub body: Option, +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.field { + Some(field) => write!(f, "{} (field {:?})", self.message, field), + None => write!(f, "{}", self.message), + } + } +} + +/// Errors returned by every fallible operation in this crate. +/// +/// This crate does not panic: every failure surfaces as one of these +/// variants. Callers who only want to branch on common conditions can use +/// the predicate methods ([`Error::is_not_found`], [`Error::is_rate_limited`], +/// [`Error::is_blocked`], ...) instead of matching variants. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The API answered with an error status. Carries the parsed error + /// envelope and response metadata (boxed to keep `Result` small). + #[error("spoo.me API error ({status}): {inner}", status = .0.status, inner = .0)] + Api(Box), + + /// The request never produced a usable response: connection failures, + /// timeouts, TLS errors. + #[error("transport error: {0}")] + Transport(#[from] reqwest::Error), + + /// A 2xx response body did not decode into the expected shape. Usually + /// means the SDK is behind the server: check for a newer release. + #[error("failed to decode response body: {0}")] + Decode(#[source] serde_json::Error), + + /// A refreshing session's refresh token no longer works. The only + /// recovery is a fresh sign-in. + #[error("session expired: refresh token was rejected")] + SessionExpired, + + /// The client was misconfigured (for example an unparsable base URL). + /// Returned by builders before any request goes out. + #[error("configuration error: {0}")] + Config(String), +} + +impl Error { + /// The parsed API error, when this is [`Error::Api`]. + pub fn api(&self) -> Option<&ApiError> { + match self { + Error::Api(e) => Some(e), + _ => None, + } + } + + /// HTTP status of the failing response, when the API answered at all. + pub fn status(&self) -> Option { + self.api().map(|e| e.status) + } + + /// The backend's machine-readable error code, when the API answered. + pub fn code(&self) -> Option<&str> { + self.api().map(|e| e.code.as_str()) + } + + /// Whether this is an API 404: no such resource, or not yours (the + /// resolve-first endpoints deliberately answer both the same way). + pub fn is_not_found(&self) -> bool { + self.status() == Some(404) + } + + /// Whether this is an API 429. The client has already retried, so a 429 + /// surfacing here means the budget is truly gone; the wait to observe is + /// in [`Error::retry_after`]. + pub fn is_rate_limited(&self) -> bool { + self.status() == Some(429) + } + + /// Whether this is an API 451: the link was taken down because its + /// destination was flagged. A verdict on the link, not a transient + /// failure. + pub fn is_blocked(&self) -> bool { + self.status() == Some(451) + } + + /// Whether this failure is a property of the link, not the session: the + /// link's stats require the link password. + pub fn is_password_required(&self) -> bool { + self.status() == Some(401) + && self + .code() + .is_some_and(|c| c == "password_required" || c == "invalid_password") + } + + /// The server-mandated wait from the `Retry-After` header, when present. + pub fn retry_after(&self) -> Option { + self.api().and_then(|e| e.rate_limit.retry_after) + } +} + +/// Parse a `Retry-After` header value. RFC 9110 allows two forms: +/// delay-seconds and an HTTP-date. Never fails: an unparsable value is +/// `None`. +pub(crate) fn parse_retry_after(value: &str, now: DateTime) -> Option { + let value = value.trim(); + if let Ok(secs) = value.parse::() { + return Some(Duration::from_secs(secs)); + } + if let Ok(when) = DateTime::parse_from_rfc2822(value) { + let delta = when.with_timezone(&Utc) - now; + return delta.to_std().ok(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retry_after_parses_both_legal_forms() { + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!( + parse_retry_after("120", now), + Some(Duration::from_secs(120)) + ); + assert_eq!( + parse_retry_after("Thu, 01 Jan 2026 00:00:30 GMT", now), + Some(Duration::from_secs(30)) + ); + // A date in the past yields no wait rather than an error. + assert_eq!( + parse_retry_after("Wed, 31 Dec 2025 23:59:00 GMT", now), + None + ); + assert_eq!(parse_retry_after("not-a-value", now), None); + assert_eq!(parse_retry_after("", now), None); + } +} diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..f9255fa --- /dev/null +++ b/src/http.rs @@ -0,0 +1,487 @@ +//! Internal transport: request building, auth, retries, error mapping. + +use std::time::Duration; + +use chrono::Utc; +use reqwest::{Method, Response, StatusCode}; +use serde::de::DeserializeOwned; + +use crate::error::{ApiError, Error, RateLimit, parse_retry_after}; + +/// How the client authenticates outgoing requests. +pub(crate) enum Auth { + /// Public endpoints only. + None, + /// `Authorization: Bearer spoo_`. + ApiKey(String), + /// A refreshing Sign in with Spoo session. + #[cfg(feature = "oauth")] + Session(std::sync::Arc), +} + +pub(crate) struct Transport { + pub(crate) http: reqwest::Client, + /// Base URL with no trailing slash, e.g. `https://spoo.me`. + pub(crate) base_url: String, + pub(crate) auth: Auth, + pub(crate) client_tag: String, + pub(crate) max_retries: u32, + pub(crate) timeout: Duration, +} + +/// A request about to be sent, in transport-neutral form. The body is +/// serialized once up front so retries never re-serialize. +pub(crate) struct RequestSpec { + pub(crate) method: Method, + pub(crate) path: String, + pub(crate) query: Vec<(String, String)>, + pub(crate) body: Option, +} + +impl RequestSpec { + pub(crate) fn new(method: Method, path: impl Into) -> Self { + RequestSpec { + method, + path: path.into(), + query: Vec::new(), + body: None, + } + } + + pub(crate) fn query(mut self, key: &str, value: Option) -> Self { + if let Some(value) = value { + self.query.push((key.to_owned(), value)); + } + self + } + + pub(crate) fn json(mut self, body: &impl serde::Serialize) -> Result { + self.body = Some(serde_json::to_value(body).map_err(Error::Decode)?); + Ok(self) + } +} + +impl Transport { + /// Send the request and decode a JSON response body. + pub(crate) async fn execute(&self, spec: RequestSpec) -> Result { + let response = self.send(spec).await?; + let bytes = response.bytes().await.map_err(Error::Transport)?; + serde_json::from_slice(&bytes).map_err(Error::Decode) + } + + /// One unauthenticated, non-retrying request outside the normal path: + /// the token-refresh call itself. Kept off [`Transport::send`] so the + /// refresh triggered from inside a request cannot recurse into another + /// refresh. + #[cfg(feature = "oauth")] + pub(crate) async fn execute_unauthenticated( + &self, + spec: RequestSpec, + ) -> Result { + let response = self.dispatch(&spec, None).await.map_err(Error::Transport)?; + if !response.status().is_success() { + return Err(Error::Api(Box::new(map_error(response).await))); + } + let bytes = response.bytes().await.map_err(Error::Transport)?; + serde_json::from_slice(&bytes).map_err(Error::Decode) + } + + /// Send the request and return the raw successful response (exports). + pub(crate) async fn send(&self, spec: RequestSpec) -> Result { + let mut attempt: u32 = 0; + #[cfg(feature = "oauth")] + let mut refreshed = false; + loop { + // Resolve the credential per attempt: a session may rotate + // between attempts. + #[cfg(feature = "oauth")] + let mut session_generation: Option = None; + let bearer: Option = match &self.auth { + Auth::None => None, + Auth::ApiKey(key) => Some(key.clone()), + #[cfg(feature = "oauth")] + Auth::Session(session) => { + let (token, generation) = session.fresh_token(self).await?; + session_generation = Some(generation); + Some(token) + } + }; + let result = self.dispatch(&spec, bearer.as_deref()).await; + match result { + Ok(response) if response.status().is_success() => { + #[cfg(feature = "tracing")] + tracing::debug!( + method = %spec.method, + path = %spec.path, + status = response.status().as_u16(), + "request completed" + ); + return Ok(response); + } + Ok(response) => { + // A 401 on a refreshing session gets one rotation and an + // immediate replay, outside the retry budget. + #[cfg(feature = "oauth")] + if response.status() == StatusCode::UNAUTHORIZED && !refreshed { + if let (Auth::Session(session), Some(generation)) = + (&self.auth, session_generation) + { + session.refresh_stale(self, generation).await?; + refreshed = true; + continue; + } + } + if attempt < self.max_retries + && retryable_status(&spec.method, response.status()) + { + attempt += 1; + let wait = backoff_delay(attempt, retry_after_header(&response)); + #[cfg(feature = "tracing")] + tracing::debug!( + path = %spec.path, + status = response.status().as_u16(), + attempt, + wait_ms = wait.as_millis() as u64, + "retrying" + ); + sleep(wait).await; + continue; + } + return Err(Error::Api(Box::new(map_error(response).await))); + } + Err(err) => { + if attempt < self.max_retries && retryable_transport(&spec.method, &err) { + attempt += 1; + let wait = backoff_delay(attempt, None); + #[cfg(feature = "tracing")] + tracing::debug!( + path = %spec.path, + error = %err, + attempt, + wait_ms = wait.as_millis() as u64, + "retrying after transport error" + ); + sleep(wait).await; + continue; + } + return Err(Error::Transport(err)); + } + } + } + } + + async fn dispatch( + &self, + spec: &RequestSpec, + bearer: Option<&str>, + ) -> Result { + let url = format!("{}{}", self.base_url, spec.path); + let mut request = self + .http + .request(spec.method.clone(), url) + .header("X-Spoo-Client", &self.client_tag); + #[cfg(not(target_arch = "wasm32"))] + { + request = request.timeout(self.timeout); + } + if !spec.query.is_empty() { + request = request.query(&spec.query); + } + if let Some(token) = bearer { + request = request.bearer_auth(token); + } + if let Some(body) = &spec.body { + request = request.json(body); + } + request.send().await + } +} + +/// Whether a failed status is worth another attempt. Idempotent methods +/// retry the full transient set; POST and PATCH retry only where the server +/// provably did no work, so a replay can never duplicate a link. +fn retryable_status(method: &Method, status: StatusCode) -> bool { + let transient = matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504); + if idempotent(method) { + transient + } else { + matches!(status.as_u16(), 429 | 503) + } +} + +fn retryable_transport(method: &Method, err: &reqwest::Error) -> bool { + if idempotent(method) { + err.is_connect() || err.is_timeout() || err.is_request() + } else { + // The request may have reached the server: replaying a POST after an + // ambiguous failure risks duplicate side effects. A connect error is + // the one case where nothing was sent. + err.is_connect() + } +} + +fn idempotent(method: &Method) -> bool { + matches!( + *method, + Method::GET | Method::PUT | Method::DELETE | Method::HEAD + ) +} + +fn retry_after_header(response: &Response) -> Option { + response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| parse_retry_after(v, Utc::now())) +} + +/// Jittered exponential backoff: 0.5s, 1s, 2s, ... capped at 8s, scaled to +/// 50-100% of the base. A server-sent `Retry-After` overrides the computed +/// wait. +fn backoff_delay(attempt: u32, retry_after: Option) -> Duration { + if let Some(wait) = retry_after { + return wait; + } + let exp = attempt.saturating_sub(1).min(16); + let base_ms = (500u64.saturating_mul(1u64 << exp)).min(8_000); + // Cheap jitter without a rand dependency: sub-millisecond clock noise. + let noise = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| u64::from(d.subsec_nanos())) + .unwrap_or(0); + let jittered = base_ms / 2 + noise % (base_ms / 2 + 1); + Duration::from_millis(jittered) +} + +async fn sleep(duration: Duration) { + #[cfg(not(target_arch = "wasm32"))] + tokio::time::sleep(duration).await; + #[cfg(target_arch = "wasm32")] + gloo_timers::future::sleep(duration).await; +} + +/// Wire shape of the backend's error envelope. +#[derive(serde::Deserialize)] +struct ErrorEnvelope { + error: String, + code: String, + #[serde(default)] + field: Option, + #[serde(default)] + details: Option, +} + +/// Build an [`ApiError`] from an error response. When the body is not the +/// JSON error envelope (a proxy-composed HTML page, say), the message is +/// `HTTP {status}` and the raw text is preserved separately: a web page is +/// never an error message. +async fn map_error(response: Response) -> ApiError { + let status = response.status().as_u16(); + let header_code = response + .headers() + .get("x-error-code") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let request_id = response + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let rate_limit = parse_rate_limit(&response); + let text = response.text().await.unwrap_or_default(); + match serde_json::from_str::(&text) { + Ok(envelope) => ApiError { + status, + code: if envelope.code.is_empty() { + header_code.unwrap_or_default() + } else { + envelope.code + }, + message: envelope.error, + field: envelope.field, + details: envelope.details, + request_id, + rate_limit, + body: None, + }, + Err(_) => ApiError { + status, + code: header_code.unwrap_or_default(), + message: format!("HTTP {status}"), + field: None, + details: None, + request_id, + rate_limit, + body: (!text.is_empty()).then_some(text), + }, + } +} + +fn parse_rate_limit(response: &Response) -> RateLimit { + let header = |name: &str| { + response + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + }; + RateLimit { + limit: header("x-ratelimit-limit").and_then(|v| v.parse().ok()), + remaining: header("x-ratelimit-remaining").and_then(|v| v.parse().ok()), + reset: header("x-ratelimit-reset") + .and_then(|v| v.parse::().ok()) + .and_then(|epoch| chrono::DateTime::from_timestamp(epoch, 0)), + retry_after: header("retry-after").and_then(|v| parse_retry_after(&v, Utc::now())), + } +} + +/// Reduce a server-suggested filename to a safe bare filename. +/// +/// Wire-supplied paths are untrusted: consumers hand this value to +/// `File::create` or path joins, so anything that could escape a directory +/// is rejected and the caller's fallback is used instead. +pub(crate) fn sanitize_filename(raw: &str, fallback: &str) -> String { + let candidate = raw.trim().trim_matches('"'); + let absolute = candidate.starts_with('/') || candidate.starts_with('\\'); + let base = candidate + .rsplit(['/', '\\']) + .next() + .unwrap_or_default() + .trim(); + if absolute || base.is_empty() || base == "." || base == ".." { + fallback.to_owned() + } else { + base.to_owned() + } +} + +/// Extract and sanitize the filename from a `Content-Disposition` header, +/// preferring the RFC 5987 `filename*` form. +pub(crate) fn content_disposition_filename(header: Option<&str>, fallback: &str) -> String { + let Some(header) = header else { + return fallback.to_owned(); + }; + let mut plain: Option = None; + let mut extended: Option = None; + for part in header.split(';') { + let part = part.trim(); + if let Some(value) = part.strip_prefix("filename*=") { + // RFC 5987: charset'lang'percent-encoded-value + let encoded = value.rsplit('\'').next().unwrap_or_default(); + extended = Some(percent_decode(encoded)); + } else if let Some(value) = part.strip_prefix("filename=") { + plain = Some(value.trim_matches('"').to_owned()); + } + } + match extended.or(plain) { + Some(name) => sanitize_filename(&name, fallback), + None => fallback.to_owned(), + } +} + +fn percent_decode(input: &str) -> String { + let mut out = Vec::with_capacity(input.len()); + let mut bytes = input.bytes().peekable(); + while let Some(byte) = bytes.next() { + if byte == b'%' { + let hi = bytes.peek().copied(); + if let Some(hi) = hi.filter(u8::is_ascii_hexdigit) { + bytes.next(); + if let Some(lo) = bytes.peek().copied().filter(u8::is_ascii_hexdigit) { + bytes.next(); + let hex = [hi, lo]; + let hex = std::str::from_utf8(&hex).unwrap_or(""); + if let Ok(decoded) = u8::from_str_radix(hex, 16) { + out.push(decoded); + continue; + } + } + out.push(b'%'); + out.push(hi); + continue; + } + } + out.push(byte); + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostile_filenames_fall_back() { + let f = "spoo-export.json"; + assert_eq!(sanitize_filename("../../../evil.json", f), "evil.json"); + assert_eq!(sanitize_filename("/tmp/absolute-evil.json", f), f); + assert_eq!(sanitize_filename("..\\..\\evil.json", f), "evil.json"); + assert_eq!(sanitize_filename("..", f), f); + assert_eq!(sanitize_filename(".", f), f); + assert_eq!(sanitize_filename("", f), f); + assert_eq!(sanitize_filename("report.csv", f), "report.csv"); + } + + #[test] + fn content_disposition_all_forms() { + let f = "spoo-export.json"; + assert_eq!( + content_disposition_filename(Some(r#"attachment; filename="stats.json""#), f), + "stats.json" + ); + assert_eq!( + content_disposition_filename( + Some("attachment; filename*=utf-8''%2e%2e%2f%2e%2e%2fesc.json"), + f + ), + "esc.json" + ); + assert_eq!( + content_disposition_filename(Some(r#"attachment; filename="../../../evil.json""#), f), + "evil.json" + ); + assert_eq!(content_disposition_filename(None, f), f); + } + + #[test] + fn backoff_caps_at_eight_seconds() { + for attempt in 1..=10 { + let d = backoff_delay(attempt, None); + assert!( + d <= Duration::from_millis(8_000), + "attempt {attempt}: {d:?}" + ); + assert!(d >= Duration::from_millis(250), "attempt {attempt}: {d:?}"); + } + assert_eq!( + backoff_delay(1, Some(Duration::from_secs(3))), + Duration::from_secs(3) + ); + } + + #[test] + fn post_only_retries_where_server_did_no_work() { + assert!(retryable_status( + &Method::GET, + StatusCode::INTERNAL_SERVER_ERROR + )); + assert!(retryable_status(&Method::GET, StatusCode::REQUEST_TIMEOUT)); + assert!(!retryable_status( + &Method::POST, + StatusCode::INTERNAL_SERVER_ERROR + )); + assert!(!retryable_status(&Method::POST, StatusCode::BAD_GATEWAY)); + assert!(retryable_status( + &Method::POST, + StatusCode::TOO_MANY_REQUESTS + )); + assert!(retryable_status( + &Method::POST, + StatusCode::SERVICE_UNAVAILABLE + )); + assert!(!retryable_status( + &Method::PATCH, + StatusCode::GATEWAY_TIMEOUT + )); + } +} diff --git a/src/page.rs b/src/page.rs new file mode 100644 index 0000000..127b17a --- /dev/null +++ b/src/page.rs @@ -0,0 +1,127 @@ +//! Pagination primitives. + +use crate::error::Error; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) type BoxFuture = std::pin::Pin + Send>>; +#[cfg(target_arch = "wasm32")] +pub(crate) type BoxFuture = std::pin::Pin>>; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) type PageFetcher = Box BoxFuture, Error>> + Send + Sync>; +#[cfg(target_arch = "wasm32")] +pub(crate) type PageFetcher = Box BoxFuture, Error>>>; + +/// One page of a listing, with the cursor to the next. +/// +/// `items` is the page's data; the pagination facts mirror the wire. Walk +/// manually with [`Page::next_page`], or lazily with [`Page::stream`] +/// (`stream` feature) which yields items across page boundaries and fetches +/// each page only when the previous one is exhausted. +pub struct Page { + /// The items on this page. + pub items: Vec, + /// 1-based page number. + pub page: u64, + /// Page size the server applied. + pub page_size: u64, + /// Total items across all pages. + pub total: u64, + /// Whether another page exists. + pub has_next: bool, + pub(crate) next: Option>, +} + +impl std::fmt::Debug for Page { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Page") + .field("items", &self.items) + .field("page", &self.page) + .field("page_size", &self.page_size) + .field("total", &self.total) + .field("has_next", &self.has_next) + .finish_non_exhaustive() + } +} + +impl Page { + /// Fetch the next page, or `None` when this was the last one. + pub async fn next_page(&self) -> Result>, Error> { + match (&self.next, self.has_next) { + (Some(fetch), true) => fetch().await.map(Some), + _ => Ok(None), + } + } +} + +#[cfg(feature = "stream")] +mod stream { + use super::*; + use std::pin::Pin; + use std::task::{Context, Poll}; + + /// A lazy item stream over every page of a listing. Created by + /// [`Page::stream`]. + pub struct PageStream { + buffer: std::collections::VecDeque, + next: Option>, + has_next: bool, + pending: Option, Error>>>, + } + + impl Page { + /// Consume this page into a stream of items that fetches following + /// pages on demand. + pub fn stream(self) -> PageStream { + PageStream { + buffer: self.items.into(), + next: self.next, + has_next: self.has_next, + pending: None, + } + } + } + + impl futures_core::Stream for PageStream { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + // Fields are all Unpin; the future is re-pinned on poll. + let this = self.get_mut(); + loop { + if let Some(item) = this.buffer.pop_front() { + return Poll::Ready(Some(Ok(item))); + } + if let Some(pending) = this.pending.as_mut() { + match pending.as_mut().poll(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Ok(page)) => { + this.pending = None; + this.buffer = page.items.into(); + this.has_next = page.has_next; + this.next = page.next; + continue; + } + Poll::Ready(Err(err)) => { + this.pending = None; + this.has_next = false; + this.next = None; + return Poll::Ready(Some(Err(err))); + } + } + } + match (&this.next, this.has_next) { + (Some(fetch), true) => { + this.pending = Some(fetch()); + } + _ => return Poll::Ready(None), + } + } + } + } + + impl Unpin for PageStream {} +} + +#[cfg(feature = "stream")] +pub use stream::PageStream; diff --git a/src/patch.rs b/src/patch.rs new file mode 100644 index 0000000..4ced972 --- /dev/null +++ b/src/patch.rs @@ -0,0 +1,75 @@ +//! Tri-state PATCH semantics. + +use serde::{Serialize, Serializer}; + +/// The three things a PATCH field can say: leave the stored value alone, +/// clear it, or replace it. +/// +/// The update endpoint gives `null` a per-field meaning (clear the password, +/// remove the expiry, move back to the default domain), so "send null" and +/// "send nothing" must stay distinguishable. `Option` cannot carry that +/// distinction; this type can. Update builders use it internally and expose +/// it through paired methods (`password(...)` / `remove_password()`), so most +/// callers never name it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Patch { + /// Do not send the field: the stored value stays as it is. + #[default] + Keep, + /// Send an explicit `null`: clear or reset the field. + Null, + /// Send a new value. + Set(T), +} + +impl Patch { + /// Whether this field should be omitted from the wire entirely. + pub fn is_keep(&self) -> bool { + matches!(self, Patch::Keep) + } +} + +impl From for Patch { + fn from(value: T) -> Self { + Patch::Set(value) + } +} + +impl Serialize for Patch { + fn serialize(&self, serializer: S) -> Result { + match self { + // Keep is skipped at the struct level via + // `skip_serializing_if = "Patch::is_keep"`; serializing it + // anyway degrades to null rather than failing. + Patch::Keep | Patch::Null => serializer.serialize_none(), + Patch::Set(value) => value.serialize(serializer), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + + #[derive(Serialize)] + struct Body { + #[serde(skip_serializing_if = "Patch::is_keep")] + password: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + max_clicks: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + alias: Patch, + } + + #[test] + fn wire_bytes_distinguish_all_three_states() { + let body = Body { + password: Patch::Null, + max_clicks: Patch::Set(10), + alias: Patch::Keep, + }; + let json = serde_json::to_string(&body).unwrap(); + assert_eq!(json, r#"{"password":null,"max_clicks":10}"#); + } +} From 8455dffded611a04c71ccab5f7a8e9c4c8a1932f Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 01:15:08 +0530 Subject: [PATCH 03/13] feat: v1 resource surface Consuming builders ending in send(), tri-state PATCH via Patch, lazy Page/stream pagination, streaming exports with the two distinct export routes, ETag-cached emoji catalogue, public stats and previews, and raw typed passthroughs for endpoints the SDK does not cover yet. --- src/client.rs | 294 +++++++++++ src/lib.rs | 62 ++- src/resources/auth.rs | 81 +++ src/resources/emoji.rs | 136 +++++ src/resources/links.rs | 1114 +++++++++++++++++++++++++++++++++++++++ src/resources/mod.rs | 7 + src/resources/public.rs | 199 +++++++ src/resources/stats.rs | 584 ++++++++++++++++++++ 8 files changed, 2459 insertions(+), 18 deletions(-) create mode 100644 src/client.rs create mode 100644 src/resources/auth.rs create mode 100644 src/resources/emoji.rs create mode 100644 src/resources/links.rs create mode 100644 src/resources/mod.rs create mode 100644 src/resources/public.rs create mode 100644 src/resources/stats.rs diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..79f893a --- /dev/null +++ b/src/client.rs @@ -0,0 +1,294 @@ +//! The client and its builder. + +use std::sync::Arc; +use std::time::Duration; + +use reqwest::Method; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::error::Error; +use crate::http::{Auth, RequestSpec, Transport}; +use crate::resources::{ + auth::AuthResource, emoji::Emoji, links::Links, public::Public, stats::Stats, +}; + +/// Default production endpoint. +pub const DEFAULT_BASE_URL: &str = "https://spoo.me"; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_MAX_RETRIES: u32 = 2; + +/// The spoo.me API client. +/// +/// Cheap to clone (`Arc` inside) and safe to share across tasks. +/// +/// ```no_run +/// # async fn run() -> Result<(), spoo_me::Error> { +/// let client = spoo_me::Client::new("spoo_your_api_key"); +/// let link = client.links().create("https://example.com/launch").send().await?; +/// println!("{}", link.short_url); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct Client { + pub(crate) transport: Arc, + pub(crate) emoji_cache: Arc>>, +} + +impl std::fmt::Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Client") + .field("base_url", &self.transport.base_url) + .finish_non_exhaustive() + } +} + +impl Client { + /// A client authenticating with an API key (`spoo_...`). + pub fn new(api_key: impl Into) -> Client { + Client::builder().api_key(api_key).build_unchecked() + } + + /// A client with no credentials, for the public endpoints (anonymous + /// shortening, public stats, previews, the emoji set). + pub fn anonymous() -> Client { + Client::builder().build_unchecked() + } + + /// A client reading its API key from the `SPOO_API_KEY` environment + /// variable. Environment access happens here and nowhere else: the + /// constructors never read configuration you did not ask for. + pub fn from_env() -> Result { + match std::env::var("SPOO_API_KEY") { + Ok(key) if !key.is_empty() => Ok(Client::new(key)), + _ => Err(Error::Config( + "SPOO_API_KEY is not set; pass a key to Client::new instead".into(), + )), + } + } + + /// Start configuring a client. + pub fn builder() -> ClientBuilder { + ClientBuilder::default() + } + + /// Link management: create, list, update, delete, bulk operations, + /// claiming anonymous links. + pub fn links(&self) -> Links { + Links { + client: self.clone(), + } + } + + /// Click statistics and exports. + pub fn stats(&self) -> Stats { + Stats { + client: self.clone(), + } + } + + /// Public, unauthenticated link surfaces: stats pages and previews. + pub fn public(&self) -> Public { + Public { + client: self.clone(), + } + } + + /// The emoji-alias catalogue and its policy caps. + pub fn emoji(&self) -> Emoji { + Emoji { + client: self.clone(), + } + } + + /// Identity: who this client is signed in as. + pub fn auth(&self) -> AuthResource { + AuthResource { + client: self.clone(), + } + } + + /// Sign in with Spoo: device-code exchange and refreshing sessions. + #[cfg(feature = "oauth")] + pub fn oauth(&self) -> crate::oauth::OAuth { + crate::oauth::OAuth { + client: self.clone(), + } + } + + /// Raw typed `GET` with the client's auth, retries, timeout and error + /// mapping applied: the supported pressure valve for endpoints this SDK + /// does not cover yet. Needing it is a signal worth an issue on the SDK. + pub async fn get( + &self, + path: &str, + query: &[(&str, &str)], + ) -> Result { + let mut spec = RequestSpec::new(Method::GET, path); + for (key, value) in query { + spec.query.push(((*key).to_owned(), (*value).to_owned())); + } + self.transport.execute(spec).await + } + + /// Raw typed `POST`. See [`Client::get`]. + pub async fn post( + &self, + path: &str, + body: &impl Serialize, + ) -> Result { + let spec = RequestSpec::new(Method::POST, path).json(body)?; + self.transport.execute(spec).await + } + + /// Raw typed `PATCH`. See [`Client::get`]. + pub async fn patch( + &self, + path: &str, + body: &impl Serialize, + ) -> Result { + let spec = RequestSpec::new(Method::PATCH, path).json(body)?; + self.transport.execute(spec).await + } + + /// Raw typed `DELETE`. See [`Client::get`]. + pub async fn delete(&self, path: &str) -> Result { + let spec = RequestSpec::new(Method::DELETE, path); + self.transport.execute(spec).await + } +} + +/// Configures and builds a [`Client`]. +#[derive(Default)] +pub struct ClientBuilder { + api_key: Option, + #[cfg(feature = "oauth")] + session: Option>, + base_url: Option, + http_client: Option, + max_retries: Option, + timeout: Option, + client_tag: Option, +} + +impl ClientBuilder { + /// Authenticate with an API key (`spoo_...`). + pub fn api_key(mut self, key: impl Into) -> Self { + self.api_key = Some(key.into()); + self + } + + /// Authenticate with a refreshing Sign in with Spoo session. + #[cfg(feature = "oauth")] + pub fn session(mut self, session: Arc) -> Self { + self.session = Some(session); + self + } + + /// Point at a self-hosted instance instead of `https://spoo.me`. + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = Some(url.into()); + self + } + + /// Inject a configured `reqwest::Client` (shared connection pool, + /// proxies, custom TLS). + pub fn http_client(mut self, client: reqwest::Client) -> Self { + self.http_client = Some(client); + self + } + + /// Retries after the first attempt. Default 2. + pub fn max_retries(mut self, retries: u32) -> Self { + self.max_retries = Some(retries); + self + } + + /// Per-request timeout. Default 30 seconds. Ignored on wasm, where the + /// host runtime owns request lifetimes. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Override the `X-Spoo-Client` identification header, e.g. + /// `"my-app/1.0"`. Defaults to this SDK's own tag. + pub fn client_tag(mut self, tag: impl Into) -> Self { + self.client_tag = Some(tag.into()); + self + } + + /// Build the client, validating configuration. + pub fn build(self) -> Result { + if let Some(url) = &self.base_url { + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(Error::Config(format!( + "base_url must start with http:// or https://, got {url:?}" + ))); + } + } + Ok(self.build_unchecked()) + } + + fn build_unchecked(self) -> Client { + let auth = { + #[cfg(feature = "oauth")] + if let Some(session) = self.session { + Auth::Session(session) + } else if let Some(key) = self.api_key { + Auth::ApiKey(key) + } else { + Auth::None + } + #[cfg(not(feature = "oauth"))] + if let Some(key) = self.api_key { + Auth::ApiKey(key) + } else { + Auth::None + } + }; + let base_url = self + .base_url + .unwrap_or_else(|| DEFAULT_BASE_URL.to_owned()) + .trim_end_matches('/') + .to_owned(); + let transport = Transport { + http: self.http_client.unwrap_or_default(), + base_url, + auth, + client_tag: self + .client_tag + .unwrap_or_else(|| format!("sdk-rust/{}", env!("CARGO_PKG_VERSION"))), + max_retries: self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES), + timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT), + }; + Client { + transport: Arc::new(transport), + emoji_cache: Arc::new(std::sync::Mutex::new(None)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_is_send_sync_clone() { + fn assert_traits() {} + assert_traits::(); + } + + #[test] + fn builder_rejects_bad_base_url() { + assert!(Client::builder().base_url("spoo.me").build().is_err()); + assert!( + Client::builder() + .base_url("https://spoo.me/") + .build() + .is_ok() + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index a2441e7..2575664 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,24 +1,50 @@ -//! Rust bindings for accessing the spoo.me API -//! -//! This crate provides a client that can access all endpoints provided by spoo.me, with support for self hosted instances of it. -//! -//!
-//! -//! # Features -//! - `blocking`: Enables blocking methods for the client, allowing synchronous calls to the API. -//! - `custom_url`: Allows setting a custom base URL for the client, useful for self-hosted instances of spoo.me. - +#![doc = include_str!("../README.md")] +#![forbid(unsafe_code)] #![warn(missing_docs)] #![warn(clippy::all)] +#![cfg_attr( + not(test), + deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + ) +)] + +mod client; +mod error; +mod http; +mod page; +mod patch; -/// A client for the URL shortener API. -pub mod client; +pub mod resources; -/// Errors related to a request. -pub mod errors; +/// Sign in with Spoo: PKCE, device-code exchange, refreshing sessions. +#[cfg(feature = "oauth")] +pub mod oauth; -/// Requests and responses for the URL shortener API. -pub mod requests; +pub use client::{Client, ClientBuilder, DEFAULT_BASE_URL}; +pub use error::{ApiError, Error, RateLimit}; +pub use page::Page; +#[cfg(feature = "stream")] +pub use page::PageStream; +pub use patch::Patch; -/// Tools for validating and formatting requests. -pub mod utils; +pub use resources::auth::{AuthProvider, AuthResource, ProfilePicture, User}; +pub use resources::emoji::{Emoji, EmojiEntry, EmojiSet}; +pub use resources::links::{ + AliasCheck, AliasIssue, AliasKind, BulkErrorCode, BulkOutcome, BulkResult, BulkSummary, + CheckAliasBuilder, ClaimOutcome, ClaimRequest, ClaimResult, ClaimStatus, CreateLinkBuilder, + DeletedLink, DomainPurge, Link, LinkItem, LinkStatus, Links, ListLinksBuilder, MetaTags, + MetaTagsInfo, SettableStatus, SortBy, SortOrder, UpdateLinkBuilder, UpdatedLink, +}; +pub use resources::public::{ + Generation, Preview, PreviewDestination, PreviewGeoDestination, Public, PublicLinkFacts, + PublicStats, PublicStatsBuilder, PublicStatus, +}; +pub use resources::stats::{ + AccountStatsBuilder, ComputedMetrics, Dimension, Export, ExportBuilder, ExportFormat, + FilterDimension, LinkStatsBuilder, LinkStatsReport, Metric, Scope, Stats, StatsReport, Summary, + TimeBucketInfo, TimeRange, +}; diff --git a/src/resources/auth.rs b/src/resources/auth.rs new file mode 100644 index 0000000..6c6fd89 --- /dev/null +++ b/src/resources/auth.rs @@ -0,0 +1,81 @@ +//! Identity: who this client is signed in as. + +use reqwest::Method; +use serde::Deserialize; + +use crate::client::Client; +use crate::error::Error; +use crate::http::RequestSpec; + +/// Identity reads, from [`crate::Client::auth`]. +pub struct AuthResource { + pub(crate) client: Client, +} + +/// A linked sign-in provider. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct AuthProvider { + /// Provider name, e.g. `google` or `github`. + #[serde(default)] + pub provider: Option, + /// The provider-side account email, when shared. + #[serde(default)] + pub email: Option, +} + +/// Profile picture info. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct ProfilePicture { + /// Picture URL. + #[serde(default)] + pub url: Option, + /// Where it came from: an OAuth provider name or `upload`. + #[serde(default)] + pub source: Option, +} + +/// The signed-in user's profile. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct User { + /// User id. + pub id: String, + /// Email address. + #[serde(default)] + pub email: Option, + /// Whether the email address is verified. + pub email_verified: bool, + /// Display name. + #[serde(default)] + pub user_name: Option, + /// Subscription plan. + pub plan: String, + /// Whether the user has set a password. + pub password_set: bool, + /// Linked sign-in providers. + #[serde(default)] + pub auth_providers: Vec, + /// Profile picture, when set. + #[serde(default)] + pub pfp: Option, +} + +#[derive(Deserialize)] +struct MeWire { + user: User, +} + +impl AuthResource { + /// Who this client is signed in as. Works with both API keys and + /// Sign in with Spoo sessions. + pub async fn me(&self) -> Result { + let wire: MeWire = self + .client + .transport + .execute(RequestSpec::new(Method::GET, "/auth/me")) + .await?; + Ok(wire.user) + } +} diff --git a/src/resources/emoji.rs b/src/resources/emoji.rs new file mode 100644 index 0000000..8b3344d --- /dev/null +++ b/src/resources/emoji.rs @@ -0,0 +1,136 @@ +//! The emoji-alias catalogue and its policy caps. + +use reqwest::Method; +use serde::Deserialize; + +use crate::client::Client; +use crate::error::Error; +use crate::http::RequestSpec; + +/// The emoji catalogue, from [`crate::Client::emoji`]. +pub struct Emoji { + pub(crate) client: Client, +} + +/// One accepted emoji, enriched for client-side search. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct EmojiEntry { + /// Raw canonical emoji character (no `U+FE0F` variation selector), + /// matching how aliases are stored and echoed. + #[serde(rename = "c")] + pub character: String, + /// Human-readable name, lowercased with spaces: the primary search key. + #[serde(rename = "n")] + pub name: String, + /// Canonical Unicode category display name, for picker tabs. Entries + /// arrive sorted by category and within-category order. + #[serde(rename = "g")] + pub group: String, + /// Whether this emoji is in the auto-generation pool. + #[serde(rename = "gen")] + pub generates: bool, + /// Extra search aliases, when the source lists any. + #[serde(rename = "k", default)] + pub keywords: Option>, +} + +/// The accepted emoji catalogue and its policy caps. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct EmojiSet { + /// Newest Unicode emoji version a custom alias may use. + pub accept_max_version: f64, + /// Cap for auto-generated aliases (lower, for older platform coverage). + pub generate_max_version: f64, + /// Maximum emoji graphemes in one alias. + pub max_graphemes: u64, + /// Every single-codepoint emoji a user may choose, with names and + /// categories. Skin-tone variants are not enumerated: the base emoji + /// suffices. + pub emoji: Vec, +} + +pub(crate) struct CachedSet { + pub(crate) etag: String, + pub(crate) set: EmojiSet, +} + +impl Emoji { + /// Fetch the catalogue. The set changes rarely, so the client caches it + /// with the server's ETag and revalidates with `If-None-Match`: a 304 + /// answers from cache without re-downloading the list. + pub async fn set(&self) -> Result { + let cached_etag = { + let cache = lock(&self.client.emoji_cache); + cache.as_ref().map(|c| c.etag.clone()) + }; + match cached_etag { + Some(etag) => self.fetch_with_etag(&etag).await, + None => { + let response = self + .client + .transport + .send(RequestSpec::new(Method::GET, "/api/v1/emoji-set")) + .await?; + self.store(response).await + } + } + } + + async fn fetch_with_etag(&self, etag: &str) -> Result { + let url = format!("{}/api/v1/emoji-set", self.client.transport.base_url); + let response = self + .client + .transport + .http + .get(url) + .header("X-Spoo-Client", &self.client.transport.client_tag) + .header("If-None-Match", etag) + .send() + .await + .map_err(Error::Transport)?; + if response.status() == reqwest::StatusCode::NOT_MODIFIED { + let cache = lock(&self.client.emoji_cache); + if let Some(cached) = cache.as_ref() { + return Ok(cached.set.clone()); + } + } + if response.status().is_success() { + return self.store(response).await; + } + // An error on a cache revalidation: fall back to the full path so + // retries and error mapping apply. + self.client + .transport + .execute(RequestSpec::new(Method::GET, "/api/v1/emoji-set")) + .await + } + + async fn store(&self, response: reqwest::Response) -> Result { + let etag = response + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let bytes = response.bytes().await.map_err(Error::Transport)?; + let set: EmojiSet = serde_json::from_slice(&bytes).map_err(Error::Decode)?; + if let Some(etag) = etag { + let mut cache = lock(&self.client.emoji_cache); + *cache = Some(CachedSet { + etag, + set: set.clone(), + }); + } + Ok(set) + } +} + +fn lock( + cache: &std::sync::Mutex>, +) -> std::sync::MutexGuard<'_, Option> { + match cache.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} diff --git a/src/resources/links.rs b/src/resources/links.rs new file mode 100644 index 0000000..1da8b12 --- /dev/null +++ b/src/resources/links.rs @@ -0,0 +1,1114 @@ +//! Link management: create, list, update, delete, bulk operations, claiming. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use crate::client::Client; +use crate::error::Error; +use crate::http::RequestSpec; +use crate::page::Page; +use crate::patch::Patch; + +/// Link management, from [`crate::Client::links`]. +pub struct Links { + pub(crate) client: Client, +} + +/// Lifecycle state of a link. `Expired` and `Blocked` are derived or +/// system-set; callers can only set `Active` and `Inactive`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[non_exhaustive] +pub enum LinkStatus { + /// Redirects are served. + #[serde(rename = "ACTIVE")] + Active, + /// Redirects are disabled by the owner. + #[serde(rename = "INACTIVE")] + Inactive, + /// The link ran out of time or clicks. + #[serde(rename = "EXPIRED")] + Expired, + /// Taken down because the destination was flagged. + #[serde(rename = "BLOCKED")] + Blocked, + /// A status this SDK version does not know yet. + #[serde(other)] + Unknown, +} + +/// The two statuses a caller may set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub enum SettableStatus { + /// Enable redirects. + #[serde(rename = "ACTIVE")] + Active, + /// Disable redirects. + #[serde(rename = "INACTIVE")] + Inactive, +} + +/// Alias style to auto-generate when no explicit alias is given. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub enum AliasKind { + /// A short alphanumeric code (the default). + #[serde(rename = "alphanumeric")] + Alphanumeric, + /// An emoji sequence. + #[serde(rename = "emoji")] + Emoji, +} + +/// A custom social preview (og:title, og:description, og:image, +/// theme-color) served to link-preview crawlers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MetaTags { + title: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + color: Option, +} + +impl MetaTags { + /// A preview with the required headline (og:title). + pub fn new(title: impl Into) -> Self { + MetaTags { + title: title.into(), + description: None, + image: None, + color: None, + } + } + + /// og:description. Roughly 200 characters render on most platforms. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// og:image: an https URL, or a `data:image/...;base64,` URI stored on + /// spoo's CDN. 1200x630 recommended, under 300KB. + pub fn image(mut self, image: impl Into) -> Self { + self.image = Some(image.into()); + self + } + + /// Accent color for Discord embeds, `#RRGGBB`. + pub fn color(mut self, color: impl Into) -> Self { + self.color = Some(color.into()); + self + } +} + +/// Custom social-preview settings stored on a link. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct MetaTagsInfo { + /// og:title. + pub title: String, + /// og:description. + #[serde(default)] + pub description: Option, + /// og:image URL. + #[serde(default)] + pub image: Option, + /// Discord embed accent color. + #[serde(default)] + pub color: Option, + /// Non-fatal quality notes, e.g. an image WhatsApp may drop. + #[serde(default)] + pub warnings: Option>, +} + +/// A freshly created link (`POST /api/v1/shorten`). +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct Link { + /// The identifier the management endpoints address this link by. + pub id: String, + /// Short code. + pub alias: String, + /// Full shortened URL, ready to share. + pub short_url: String, + /// The destination. + pub long_url: String, + /// Owning user, `None` for anonymous creates. + #[serde(default)] + pub owner_id: Option, + /// When the link was created. + #[serde(with = "chrono::serde::ts_seconds")] + pub created_at: DateTime, + /// Lifecycle state. + pub status: LinkStatus, + /// Whether statistics are owner-only. + #[serde(default)] + pub private_stats: Option, + /// Per-country destination overrides (ISO alpha-2 code to URL). + #[serde(default)] + pub geo_rules: Option>, + /// Custom social preview, if configured. + #[serde(default)] + pub meta_tags: Option, + /// One-time bearer proof of creation, present only on anonymous + /// creates and shown exactly once. Store it to later attach the link to + /// an account via [`Links::claim`]. + #[serde(default)] + pub claim_token: Option, +} + +/// A link as returned by the list, get and get-by-address endpoints. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct LinkItem { + /// The identifier the management endpoints address this link by. + pub id: String, + /// Short code. + #[serde(default)] + pub alias: Option, + /// The destination. + #[serde(default)] + pub long_url: Option, + /// Lifecycle state. + #[serde(default)] + pub status: Option, + /// When the link was created. + #[serde(default)] + pub created_at: Option>, + /// When the link expires, if an expiry is set. + #[serde(default, with = "chrono::serde::ts_seconds_option")] + pub expire_after: Option>, + /// Click limit, if one is set. + #[serde(default)] + pub max_clicks: Option, + /// Whether statistics are owner-only. + #[serde(default)] + pub private_stats: Option, + /// Whether known bots are blocked. + #[serde(default)] + pub block_bots: Option, + /// Whether the link is password-protected. + pub password_set: bool, + /// Lifetime click count. + #[serde(default)] + pub total_clicks: Option, + /// Most recent click. + #[serde(default)] + pub last_click: Option>, + /// Custom domain the link lives on, `None` for the default namespace. + #[serde(default)] + pub domain: Option, + /// Per-country destination overrides. + #[serde(default)] + pub geo_rules: Option>, + /// Custom social preview, if configured. + #[serde(default)] + pub meta_tags: Option, +} + +/// The link's state after an update (`PATCH /api/v1/urls/{url_id}`). +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct UpdatedLink { + /// The link's identifier. + pub id: String, + /// Short code. + #[serde(default)] + pub alias: Option, + /// The destination. + #[serde(default)] + pub long_url: Option, + /// Lifecycle state. + #[serde(default)] + pub status: Option, + /// Whether the link is password-protected. + pub password_set: bool, + /// Click limit, if one is set. + #[serde(default)] + pub max_clicks: Option, + /// When the link expires, if an expiry is set. + #[serde(default, with = "chrono::serde::ts_seconds_option")] + pub expire_after: Option>, + /// Whether known bots are blocked. + #[serde(default)] + pub block_bots: Option, + /// Whether statistics are owner-only. + #[serde(default)] + pub private_stats: Option, + /// Custom domain the link lives on, `None` for the default namespace. + #[serde(default)] + pub domain: Option, + /// Per-country destination overrides. + #[serde(default)] + pub geo_rules: Option>, + /// When the update was applied. + #[serde(with = "chrono::serde::ts_seconds")] + pub updated_at: DateTime, + /// Custom social preview, if configured. + #[serde(default)] + pub meta_tags: Option, +} + +/// Confirmation of a deletion. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct DeletedLink { + /// Confirmation message. + pub message: String, + /// The deleted link's identifier. + pub id: String, +} + +/// Why an alias is unavailable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub enum AliasIssue { + /// Too short or too long. + #[serde(rename = "length")] + Length, + /// Contains characters outside the accepted set. + #[serde(rename = "format")] + Format, + /// Reserved by the platform. + #[serde(rename = "reserved")] + Reserved, + /// Already in use. + #[serde(rename = "taken")] + Taken, + /// Emoji alias contains sequences outside the accepted set. + #[serde(rename = "emoji_policy")] + EmojiPolicy, + /// A reason this SDK version does not know yet. + #[serde(other)] + Unknown, +} + +/// Result of an alias availability check. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct AliasCheck { + /// Whether the alias is free to use: it passes validation AND is not + /// taken. + pub available: bool, + /// When unavailable, why. + #[serde(default)] + pub reason: Option, +} + +/// Counts derived from a bulk operation's result rows. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct BulkSummary { + /// Unique ids in the request, after deduplication. + pub total: u64, + /// Rows that succeeded. + pub succeeded: u64, + /// Rows that failed. + pub failed: u64, +} + +/// Machine-readable cause of a per-item bulk failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum BulkErrorCode { + /// No such link in your account (someone else's id answers the same, + /// deliberately). + NotFound, + /// The link is blocked. + Forbidden, + /// The operation conflicts with the link's current state. + Conflict, + /// The value failed validation for this link. + ValidationError, + /// Unexpected per-item failure, logged server-side. + Internal, + /// Processing aborted before this item. + NotAttempted, + /// A code this SDK version does not know yet. + #[serde(other)] + Unknown, +} + +/// Per-item verdict of a bulk operation. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct BulkResult { + /// The requested link id. + pub id: String, + /// Echoed when the id resolved to a link you own. + #[serde(default)] + pub alias: Option, + /// Whether the operation succeeded for this id. + pub ok: bool, + /// Failure cause to branch on; `None` when ok. + #[serde(default)] + pub error_code: Option, + /// Display-safe failure message; not stable, branch on `error_code`. + #[serde(default)] + pub error: Option, +} + +/// Envelope of every bulk link operation. Partial success is data, not an +/// error: inspect `results` row by row. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct BulkOutcome { + /// Aggregate counts. + pub summary: BulkSummary, + /// One row per unique requested id, in request order. + pub results: Vec, +} + +/// Confirmation of a whole-domain bulk delete. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct DomainPurge { + /// Confirmation message. + pub message: String, + /// Number of links deleted. + pub count: u64, + /// Domain whose links were deleted. + pub domain: String, +} + +/// One (link id, claim token) pair to claim. +#[derive(Debug, Clone, Serialize)] +pub struct ClaimRequest { + url_id: String, + token: String, +} + +impl ClaimRequest { + /// Pair a link id with the one-time claim token its anonymous create + /// returned. + pub fn new(url_id: impl Into, token: impl Into) -> Self { + ClaimRequest { + url_id: url_id.into(), + token: token.into(), + } + } +} + +/// Per-item outcome of a claim batch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ClaimStatus { + /// Ownership transferred; the token is burned. + Claimed, + /// You already own this link (idempotent repeat). + AlreadyYours, + /// Unknown id, wrong token, or a link that is not claimable + /// (deliberately indistinguishable). + Invalid, + /// A status this SDK version does not know yet. + #[serde(other)] + Unknown, +} + +/// One row of a claim batch's outcome. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct ClaimResult { + /// The link id from the request item. + pub url_id: String, + /// What happened. + pub status: ClaimStatus, +} + +/// Outcome of a claim batch. The batch never hard-fails: every submitted +/// item gets a result, in request order. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct ClaimOutcome { + /// One outcome per submitted item. + pub results: Vec, + /// Convenience count of `Claimed` results. + pub claimed: u64, +} + +impl Links { + /// Shorten a URL. Returns a builder: chain options, then `.send()`. + /// + /// ```no_run + /// # async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> { + /// let link = client + /// .links() + /// .create("https://example.com/launch") + /// .alias("launch") + /// .max_clicks(10_000) + /// .send() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn create(&self, long_url: impl Into) -> CreateLinkBuilder { + CreateLinkBuilder { + client: self.client.clone(), + body: CreateLinkBody { + long_url: long_url.into(), + ..Default::default() + }, + } + } + + /// Check whether an alias is available before trying to create it. + pub fn check_alias(&self, alias: impl Into) -> CheckAliasBuilder { + CheckAliasBuilder { + client: self.client.clone(), + alias: alias.into(), + domain: None, + } + } + + /// List your links, paginated. Returns a builder: chain filters, then + /// `.send()` for a [`Page`]. + pub fn list(&self) -> ListLinksBuilder { + ListLinksBuilder { + client: self.client.clone(), + page: None, + page_size: None, + sort_by: None, + sort_order: None, + domain: None, + filter: serde_json::Map::new(), + } + } + + /// Fetch one link by its id. + pub async fn get(&self, id: &str) -> Result { + self.client + .transport + .execute(RequestSpec::new(Method::GET, format!("/api/v1/urls/{id}"))) + .await + } + + /// Fetch one link by where it lives: domain plus alias. Pass the system + /// domain (`spoo.me`) for default-namespace links. + pub async fn get_by_address(&self, domain: &str, alias: &str) -> Result { + self.client + .transport + .execute(RequestSpec::new( + Method::GET, + format!("/api/v1/urls/{domain}/{alias}"), + )) + .await + } + + /// Update a link. Returns a builder: chain changes, then `.send()`. + /// Fields you do not touch keep their stored values. + pub fn update(&self, id: impl Into) -> UpdateLinkBuilder { + UpdateLinkBuilder { + client: self.client.clone(), + id: id.into(), + body: UpdateLinkBody::default(), + } + } + + /// Enable or disable a link's redirects. + pub async fn set_status(&self, id: &str, status: SettableStatus) -> Result { + let spec = RequestSpec::new(Method::PATCH, format!("/api/v1/urls/{id}/status")) + .json(&serde_json::json!({ "status": status }))?; + self.client.transport.execute(spec).await + } + + /// Delete a link permanently. + pub async fn delete(&self, id: &str) -> Result { + self.client + .transport + .execute(RequestSpec::new( + Method::DELETE, + format!("/api/v1/urls/{id}"), + )) + .await + } + + /// Delete every link on one of your custom domains. Irreversible and + /// whole-domain: there is deliberately no filter. + pub async fn delete_all_on_domain(&self, domain: &str) -> Result { + let spec = + RequestSpec::new(Method::DELETE, "/api/v1/urls").query("domain", Some(domain.into())); + self.client.transport.execute(spec).await + } + + /// Delete up to 100 links by id. Partial success is data: check the + /// outcome's rows. + pub async fn bulk_delete(&self, ids: I) -> Result + where + I: IntoIterator, + S: Into, + { + self.bulk("/api/v1/urls/bulk/delete", ids, serde_json::Map::new()) + .await + } + + /// Set the status of up to 100 links at once. + pub async fn bulk_set_status( + &self, + ids: I, + status: SettableStatus, + ) -> Result + where + I: IntoIterator, + S: Into, + { + let mut extra = serde_json::Map::new(); + extra.insert( + "status".into(), + serde_json::to_value(status).map_err(Error::Decode)?, + ); + self.bulk("/api/v1/urls/bulk/status", ids, extra).await + } + + /// Set or clear the expiry of up to 100 links at once. `None` clears. + pub async fn bulk_set_expiry( + &self, + ids: I, + expire_after: Option>, + ) -> Result + where + I: IntoIterator, + S: Into, + { + let mut extra = serde_json::Map::new(); + extra.insert( + "expire_after".into(), + match expire_after { + Some(when) => serde_json::Value::String(when.to_rfc3339()), + None => serde_json::Value::Null, + }, + ); + self.bulk("/api/v1/urls/bulk/expiry", ids, extra).await + } + + /// Move up to 100 links to a custom domain you own, or back to the + /// default namespace with `None`. + pub async fn bulk_move_domain( + &self, + ids: I, + domain: Option<&str>, + ) -> Result + where + I: IntoIterator, + S: Into, + { + let mut extra = serde_json::Map::new(); + extra.insert( + "domain".into(), + match domain { + Some(fqdn) => serde_json::Value::String(fqdn.to_owned()), + None => serde_json::Value::Null, + }, + ); + self.bulk("/api/v1/urls/bulk/domain", ids, extra).await + } + + async fn bulk( + &self, + path: &str, + ids: I, + mut extra: serde_json::Map, + ) -> Result + where + I: IntoIterator, + S: Into, + { + let ids: Vec = ids.into_iter().map(Into::into).collect(); + extra.insert( + "ids".into(), + serde_json::Value::Array(ids.into_iter().map(serde_json::Value::String).collect()), + ); + let spec = RequestSpec::new(Method::POST, path).json(&serde_json::Value::Object(extra))?; + self.client.transport.execute(spec).await + } + + /// Attach anonymously created links to this account, up to 16 per call. + /// Each item resolves independently; the batch never hard-fails. + pub async fn claim( + &self, + claims: impl IntoIterator, + ) -> Result { + let claims: Vec = claims.into_iter().collect(); + let spec = RequestSpec::new(Method::POST, "/api/v1/urls/claim") + .json(&serde_json::json!({ "claims": claims }))?; + self.client.transport.execute(spec).await + } +} + +#[derive(Default, Serialize, Clone)] +struct CreateLinkBody { + long_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + alias: Option, + #[serde(skip_serializing_if = "Option::is_none")] + alias_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + block_bots: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_clicks: Option, + #[serde(skip_serializing_if = "Option::is_none")] + expire_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + private_stats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + domain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + geo_rules: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + meta_tags: Option, +} + +/// Builder for [`Links::create`]. Chain options, finish with +/// [`CreateLinkBuilder::send`]. +#[must_use = "builders do nothing until .send() is awaited"] +pub struct CreateLinkBuilder { + client: Client, + body: CreateLinkBody, +} + +impl CreateLinkBuilder { + /// Custom short code: alphanumeric (3-16 chars) or emoji-only (1-15 + /// fully-qualified emoji). + pub fn alias(mut self, alias: impl Into) -> Self { + self.body.alias = Some(alias.into()); + self + } + + /// Alias style to auto-generate when no explicit alias is given. + pub fn alias_kind(mut self, kind: AliasKind) -> Self { + self.body.alias_type = Some(kind); + self + } + + /// Password-protect the link. Min 8 chars with a letter, a number and a + /// special character (the server validates). + pub fn password(mut self, password: impl Into) -> Self { + self.body.password = Some(password.into()); + self + } + + /// Block known bot user agents. + pub fn block_bots(mut self, block: bool) -> Self { + self.body.block_bots = Some(block); + self + } + + /// Expire the link after this many clicks. + pub fn max_clicks(mut self, max: u64) -> Self { + self.body.max_clicks = Some(max); + self + } + + /// Expire the link at a point in time. + pub fn expire_after(mut self, when: DateTime) -> Self { + self.body.expire_after = Some(when.to_rfc3339()); + self + } + + /// Make statistics owner-only. Requires authentication. + pub fn private_stats(mut self, private: bool) -> Self { + self.body.private_stats = Some(private); + self + } + + /// Scope the link under a custom domain you own (must be ACTIVE). + pub fn domain(mut self, fqdn: impl Into) -> Self { + self.body.domain = Some(fqdn.into()); + self + } + + /// Redirect visitors from one country somewhere else. ISO 3166-1 + /// alpha-2 code; repeatable. + pub fn geo_rule(mut self, country: impl Into, url: impl Into) -> Self { + self.body + .geo_rules + .get_or_insert_with(HashMap::new) + .insert(country.into(), url.into()); + self + } + + /// Replace the whole set of per-country destination overrides. + pub fn geo_rules(mut self, rules: HashMap) -> Self { + self.body.geo_rules = Some(rules); + self + } + + /// Attach a custom social preview. + pub fn meta_tags(mut self, tags: MetaTags) -> Self { + self.body.meta_tags = Some(tags); + self + } + + /// Create the link. + pub async fn send(self) -> Result { + let spec = RequestSpec::new(Method::POST, "/api/v1/shorten").json(&self.body)?; + self.client.transport.execute(spec).await + } +} + +/// Builder for [`Links::check_alias`]. +#[must_use = "builders do nothing until .send() is awaited"] +pub struct CheckAliasBuilder { + client: Client, + alias: String, + domain: Option, +} + +impl CheckAliasBuilder { + /// Check availability on a custom domain instead of the default + /// namespace. + pub fn domain(mut self, fqdn: impl Into) -> Self { + self.domain = Some(fqdn.into()); + self + } + + /// Run the check. + pub async fn send(self) -> Result { + let spec = RequestSpec::new(Method::GET, "/api/v1/shorten/check-alias") + .query("alias", Some(self.alias)) + .query("domain", self.domain); + self.client.transport.execute(spec).await + } +} + +/// Sort key for [`Links::list`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SortBy { + /// Creation time (the default). + CreatedAt, + /// Most recent click. + LastClick, + /// Lifetime click count. + TotalClicks, +} + +impl SortBy { + fn as_str(self) -> &'static str { + match self { + SortBy::CreatedAt => "created_at", + SortBy::LastClick => "last_click", + SortBy::TotalClicks => "total_clicks", + } + } +} + +/// Sort direction for [`Links::list`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SortOrder { + /// Smallest or oldest first. + Ascending, + /// Largest or newest first (the default). + Descending, +} + +impl SortOrder { + fn as_str(self) -> &'static str { + match self { + SortOrder::Ascending => "asc", + SortOrder::Descending => "desc", + } + } +} + +/// Builder for [`Links::list`]. Chain filters, finish with +/// [`ListLinksBuilder::send`]. +#[must_use = "builders do nothing until .send() is awaited"] +#[derive(Clone)] +pub struct ListLinksBuilder { + client: Client, + page: Option, + page_size: Option, + sort_by: Option, + sort_order: Option, + domain: Option, + filter: serde_json::Map, +} + +/// Wire shape of the list response. +#[derive(Deserialize)] +struct ListWire { + items: Vec, + page: u64, + #[serde(rename = "pageSize")] + page_size: u64, + total: u64, + #[serde(rename = "hasNext")] + has_next: bool, +} + +impl ListLinksBuilder { + /// 1-based page number. + pub fn page(mut self, page: u64) -> Self { + self.page = Some(page); + self + } + + /// Items per page, 1 to 100. + pub fn page_size(mut self, size: u64) -> Self { + self.page_size = Some(size); + self + } + + /// Sort key. + pub fn sort_by(mut self, key: SortBy) -> Self { + self.sort_by = Some(key); + self + } + + /// Sort direction. + pub fn sort_order(mut self, order: SortOrder) -> Self { + self.sort_order = Some(order); + self + } + + /// Only links on this custom domain. + pub fn domain(mut self, fqdn: impl Into) -> Self { + self.domain = Some(fqdn.into()); + self + } + + /// Only links with this status. + pub fn status(mut self, status: SettableStatus) -> Self { + self.filter.insert( + "status".into(), + match status { + SettableStatus::Active => "ACTIVE".into(), + SettableStatus::Inactive => "INACTIVE".into(), + }, + ); + self + } + + /// Only links created after this time. + pub fn created_after(mut self, when: DateTime) -> Self { + self.filter + .insert("createdAfter".into(), when.to_rfc3339().into()); + self + } + + /// Only links created before this time. + pub fn created_before(mut self, when: DateTime) -> Self { + self.filter + .insert("createdBefore".into(), when.to_rfc3339().into()); + self + } + + /// Only links with (or without) a password. + pub fn password_set(mut self, set: bool) -> Self { + self.filter.insert("passwordSet".into(), set.into()); + self + } + + /// Only links with (or without) a click limit. + pub fn max_clicks_set(mut self, set: bool) -> Self { + self.filter.insert("maxClicksSet".into(), set.into()); + self + } + + /// Case-insensitive search in alias and destination URL. + pub fn search(mut self, term: impl Into) -> Self { + self.filter.insert("search".into(), term.into().into()); + self + } + + /// Fetch the page. + pub async fn send(self) -> Result, Error> { + let next_template = self.clone(); + let spec = self.into_spec()?; + let wire: ListWire = next_template.client.transport.execute(spec).await?; + Ok(build_page(wire, next_template)) + } + + fn into_spec(self) -> Result { + let filter = if self.filter.is_empty() { + None + } else { + Some(serde_json::to_string(&self.filter).map_err(Error::Decode)?) + }; + Ok(RequestSpec::new(Method::GET, "/api/v1/urls") + .query("page", self.page.map(|p| p.to_string())) + .query("pageSize", self.page_size.map(|s| s.to_string())) + .query("sortBy", self.sort_by.map(|s| s.as_str().to_owned())) + .query("sortOrder", self.sort_order.map(|s| s.as_str().to_owned())) + .query("domain", self.domain) + .query("filter", filter)) + } +} + +fn build_page(wire: ListWire, template: ListLinksBuilder) -> Page { + let current = wire.page; + let has_next = wire.has_next; + let next = has_next.then(|| { + let fetch: crate::page::PageFetcher = Box::new(move || { + let mut builder = template.clone(); + builder.page = Some(current + 1); + Box::pin(async move { builder.send().await }) + }); + fetch + }); + Page { + items: wire.items, + page: wire.page, + page_size: wire.page_size, + total: wire.total, + has_next, + next, + } +} + +#[derive(Default, Serialize, Clone)] +struct UpdateLinkBody { + #[serde(skip_serializing_if = "Patch::is_keep")] + long_url: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + alias: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + password: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + block_bots: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + max_clicks: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + expire_after: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + private_stats: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + status: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + domain: Patch, + #[serde(skip_serializing_if = "Patch::is_keep")] + geo_rules: Patch>, + #[serde(skip_serializing_if = "Patch::is_keep")] + meta_tags: Patch, +} + +/// Builder for [`Links::update`]. Untouched fields keep their stored +/// values; the `remove_*` methods clear a setting explicitly. +#[must_use = "builders do nothing until .send() is awaited"] +pub struct UpdateLinkBuilder { + client: Client, + id: String, + body: UpdateLinkBody, +} + +impl UpdateLinkBuilder { + /// Point the link at a new destination. + pub fn long_url(mut self, url: impl Into) -> Self { + self.body.long_url = Patch::Set(url.into()); + self + } + + /// Rename the short code. Must be available. + pub fn alias(mut self, alias: impl Into) -> Self { + self.body.alias = Patch::Set(alias.into()); + self + } + + /// Set a new password. + pub fn password(mut self, password: impl Into) -> Self { + self.body.password = Patch::Set(password.into()); + self + } + + /// Remove password protection. + pub fn remove_password(mut self) -> Self { + self.body.password = Patch::Null; + self + } + + /// Enable or disable bot blocking. + pub fn block_bots(mut self, block: bool) -> Self { + self.body.block_bots = Patch::Set(block); + self + } + + /// Set a new click limit. + pub fn max_clicks(mut self, max: u64) -> Self { + self.body.max_clicks = Patch::Set(max); + self + } + + /// Remove the click limit. + pub fn remove_max_clicks(mut self) -> Self { + self.body.max_clicks = Patch::Null; + self + } + + /// Set a new expiry time. + pub fn expire_after(mut self, when: DateTime) -> Self { + self.body.expire_after = Patch::Set(when.to_rfc3339()); + self + } + + /// Remove the expiry. + pub fn remove_expiry(mut self) -> Self { + self.body.expire_after = Patch::Null; + self + } + + /// Make statistics owner-only, or public again. + pub fn private_stats(mut self, private: bool) -> Self { + self.body.private_stats = Patch::Set(private); + self + } + + /// Enable or disable redirects. + pub fn status(mut self, status: SettableStatus) -> Self { + self.body.status = Patch::Set(status); + self + } + + /// Move the link to a custom domain you own. + pub fn domain(mut self, fqdn: impl Into) -> Self { + self.body.domain = Patch::Set(fqdn.into()); + self + } + + /// Move the link back to the default namespace. + pub fn system_domain(mut self) -> Self { + self.body.domain = Patch::Null; + self + } + + /// Replace all per-country destination overrides. + pub fn geo_rules(mut self, rules: HashMap) -> Self { + self.body.geo_rules = Patch::Set(rules); + self + } + + /// Remove all per-country destination overrides. + pub fn clear_geo_rules(mut self) -> Self { + self.body.geo_rules = Patch::Null; + self + } + + /// Replace the custom social preview. + pub fn meta_tags(mut self, tags: MetaTags) -> Self { + self.body.meta_tags = Patch::Set(tags); + self + } + + /// Remove the custom social preview. + pub fn remove_meta_tags(mut self) -> Self { + self.body.meta_tags = Patch::Null; + self + } + + /// Apply the update. + pub async fn send(self) -> Result { + let spec = RequestSpec::new(Method::PATCH, format!("/api/v1/urls/{}", self.id)) + .json(&self.body)?; + self.client.transport.execute(spec).await + } +} diff --git a/src/resources/mod.rs b/src/resources/mod.rs new file mode 100644 index 0000000..6e4cec1 --- /dev/null +++ b/src/resources/mod.rs @@ -0,0 +1,7 @@ +//! Resource namespaces hanging off [`crate::Client`]. + +pub mod auth; +pub mod emoji; +pub mod links; +pub mod public; +pub mod stats; diff --git a/src/resources/public.rs b/src/resources/public.rs new file mode 100644 index 0000000..aecc3f8 --- /dev/null +++ b/src/resources/public.rs @@ -0,0 +1,199 @@ +//! Public, unauthenticated link surfaces: stats pages and previews. + +use chrono::{DateTime, Utc}; +use reqwest::Method; +use serde::Deserialize; + +use crate::client::Client; +use crate::error::Error; +use crate::http::RequestSpec; + +/// Public link surfaces, from [`crate::Client::public`]. +pub struct Public { + pub(crate) client: Client, +} + +/// Which generation a link belongs to. Old links carry slightly different +/// stats dimensions than new ones. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub enum Generation { + /// A link from the original platform. + #[serde(rename = "v1")] + V1, + /// A link from the current platform. + #[serde(rename = "v2")] + V2, + /// A generation this SDK version does not know yet. + #[serde(other)] + Unknown, +} + +/// Lifecycle state as the public surfaces report it (lowercase wire form). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum PublicStatus { + /// Redirects are served. + Active, + /// Redirects are disabled by the owner. + Inactive, + /// The link ran out of time or clicks. + Expired, + /// Taken down because the destination was flagged. + Blocked, + /// A status this SDK version does not know yet. + #[serde(other)] + Unknown, +} + +/// Public facts about a link, shown above its charts. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct PublicLinkFacts { + /// Short code. + pub alias: String, + /// Full short URL. + pub short_url: String, + /// The destination; withheld while the link is not active. + #[serde(default)] + pub long_url: Option, + /// When the link was created. + #[serde(default)] + pub created_at: Option>, + /// Lifecycle state. + pub status: PublicStatus, + /// Click limit, if one is set. + #[serde(default)] + pub max_clicks: Option, + /// Whether known bots are blocked. + pub block_bots: bool, + /// Whether the link is password-protected. + pub password_protected: bool, +} + +/// A public stats page's data. +/// +/// `stats` is the modern stats wire shape (summary, `{metric}_by_{dimension}` +/// series, time range) kept as raw JSON: v1 and v2 links carry different +/// dimension sets, so the shape is deliberately open. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct PublicStats { + /// Which generation the link belongs to. + pub generation: Generation, + /// Facts about the link. + pub link: PublicLinkFacts, + /// The stats body, raw. + pub stats: serde_json::Map, +} + +/// A destination URL split into display parts. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct PreviewDestination { + /// The full destination URL. + pub url: String, + /// Its host. + pub domain: String, + /// Its path. + pub path: String, + /// Whether it is served over https. + pub is_https: bool, +} + +/// One geo-rule destination group: every rule is listed, nothing +/// summarized. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct PreviewGeoDestination { + /// The full destination URL. + pub url: String, + /// Its host. + pub domain: String, + /// Its path. + pub path: String, + /// Whether it is served over https. + pub is_https: bool, + /// ISO 3166-1 alpha-2 codes this destination serves, sorted. + pub countries: Vec, +} + +/// Where a short link leads, without following it. `destination` and +/// `geo_destinations` are present only while the link is active and not +/// password-protected: the preview never reveals a destination the redirect +/// would refuse to serve. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct Preview { + /// Which generation the link belongs to. + pub generation: Generation, + /// Short code. + pub alias: String, + /// Full short URL. + pub short_url: String, + /// Lifecycle state. + pub status: PublicStatus, + /// When the link was created. + #[serde(default)] + pub created_at: Option, + /// Whether the link is password-protected. + pub password_protected: bool, + /// The default destination. + #[serde(default)] + pub destination: Option, + /// Per-country destinations, when geo rules exist. + #[serde(default)] + pub geo_destinations: Option>, +} + +impl Public { + /// A link's public stats page data. Chain [`PublicStatsBuilder::password`] + /// for password-protected links. + pub fn stats(&self, short_code: impl Into) -> PublicStatsBuilder { + PublicStatsBuilder { + client: self.client.clone(), + short_code: short_code.into(), + password: None, + } + } + + /// Where a short link leads, without following it. + pub async fn preview(&self, short_code: &str) -> Result { + self.client + .transport + .execute(RequestSpec::new( + Method::GET, + format!("/api/v1/public/preview/{short_code}"), + )) + .await + } +} + +/// Builder for [`Public::stats`]. +#[must_use = "builders do nothing until .send() is awaited"] +pub struct PublicStatsBuilder { + client: Client, + short_code: String, + password: Option, +} + +impl PublicStatsBuilder { + /// The link password, for password-protected links' stats. + pub fn password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } + + /// Fetch the stats page data. Sends a plain GET, or a POST carrying the + /// password when one was provided: one method, both wire forms. + pub async fn send(self) -> Result { + let path = format!("/api/v1/public/stats/{}", self.short_code); + let spec = match self.password { + Some(password) => RequestSpec::new(Method::POST, path) + .json(&serde_json::json!({ "password": password }))?, + None => RequestSpec::new(Method::GET, path), + }; + self.client.transport.execute(spec).await + } +} diff --git a/src/resources/stats.rs b/src/resources/stats.rs new file mode 100644 index 0000000..eae637e --- /dev/null +++ b/src/resources/stats.rs @@ -0,0 +1,584 @@ +//! Click statistics and exports. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use reqwest::Method; +use serde::Deserialize; + +use crate::client::Client; +use crate::error::Error; +use crate::http::{RequestSpec, content_disposition_filename}; + +/// Statistics and exports, from [`crate::Client::stats`]. +pub struct Stats { + pub(crate) client: Client, +} + +/// A grouping dimension for statistics breakdowns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Dimension { + /// Time buckets (day/week/month, auto-selected from the range). + Time, + /// Browser name. + Browser, + /// Operating system. + Os, + /// Device type: mobile, tablet, desktop, unknown. + Device, + /// Country. + Country, + /// City. + City, + /// Referrer URL. + Referrer, + /// URL alias. + ShortCode, + /// `utm_source` tag; untagged clicks appear as `(none)`. + UtmSource, + /// `utm_medium` tag. + UtmMedium, + /// `utm_campaign` tag. + UtmCampaign, +} + +impl Dimension { + fn as_str(self) -> &'static str { + match self { + Dimension::Time => "time", + Dimension::Browser => "browser", + Dimension::Os => "os", + Dimension::Device => "device", + Dimension::Country => "country", + Dimension::City => "city", + Dimension::Referrer => "referrer", + Dimension::ShortCode => "short_code", + Dimension::UtmSource => "utm_source", + Dimension::UtmMedium => "utm_medium", + Dimension::UtmCampaign => "utm_campaign", + } + } +} + +/// A metric to include in the breakdown. Defaults to both when unset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Metric { + /// Total click count. + Clicks, + /// Unique visitor count. + UniqueClicks, +} + +impl Metric { + fn as_str(self) -> &'static str { + match self { + Metric::Clicks => "clicks", + Metric::UniqueClicks => "unique_clicks", + } + } +} + +/// A dimension clicks can be filtered by. Values are case-sensitive, exact +/// as stored. The aggregate-only `short_code`/`url_id` slicers live on +/// [`AccountStatsBuilder`] instead, so they cannot be sent where the +/// endpoint rejects them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum FilterDimension { + /// Browser name (Chrome, Firefox, Safari, ...). + Browser, + /// Operating system (Windows, macOS, iOS, ...). + Os, + /// Device type: mobile, tablet, desktop, unknown. + Device, + /// Country name. + Country, + /// City name. + City, + /// Referrer URL. + Referrer, + /// `utm_source` tag; `(none)` matches untagged clicks. + UtmSource, + /// `utm_medium` tag. + UtmMedium, + /// `utm_campaign` tag. + UtmCampaign, +} + +impl FilterDimension { + fn as_str(self) -> &'static str { + match self { + FilterDimension::Browser => "browser", + FilterDimension::Os => "os", + FilterDimension::Device => "device", + FilterDimension::Country => "country", + FilterDimension::City => "city", + FilterDimension::Referrer => "referrer", + FilterDimension::UtmSource => "utm_source", + FilterDimension::UtmMedium => "utm_medium", + FilterDimension::UtmCampaign => "utm_campaign", + } + } +} + +/// Response scope marker. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub enum Scope { + /// The owner's aggregate. + #[serde(rename = "all")] + All, + /// The public stats page's frozen contract for anonymous links. + #[serde(rename = "anon")] + Anon, + /// A scope this SDK version does not know yet. + #[serde(other)] + Unknown, +} + +/// Summary block of a statistics response. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct Summary { + /// Total clicks in the range. + pub total_clicks: u64, + /// Unique visitors in the range. + pub unique_clicks: u64, + /// First click in the range. + #[serde(default)] + pub first_click: Option>, + /// Most recent click in the range. + #[serde(default)] + pub last_click: Option>, + /// Average redirection latency, milliseconds. + #[serde(default)] + pub avg_redirection_time: Option, +} + +/// The time range a response covers. +#[derive(Debug, Clone, Default, Deserialize)] +#[non_exhaustive] +pub struct TimeRange { + /// Range start. + #[serde(default)] + pub start_date: Option>, + /// Range end. + #[serde(default)] + pub end_date: Option>, +} + +/// Time bucketing metadata, present when grouping by time. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct TimeBucketInfo { + /// Bucketing strategy the server chose. + pub strategy: String, + /// The server's bucket format string. + pub mongo_format: String, + /// Suggested display format. + pub display_format: String, + /// Timezone the buckets are aligned to. + pub timezone: String, + /// Bucket width in minutes, for sub-daily strategies. + #[serde(default)] + pub interval_minutes: Option, +} + +/// Derived rates the server computes over the range. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct ComputedMetrics { + /// unique_clicks / total_clicks. + pub unique_click_rate: f64, + /// 1 - unique_click_rate. + pub repeat_click_rate: f64, + /// total_clicks / unique visitors. + pub average_clicks_per_visitor: f64, +} + +/// A statistics breakdown (`GET /api/v1/stats`). +/// +/// `metrics` is keyed `{metric}_by_{dimension}` (for example +/// `clicks_by_browser`); each value is a list of data points whose keys are +/// the dimension name, the metric name and `{metric}_percentage`. The keys +/// are dynamic by design, so data points stay [`serde_json::Value`] maps. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct StatsReport { + /// Response scope marker. + pub scope: Scope, + /// The filters the server applied. + #[serde(default)] + pub filters: HashMap>, + /// The grouping dimensions applied. + #[serde(default)] + pub group_by: Vec, + /// Timezone of the response. + pub timezone: String, + /// The covered range. + pub time_range: TimeRange, + /// Totals over the range. + pub summary: Summary, + /// Breakdown series, keyed `{metric}_by_{dimension}`. + #[serde(default)] + pub metrics: HashMap>>, + /// When the server produced the response. + #[serde(default)] + pub generated_at: Option>, + /// Alias echo, present when sliced to one link. + #[serde(default)] + pub short_code: Option, + /// Time bucketing metadata, when grouping by time. + #[serde(default)] + pub time_bucket_info: Option, + /// Derived rates, when the server includes them. + #[serde(default)] + pub computed_metrics: Option, +} + +/// A single link's statistics (`GET /api/v1/stats/links/{url_id}`): the +/// standard wire plus the identity of the selected link. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct LinkStatsReport { + /// The standard statistics body. + #[serde(flatten)] + pub stats: StatsReport, + /// The selected link's id. + pub url_id: String, + /// The selected link's alias. + pub alias: String, +} + +/// Export file format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ExportFormat { + /// JSON document. + Json, + /// CSV files, zipped together. + Csv, + /// Excel workbook. + Xlsx, + /// XML document. + Xml, +} + +impl ExportFormat { + fn as_str(self) -> &'static str { + match self { + ExportFormat::Json => "json", + ExportFormat::Csv => "csv", + ExportFormat::Xlsx => "xlsx", + ExportFormat::Xml => "xml", + } + } + + fn extension(self) -> &'static str { + match self { + // CSV exports arrive zipped. + ExportFormat::Csv => "zip", + other => other.as_str(), + } + } +} + +/// A downloaded export. The body is consumed as a stream or buffered once; +/// `filename` is reduced to a safe bare filename before you see it, so it +/// can be joined into a directory path as-is. +pub struct Export { + /// Server-suggested filename, sanitized to a bare name. + pub filename: String, + /// The response content type. + pub content_type: String, + response: reqwest::Response, +} + +impl std::fmt::Debug for Export { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Export") + .field("filename", &self.filename) + .field("content_type", &self.content_type) + .finish_non_exhaustive() + } +} + +impl Export { + /// Buffer the whole body. Fine for typical exports; use + /// [`Export::bytes_stream`] for very large accounts. + pub async fn bytes(self) -> Result, Error> { + Ok(self + .response + .bytes() + .await + .map_err(Error::Transport)? + .to_vec()) + } + + /// Stream the body without buffering it. + #[cfg(feature = "stream")] + pub fn bytes_stream( + self, + ) -> impl futures_core::Stream> { + self.response.bytes_stream() + } +} + +impl Stats { + /// Account-wide statistics across all your links, optionally sliced to + /// specific links. + pub fn account(&self) -> AccountStatsBuilder { + AccountStatsBuilder { + client: self.client.clone(), + core: QueryCore::default(), + short_codes: Vec::new(), + url_ids: Vec::new(), + } + } + + /// One link's statistics, addressed by id. + pub fn for_link(&self, url_id: impl Into) -> LinkStatsBuilder { + LinkStatsBuilder { + client: self.client.clone(), + url_id: url_id.into(), + core: QueryCore::default(), + } + } + + /// Download the account-wide export. Only this aggregate route carries + /// the account-level slicers; per-link downloads with per-link filenames + /// come from [`Stats::export_link`]. + pub fn export(&self) -> ExportBuilder { + ExportBuilder { + client: self.client.clone(), + path: "/api/v1/export".to_owned(), + format: None, + core: QueryCore::default(), + } + } + + /// Download one link's export, named after the link by the server. + pub fn export_link(&self, url_id: impl Into) -> ExportBuilder { + ExportBuilder { + client: self.client.clone(), + path: format!("/api/v1/export/links/{}", url_id.into()), + format: None, + core: QueryCore::default(), + } + } +} + +/// Query state shared by the stats and export builders. +#[derive(Default, Clone)] +struct QueryCore { + start_date: Option, + end_date: Option, + group_by: Vec, + metrics: Vec, + timezone: Option, + filters: HashMap<&'static str, Vec>, +} + +impl QueryCore { + fn apply(self, mut spec: RequestSpec) -> Result { + spec = spec + .query("start_date", self.start_date) + .query("end_date", self.end_date) + .query( + "group_by", + (!self.group_by.is_empty()).then(|| { + self.group_by + .iter() + .map(|d| d.as_str()) + .collect::>() + .join(",") + }), + ) + .query( + "metrics", + (!self.metrics.is_empty()).then(|| { + self.metrics + .iter() + .map(|m| m.as_str()) + .collect::>() + .join(",") + }), + ) + .query("timezone", self.timezone); + if !self.filters.is_empty() { + let filters = serde_json::to_string(&self.filters).map_err(Error::Decode)?; + spec = spec.query("filters", Some(filters)); + } + Ok(spec) + } +} + +macro_rules! stats_query_methods { + () => { + /// Start of the time range. Defaults to 7 days before the end. + pub fn start_date(mut self, when: DateTime) -> Self { + self.core.start_date = Some(when.to_rfc3339()); + self + } + + /// End of the time range. Defaults to now. + pub fn end_date(mut self, when: DateTime) -> Self { + self.core.end_date = Some(when.to_rfc3339()); + self + } + + /// Add a grouping dimension. Repeatable; defaults to time. + pub fn group_by(mut self, dimension: Dimension) -> Self { + self.core.group_by.push(dimension); + self + } + + /// Add a metric. Repeatable; defaults to both. + pub fn metric(mut self, metric: Metric) -> Self { + self.core.metrics.push(metric); + self + } + + /// IANA timezone for bucketing and display. Defaults to UTC. + pub fn timezone(mut self, tz: impl Into) -> Self { + self.core.timezone = Some(tz.into()); + self + } + + /// Only clicks matching one of these values on a dimension. + /// Values are case-sensitive, exact as stored. Repeatable. + pub fn filter(mut self, dimension: FilterDimension, values: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.core + .filters + .entry(dimension.as_str()) + .or_default() + .extend(values.into_iter().map(Into::into)); + self + } + }; +} + +/// Builder for [`Stats::account`]. +#[must_use = "builders do nothing until .send() is awaited"] +pub struct AccountStatsBuilder { + client: Client, + core: QueryCore, + short_codes: Vec, + url_ids: Vec, +} + +impl AccountStatsBuilder { + stats_query_methods!(); + + /// Slice the aggregate to these aliases. Repeatable. + pub fn short_codes(mut self, codes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.short_codes.extend(codes.into_iter().map(Into::into)); + self + } + + /// Slice the aggregate to these link ids. Ids you do not own match + /// nothing. Repeatable. + pub fn url_ids(mut self, ids: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.url_ids.extend(ids.into_iter().map(Into::into)); + self + } + + /// Fetch the breakdown. + pub async fn send(mut self) -> Result { + if !self.short_codes.is_empty() { + self.core.filters.insert("short_code", self.short_codes); + } + if !self.url_ids.is_empty() { + self.core.filters.insert("url_id", self.url_ids); + } + let spec = self + .core + .apply(RequestSpec::new(Method::GET, "/api/v1/stats"))?; + self.client.transport.execute(spec).await + } +} + +/// Builder for [`Stats::for_link`]. +#[must_use = "builders do nothing until .send() is awaited"] +pub struct LinkStatsBuilder { + client: Client, + url_id: String, + core: QueryCore, +} + +impl LinkStatsBuilder { + stats_query_methods!(); + + /// Fetch the breakdown. + pub async fn send(self) -> Result { + let spec = self.core.apply(RequestSpec::new( + Method::GET, + format!("/api/v1/stats/links/{}", self.url_id), + ))?; + self.client.transport.execute(spec).await + } +} + +/// Builder for [`Stats::export`] and [`Stats::export_link`]. +#[must_use = "builders do nothing until .send() is awaited"] +pub struct ExportBuilder { + client: Client, + path: String, + format: Option, + core: QueryCore, +} + +impl ExportBuilder { + stats_query_methods!(); + + /// File format. Defaults to JSON server-side. + pub fn format(mut self, format: ExportFormat) -> Self { + self.format = Some(format); + self + } + + /// Download the export. + pub async fn send(self) -> Result { + let fallback = format!( + "spoo-export.{}", + self.format.map(ExportFormat::extension).unwrap_or("json") + ); + let spec = self.core.apply( + RequestSpec::new(Method::GET, self.path) + .query("format", self.format.map(|f| f.as_str().to_owned())), + )?; + let response = self.client.transport.send(spec).await?; + let filename = content_disposition_filename( + response + .headers() + .get("content-disposition") + .and_then(|v| v.to_str().ok()), + &fallback, + ); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_owned(); + Ok(Export { + filename, + content_type, + response, + }) + } +} From c5ae3ac54a00010d8e2d154831ff97101dda789d Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 01:15:08 +0530 Subject: [PATCH 04/13] feat: sign in with spoo behind the oauth feature PKCE S256 helpers, device-code exchange, and a self-refreshing session with single-flight rotation, proactive expiry refresh, an on_refresh persistence hook, and a typed SessionExpired for dead refresh tokens. --- src/oauth.rs | 345 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 src/oauth.rs diff --git a/src/oauth.rs b/src/oauth.rs new file mode 100644 index 0000000..221092a --- /dev/null +++ b/src/oauth.rs @@ -0,0 +1,345 @@ +//! The client half of Sign in with Spoo (authorization-code + PKCE). +//! +//! The SDK never opens browsers, renders consent, or stores secrets; it +//! provides the protocol pieces and a self-refreshing [`Session`] credential +//! for [`crate::ClientBuilder::session`]. Enabled by the `oauth` feature. +//! +//! [`Session`]: crate::oauth::Session + +use std::time::Duration; + +use base64::Engine as _; +use reqwest::Method; +use serde::Deserialize; +use sha2::Digest as _; + +use crate::client::Client; +use crate::error::Error; +use crate::http::{RequestSpec, Transport}; +use crate::resources::auth::User; + +const VERIFIER_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + +/// A PKCE verifier and its S256 challenge. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct PkcePair { + /// The secret half: goes into the token exchange. + pub verifier: String, + /// The public half: goes into the authorization URL. + pub challenge: String, +} + +/// Generate a PKCE pair (RFC 7636, S256). The verifier is 64 characters +/// from the unreserved set. +pub fn generate_pkce_pair() -> PkcePair { + let verifier = random_string(64); + let digest = sha2::Sha256::digest(verifier.as_bytes()); + let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest); + PkcePair { + verifier, + challenge, + } +} + +/// Generate a CSRF-binding `state` value for the authorization URL. +pub fn generate_state() -> String { + random_string(32) +} + +fn random_string(len: usize) -> String { + let mut rng = rand::rng(); + (0..len) + .map(|_| { + let idx = rand::Rng::random_range(&mut rng, 0..VERIFIER_CHARS.len()); + // The index is in range by construction; the fallback keeps the + // panic-free guarantee honest. + char::from(VERIFIER_CHARS.get(idx).copied().unwrap_or(b'a')) + }) + .collect() +} + +/// An access/refresh token pair. Refresh tokens rotate: after a refresh the +/// pair you held before is dead. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct TokenPair { + /// JWT access token. + pub access_token: String, + /// JWT refresh token. + pub refresh_token: String, +} + +impl TokenPair { + /// Assemble a pair from stored values. + pub fn new(access_token: impl Into, refresh_token: impl Into) -> Self { + TokenPair { + access_token: access_token.into(), + refresh_token: refresh_token.into(), + } + } +} + +/// The result of a device-code exchange: tokens plus the signed-in user. +#[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] +pub struct DeviceTokens { + /// JWT access token. + pub access_token: String, + /// JWT refresh token. + pub refresh_token: String, + /// The signed-in user's profile. + pub user: User, +} + +impl DeviceTokens { + /// The token pair, for building a [`Session`]. + pub fn tokens(&self) -> TokenPair { + TokenPair::new(self.access_token.clone(), self.refresh_token.clone()) + } +} + +/// Sign in with Spoo protocol calls, from [`crate::Client::oauth`]. +pub struct OAuth { + pub(crate) client: Client, +} + +impl OAuth { + /// Build the consent-page URL your app opens in a browser. S256 is + /// mandatory; there is no plain fallback. + pub fn authorization_url( + &self, + app_id: impl Into, + state: impl Into, + code_challenge: impl Into, + ) -> AuthorizationUrlBuilder { + AuthorizationUrlBuilder { + base_url: self.client.transport.base_url.clone(), + app_id: app_id.into(), + state: state.into(), + code_challenge: code_challenge.into(), + redirect_uri: None, + } + } + + /// Exchange the one-time code from the callback for tokens. The code + /// and verifier are the credentials; no auth header is involved. + pub async fn exchange_code( + &self, + code: impl Into, + code_verifier: impl Into, + ) -> Result { + let spec = + RequestSpec::new(Method::POST, "/auth/device/token").json(&serde_json::json!({ + "code": code.into(), + "code_verifier": code_verifier.into(), + }))?; + self.client.transport.execute(spec).await + } + + /// Trade a refresh token for a fresh pair. The pair you sent is invalid + /// afterwards. Prefer a [`Session`], which handles rotation, persistence + /// and retry for you. + pub async fn refresh_tokens(&self, refresh_token: &str) -> Result { + refresh_call(&self.client.transport, refresh_token).await + } +} + +/// Builder for [`OAuth::authorization_url`]. +#[must_use = "the URL is only produced by .build()"] +pub struct AuthorizationUrlBuilder { + base_url: String, + app_id: String, + state: String, + code_challenge: String, + redirect_uri: Option, +} + +impl AuthorizationUrlBuilder { + /// Must exactly match a redirect URI registered for the app; the server + /// rejects everything else, including a different port. Omit to use the + /// app's registered default. + pub fn redirect_uri(mut self, uri: impl Into) -> Self { + self.redirect_uri = Some(uri.into()); + self + } + + /// Produce the URL. + pub fn build(self) -> Result { + let mut url = reqwest::Url::parse(&format!("{}/auth/device/login", self.base_url)) + .map_err(|e| Error::Config(format!("invalid base URL: {e}")))?; + { + let mut query = url.query_pairs_mut(); + query.append_pair("app_id", &self.app_id); + if let Some(redirect_uri) = &self.redirect_uri { + query.append_pair("redirect_uri", redirect_uri); + } + query.append_pair("state", &self.state); + query.append_pair("code_challenge", &self.code_challenge); + query.append_pair("code_challenge_method", "S256"); + } + Ok(url.into()) + } +} + +/// The refresh endpoint answers 400 or 401 when the refresh token is dead: +/// both mean the session is over. +async fn refresh_call(transport: &Transport, refresh_token: &str) -> Result { + let spec = RequestSpec::new(Method::POST, "/auth/device/refresh") + .json(&serde_json::json!({ "refresh_token": refresh_token }))?; + match transport.execute_unauthenticated::(spec).await { + Ok(pair) => Ok(pair), + Err(Error::Api(e)) if e.status == 400 || e.status == 401 => Err(Error::SessionExpired), + Err(err) => Err(err), + } +} + +type OnRefresh = Box; + +struct SessionState { + tokens: TokenPair, + generation: u64, + /// Unix seconds the access token expires at, from its `exp` claim. + expires_at: Option, +} + +/// A self-refreshing Sign in with Spoo credential. +/// +/// Attach it with [`crate::ClientBuilder::session`]. The client refreshes +/// proactively shortly before the access token's `exp`, retries once after +/// an unexpected 401, and rotations are single-flight: concurrent requests +/// share one refresh, so a rotated pair is never persisted twice. Every +/// rotation is reported through the `on_refresh` hook; persist the pair +/// there, because the previous refresh token is dead the moment it fires. +pub struct Session { + state: tokio::sync::Mutex, + on_refresh: Option, + expiry_skew: Duration, +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session").finish_non_exhaustive() + } +} + +impl Session { + /// A session from a stored or freshly exchanged token pair. + pub fn new(tokens: TokenPair) -> Session { + let expires_at = decode_exp(&tokens.access_token); + Session { + state: tokio::sync::Mutex::new(SessionState { + tokens, + generation: 0, + expires_at, + }), + on_refresh: None, + expiry_skew: Duration::from_secs(30), + } + } + + /// Called after every successful refresh with the rotated pair. + /// Persist it there: the previous refresh token is already dead. + pub fn on_refresh(mut self, hook: impl Fn(&TokenPair) + Send + Sync + 'static) -> Session { + self.on_refresh = Some(Box::new(hook)); + self + } + + /// How long before the access token's `exp` to refresh proactively. + /// Default 30 seconds. + pub fn expiry_skew(mut self, skew: Duration) -> Session { + self.expiry_skew = skew; + self + } + + /// Mark the current access token stale, so the next request refreshes + /// first. + pub async fn invalidate(&self) { + let mut state = self.state.lock().await; + state.expires_at = Some(0); + } + + /// A fresh access token and the rotation generation it belongs to. + /// Refreshes first when the token is at or past `exp - skew`. + pub(crate) async fn fresh_token(&self, transport: &Transport) -> Result<(String, u64), Error> { + let mut state = self.state.lock().await; + let stale = state + .expires_at + .is_some_and(|exp| utc_now_secs() + self.expiry_skew.as_secs() as i64 >= exp); + if stale { + self.rotate_locked(&mut state, transport).await?; + } + Ok((state.tokens.access_token.clone(), state.generation)) + } + + /// Refresh after a 401, unless another task already rotated past the + /// generation this request was sent with. + pub(crate) async fn refresh_stale( + &self, + transport: &Transport, + seen_generation: u64, + ) -> Result<(), Error> { + let mut state = self.state.lock().await; + if state.generation != seen_generation { + return Ok(()); + } + self.rotate_locked(&mut state, transport).await + } + + async fn rotate_locked( + &self, + state: &mut SessionState, + transport: &Transport, + ) -> Result<(), Error> { + let pair = refresh_call(transport, &state.tokens.refresh_token).await?; + state.expires_at = decode_exp(&pair.access_token); + state.tokens = pair; + state.generation += 1; + if let Some(hook) = &self.on_refresh { + hook(&state.tokens); + } + Ok(()) + } +} + +fn utc_now_secs() -> i64 { + chrono::Utc::now().timestamp() +} + +/// Best-effort read of a JWT's `exp` claim. `None` disables proactive +/// refresh; the 401-retry path still works. +fn decode_exp(jwt: &str) -> Option { + let payload = jwt.split('.').nth(1)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + value.get("exp")?.as_i64() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_pair_matches_rfc_7636_s256() { + let pair = generate_pkce_pair(); + assert_eq!(pair.verifier.len(), 64); + assert!(pair.verifier.bytes().all(|b| VERIFIER_CHARS.contains(&b))); + let digest = sha2::Sha256::digest(pair.verifier.as_bytes()); + let expected = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest); + assert_eq!(pair.challenge, expected); + // No padding, URL-safe alphabet. + assert!(!pair.challenge.contains('=')); + } + + #[test] + fn exp_claim_decodes() { + // {"exp": 1234567890} + let payload = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"exp":1234567890}"#); + let jwt = format!("h.{payload}.s"); + assert_eq!(decode_exp(&jwt), Some(1234567890)); + assert_eq!(decode_exp("not-a-jwt"), None); + } +} From cef77ad67cc12b4f981d9e2b6291353a7d3e9e1d Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 01:15:08 +0530 Subject: [PATCH 05/13] test: wiremock suite asserting wire bytes Exact request bodies for create/update/bulk/claim, tri-state PATCH serialization, retry sequencing with Retry-After, hostile export filenames, ETag revalidation, and a stampede test proving concurrent requests share a single token rotation. --- tests/common/mod.rs | 24 +++ tests/links.rs | 388 ++++++++++++++++++++++++++++++++++++++ tests/oauth.rs | 248 ++++++++++++++++++++++++ tests/public_and_emoji.rs | 168 +++++++++++++++++ tests/stats.rs | 223 ++++++++++++++++++++++ tests/transport.rs | 274 +++++++++++++++++++++++++++ 6 files changed, 1325 insertions(+) create mode 100644 tests/common/mod.rs create mode 100644 tests/links.rs create mode 100644 tests/oauth.rs create mode 100644 tests/public_and_emoji.rs create mode 100644 tests/stats.rs create mode 100644 tests/transport.rs diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..09a7a43 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,24 @@ +//! Shared harness for the wiremock suite. + +use spoo_me::Client; +use wiremock::MockServer; + +pub async fn server_and_client() -> (MockServer, Client) { + let server = MockServer::start().await; + let client = Client::builder() + .api_key("spoo_test_key") + .base_url(server.uri()) + .max_retries(2) + .build() + .expect("test client config is valid"); + (server, client) +} + +// Not every integration-test binary uses every helper. +#[allow(dead_code)] +pub async fn anonymous_client(server: &MockServer) -> Client { + Client::builder() + .base_url(server.uri()) + .build() + .expect("test client config is valid") +} diff --git a/tests/links.rs b/tests/links.rs new file mode 100644 index 0000000..99315b4 --- /dev/null +++ b/tests/links.rs @@ -0,0 +1,388 @@ +//! Wire-level tests for the links resource: exact request bytes, exact +//! response decoding. + +mod common; + +use serde_json::json; +use wiremock::matchers::{body_json, header, method, path, query_param}; +use wiremock::{Mock, ResponseTemplate}; + +use spoo_me::{ + AliasIssue, AliasKind, BulkErrorCode, ClaimRequest, ClaimStatus, LinkStatus, MetaTags, + SettableStatus, SortBy, SortOrder, +}; + +fn link_body() -> serde_json::Value { + json!({ + "id": "665f0c2f9e7a4b1d2c3d4e5f", + "alias": "launch", + "short_url": "https://spoo.me/launch", + "long_url": "https://example.com/launch", + "owner_id": null, + "created_at": 1704067200, + "status": "ACTIVE", + "claim_token": "tok_once" + }) +} + +#[tokio::test] +async fn create_sends_exact_body_and_decodes() { + let (server, client) = common::server_and_client().await; + Mock::given(method("POST")) + .and(path("/api/v1/shorten")) + .and(header("authorization", "Bearer spoo_test_key")) + .and(body_json(json!({ + "long_url": "https://example.com/launch", + "alias": "launch", + "alias_type": "alphanumeric", + "password": "secure@123", + "block_bots": true, + "max_clicks": 100, + "expire_after": "2027-01-01T00:00:00+00:00", + "private_stats": true, + "geo_rules": {"IN": "https://example.in/"}, + "meta_tags": {"title": "Launch", "color": "#FF5733"} + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(link_body())) + .expect(1) + .mount(&server) + .await; + + let when = chrono::DateTime::parse_from_rfc3339("2027-01-01T00:00:00Z") + .expect("valid test timestamp") + .with_timezone(&chrono::Utc); + let link = client + .links() + .create("https://example.com/launch") + .alias("launch") + .alias_kind(AliasKind::Alphanumeric) + .password("secure@123") + .block_bots(true) + .max_clicks(100) + .expire_after(when) + .private_stats(true) + .geo_rule("IN", "https://example.in/") + .meta_tags(MetaTags::new("Launch").color("#FF5733")) + .send() + .await + .expect("create succeeds"); + + assert_eq!(link.id, "665f0c2f9e7a4b1d2c3d4e5f"); + assert_eq!(link.status, LinkStatus::Active); + assert_eq!(link.created_at.timestamp(), 1704067200); + assert_eq!(link.claim_token.as_deref(), Some("tok_once")); +} + +#[tokio::test] +async fn create_omits_unset_fields() { + let (server, client) = common::server_and_client().await; + Mock::given(method("POST")) + .and(path("/api/v1/shorten")) + .and(body_json(json!({"long_url": "https://example.com/"}))) + .respond_with(ResponseTemplate::new(201).set_body_json(link_body())) + .expect(1) + .mount(&server) + .await; + + client + .links() + .create("https://example.com/") + .send() + .await + .expect("bare create succeeds"); +} + +#[tokio::test] +async fn update_patch_tristate_wire_bytes() { + let (server, client) = common::server_and_client().await; + // password cleared (explicit null), max_clicks set, everything else + // absent from the body entirely. + Mock::given(method("PATCH")) + .and(path("/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f")) + .and(body_json(json!({"password": null, "max_clicks": 500}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "665f0c2f9e7a4b1d2c3d4e5f", + "password_set": false, + "max_clicks": 500, + "updated_at": 1704067300 + }))) + .expect(1) + .mount(&server) + .await; + + let updated = client + .links() + .update("665f0c2f9e7a4b1d2c3d4e5f") + .remove_password() + .max_clicks(500) + .send() + .await + .expect("update succeeds"); + assert!(!updated.password_set); + assert_eq!(updated.updated_at.timestamp(), 1704067300); +} + +#[tokio::test] +async fn update_system_domain_sends_null() { + let (server, client) = common::server_and_client().await; + Mock::given(method("PATCH")) + .and(path("/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f")) + .and(body_json(json!({"domain": null}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "665f0c2f9e7a4b1d2c3d4e5f", + "password_set": false, + "updated_at": 1704067300 + }))) + .expect(1) + .mount(&server) + .await; + + client + .links() + .update("665f0c2f9e7a4b1d2c3d4e5f") + .system_domain() + .send() + .await + .expect("domain move succeeds"); +} + +#[tokio::test] +async fn list_pagination_walks_and_streams() { + let (server, client) = common::server_and_client().await; + let item = |id: &str| { + json!({ + "id": id, + "alias": id, + "created_at": "2026-01-01T00:00:00Z", + "expire_after": 1767225599, + "password_set": false + }) + }; + Mock::given(method("GET")) + .and(path("/api/v1/urls")) + .and(query_param("page", "1")) + .and(query_param("sortBy", "created_at")) + .and(query_param("sortOrder", "desc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "items": [item("a"), item("b")], + "page": 1, "pageSize": 2, "total": 3, "hasNext": true, + "sortBy": "created_at", "sortOrder": "descending" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/urls")) + .and(query_param("page", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "items": [item("c")], + "page": 2, "pageSize": 2, "total": 3, "hasNext": false, + "sortBy": "created_at", "sortOrder": "descending" + }))) + .mount(&server) + .await; + + let first = client + .links() + .list() + .page(1) + .page_size(2) + .sort_by(SortBy::CreatedAt) + .sort_order(SortOrder::Descending) + .send() + .await + .expect("page 1 loads"); + assert_eq!(first.items.len(), 2); + assert!(first.has_next); + assert_eq!( + first.items[0].expire_after.map(|d| d.timestamp()), + Some(1767225599) + ); + + let second = first + .next_page() + .await + .expect("page 2 loads") + .expect("page 2 exists"); + assert_eq!(second.items.len(), 1); + assert!(!second.has_next); + assert!(second.next_page().await.expect("no error").is_none()); + + // The stream sees all three items across the page boundary. + #[cfg(feature = "stream")] + { + use futures_util::StreamExt as _; + let fresh = client + .links() + .list() + .page(1) + .page_size(2) + .sort_by(SortBy::CreatedAt) + .sort_order(SortOrder::Descending) + .send() + .await + .expect("page 1 reloads"); + let ids: Vec = fresh + .stream() + .map(|item| item.expect("stream item ok").id) + .collect() + .await; + assert_eq!(ids, vec!["a", "b", "c"]); + } +} + +#[tokio::test] +async fn list_filter_is_one_json_param() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/urls")) + .and(query_param( + "filter", + r#"{"passwordSet":true,"search":"promo","status":"ACTIVE"}"#, + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "items": [], "page": 1, "pageSize": 20, "total": 0, "hasNext": false, + "sortBy": "created_at", "sortOrder": "descending" + }))) + .expect(1) + .mount(&server) + .await; + + client + .links() + .list() + .status(SettableStatus::Active) + .password_set(true) + .search("promo") + .send() + .await + .expect("filtered list succeeds"); +} + +#[tokio::test] +async fn check_alias_decodes_typed_reason() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/shorten/check-alias")) + .and(query_param("alias", "taken-one")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"available": false, "reason": "taken"})), + ) + .expect(1) + .mount(&server) + .await; + + let check = client + .links() + .check_alias("taken-one") + .send() + .await + .expect("check succeeds"); + assert!(!check.available); + assert_eq!(check.reason, Some(AliasIssue::Taken)); +} + +#[tokio::test] +async fn bulk_partial_success_is_data() { + let (server, client) = common::server_and_client().await; + Mock::given(method("POST")) + .and(path("/api/v1/urls/bulk/status")) + .and(body_json(json!({ + "ids": ["665f0c2f9e7a4b1d2c3d4e5f", "665f0c2f9e7a4b1d2c3d4e60"], + "status": "INACTIVE" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "summary": {"total": 2, "succeeded": 1, "failed": 1}, + "results": [ + {"id": "665f0c2f9e7a4b1d2c3d4e5f", "alias": "a", "ok": true}, + {"id": "665f0c2f9e7a4b1d2c3d4e60", "ok": false, + "error_code": "not_found", "error": "no such URL"} + ] + }))) + .expect(1) + .mount(&server) + .await; + + let outcome = client + .links() + .bulk_set_status( + ["665f0c2f9e7a4b1d2c3d4e5f", "665f0c2f9e7a4b1d2c3d4e60"], + SettableStatus::Inactive, + ) + .await + .expect("bulk call itself succeeds"); + assert_eq!(outcome.summary.failed, 1); + assert_eq!(outcome.results[1].error_code, Some(BulkErrorCode::NotFound)); +} + +#[tokio::test] +async fn bulk_expiry_clear_sends_null() { + let (server, client) = common::server_and_client().await; + Mock::given(method("POST")) + .and(path("/api/v1/urls/bulk/expiry")) + .and(body_json(json!({ + "ids": ["665f0c2f9e7a4b1d2c3d4e5f"], + "expire_after": null + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "summary": {"total": 1, "succeeded": 1, "failed": 0}, + "results": [{"id": "665f0c2f9e7a4b1d2c3d4e5f", "ok": true}] + }))) + .expect(1) + .mount(&server) + .await; + + client + .links() + .bulk_set_expiry(["665f0c2f9e7a4b1d2c3d4e5f"], None) + .await + .expect("bulk expiry clear succeeds"); +} + +#[tokio::test] +async fn claim_wire_field_is_token() { + let (server, client) = common::server_and_client().await; + Mock::given(method("POST")) + .and(path("/api/v1/urls/claim")) + .and(body_json(json!({ + "claims": [{"url_id": "665f0c2f9e7a4b1d2c3d4e5f", "token": "tok_once"}] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"url_id": "665f0c2f9e7a4b1d2c3d4e5f", "status": "claimed"}], + "claimed": 1 + }))) + .expect(1) + .mount(&server) + .await; + + let outcome = client + .links() + .claim([ClaimRequest::new("665f0c2f9e7a4b1d2c3d4e5f", "tok_once")]) + .await + .expect("claim succeeds"); + assert_eq!(outcome.claimed, 1); + assert_eq!(outcome.results[0].status, ClaimStatus::Claimed); +} + +#[tokio::test] +async fn unknown_enum_values_do_not_break_decoding() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "665f0c2f9e7a4b1d2c3d4e5f", + "status": "QUARANTINED", + "password_set": false, + "brand_new_field": {"nested": true} + }))) + .expect(1) + .mount(&server) + .await; + + let link = client + .links() + .get("665f0c2f9e7a4b1d2c3d4e5f") + .await + .expect("decoding survives unknown values"); + assert_eq!(link.status, Some(LinkStatus::Unknown)); +} diff --git a/tests/oauth.rs b/tests/oauth.rs new file mode 100644 index 0000000..59612f1 --- /dev/null +++ b/tests/oauth.rs @@ -0,0 +1,248 @@ +//! Sign in with Spoo: PKCE, exchange, and the refreshing session's +//! guarantees (rotation, single-flight, expiry). +#![cfg(feature = "oauth")] + +mod common; + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use serde_json::json; +use wiremock::matchers::{body_json, method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use spoo_me::oauth::{Session, TokenPair}; +use spoo_me::{Client, Error}; + +fn jwt_with_exp(exp: i64) -> String { + let payload = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!("{{\"exp\":{exp}}}")); + format!("header.{payload}.sig") +} + +fn far_future() -> i64 { + chrono::Utc::now().timestamp() + 3600 +} + +fn session_client(server: &wiremock::MockServer, session: Arc) -> Client { + Client::builder() + .base_url(server.uri()) + .session(session) + .build() + .expect("test client config is valid") +} + +#[tokio::test] +async fn authorization_url_carries_the_pkce_contract() { + let client = Client::anonymous(); + let url = client + .oauth() + .authorization_url("app_123", "state_abc", "challenge_xyz") + .redirect_uri("http://127.0.0.1:8000/callback") + .build() + .expect("url builds"); + assert!(url.starts_with("https://spoo.me/auth/device/login?")); + assert!(url.contains("app_id=app_123")); + assert!(url.contains("state=state_abc")); + assert!(url.contains("code_challenge=challenge_xyz")); + assert!(url.contains("code_challenge_method=S256")); + assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8000%2Fcallback")); +} + +#[tokio::test] +async fn exchange_code_sends_verifier() { + let (server, _) = common::server_and_client().await; + let client = common::anonymous_client(&server).await; + Mock::given(method("POST")) + .and(path("/auth/device/token")) + .and(body_json(json!({ + "code": "one-time-code", + "code_verifier": "the-verifier" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": jwt_with_exp(far_future()), + "refresh_token": "refresh_1", + "user": { + "id": "u1", + "email_verified": true, + "plan": "free", + "password_set": false, + "auth_providers": [] + } + }))) + .expect(1) + .mount(&server) + .await; + + let tokens = client + .oauth() + .exchange_code("one-time-code", "the-verifier") + .await + .expect("exchange succeeds"); + assert_eq!(tokens.refresh_token, "refresh_1"); + assert_eq!(tokens.user.id, "u1"); +} + +#[tokio::test] +async fn session_refreshes_once_on_401_and_replays() { + let (server, _) = common::server_and_client().await; + let access_1 = jwt_with_exp(far_future()); + let access_2 = jwt_with_exp(far_future() + 1); + + // First request with the stale token: 401. + Mock::given(method("GET")) + .and(path("/api/v1/urls/x")) + .and(wiremock::matchers::header( + "authorization", + format!("Bearer {access_1}").as_str(), + )) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": "token revoked", "code": "authentication_error" + }))) + .expect(1) + .mount(&server) + .await; + // The rotation. + Mock::given(method("POST")) + .and(path("/auth/device/refresh")) + .and(body_json(json!({"refresh_token": "refresh_1"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_2, + "refresh_token": "refresh_2" + }))) + .expect(1) + .mount(&server) + .await; + // The replay with the rotated token. + Mock::given(method("GET")) + .and(path("/api/v1/urls/x")) + .and(wiremock::matchers::header( + "authorization", + format!("Bearer {access_2}").as_str(), + )) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"id": "x", "password_set": false})), + ) + .expect(1) + .mount(&server) + .await; + + let rotations: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seen = rotations.clone(); + let session = Arc::new( + Session::new(TokenPair::new(access_1.clone(), "refresh_1")).on_refresh(move |pair| { + seen.lock() + .expect("hook lock") + .push(pair.refresh_token.clone()); + }), + ); + let client = session_client(&server, session); + + client + .links() + .get("x") + .await + .expect("401 rotate replay succeeds"); + assert_eq!( + rotations.lock().expect("hook lock").as_slice(), + ["refresh_2"], + "rotation persisted exactly once" + ); +} + +#[tokio::test] +async fn concurrent_requests_share_one_refresh() { + let (server, _) = common::server_and_client().await; + // An expired access token forces every request through the proactive + // refresh path at once. + let expired = jwt_with_exp(chrono::Utc::now().timestamp() - 10); + let fresh = jwt_with_exp(far_future()); + + let refresh_count = Arc::new(AtomicU32::new(0)); + let counter = refresh_count.clone(); + Mock::given(method("POST")) + .and(path("/auth/device/refresh")) + .respond_with(move |_: &wiremock::Request| { + counter.fetch_add(1, Ordering::SeqCst); + ResponseTemplate::new(200).set_body_json(json!({ + "access_token": jwt_with_exp(chrono::Utc::now().timestamp() + 3600), + "refresh_token": "refresh_2" + })) + }) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/urls/x")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"id": "x", "password_set": false})), + ) + .mount(&server) + .await; + let _ = fresh; + + let session = Arc::new(Session::new(TokenPair::new(expired, "refresh_1"))); + let client = session_client(&server, session); + + let mut handles = Vec::new(); + for _ in 0..8 { + let client = client.clone(); + handles.push(tokio::spawn(async move { client.links().get("x").await })); + } + for handle in handles { + handle.await.expect("task joins").expect("request succeeds"); + } + assert_eq!( + refresh_count.load(Ordering::SeqCst), + 1, + "stampede must share a single rotation" + ); +} + +#[tokio::test] +async fn dead_refresh_token_is_session_expired() { + let (server, _) = common::server_and_client().await; + let expired = jwt_with_exp(chrono::Utc::now().timestamp() - 10); + Mock::given(method("POST")) + .and(path("/auth/device/refresh")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": "refresh token revoked", "code": "authentication_error" + }))) + .mount(&server) + .await; + + let session = Arc::new(Session::new(TokenPair::new(expired, "refresh_dead"))); + let client = session_client(&server, session); + + let err = client.links().get("x").await.expect_err("refresh fails"); + assert!(matches!(err, Error::SessionExpired)); +} + +#[tokio::test] +async fn invalidate_forces_a_refresh_before_the_next_request() { + let (server, _) = common::server_and_client().await; + let valid = jwt_with_exp(far_future()); + Mock::given(method("POST")) + .and(path("/auth/device/refresh")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": jwt_with_exp(far_future()), + "refresh_token": "refresh_2" + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/urls/x")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"id": "x", "password_set": false})), + ) + .expect(1) + .mount(&server) + .await; + + let session = Arc::new(Session::new(TokenPair::new(valid, "refresh_1"))); + let client = session_client(&server, session.clone()); + + session.invalidate().await; + client.links().get("x").await.expect("request succeeds"); +} diff --git a/tests/public_and_emoji.rs b/tests/public_and_emoji.rs new file mode 100644 index 0000000..e515257 --- /dev/null +++ b/tests/public_and_emoji.rs @@ -0,0 +1,168 @@ +//! Public surfaces and the emoji catalogue's ETag cache. + +mod common; + +use serde_json::json; +use wiremock::matchers::{body_json, header, method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use spoo_me::{Generation, PublicStatus}; + +#[tokio::test] +async fn public_stats_get_without_password() { + let (server, _) = common::server_and_client().await; + let client = common::anonymous_client(&server).await; + Mock::given(method("GET")) + .and(path("/api/v1/public/stats/launch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "generation": "v2", + "link": { + "alias": "launch", + "short_url": "https://spoo.me/launch", + "status": "active", + "block_bots": false, + "password_protected": false + }, + "stats": {"summary": {"total_clicks": 5}} + }))) + .expect(1) + .mount(&server) + .await; + + let stats = client + .public() + .stats("launch") + .send() + .await + .expect("public stats load"); + assert_eq!(stats.generation, Generation::V2); + assert_eq!(stats.link.status, PublicStatus::Active); + assert_eq!(stats.stats["summary"]["total_clicks"], 5); +} + +#[tokio::test] +async fn public_stats_password_switches_to_post() { + let (server, _) = common::server_and_client().await; + let client = common::anonymous_client(&server).await; + Mock::given(method("POST")) + .and(path("/api/v1/public/stats/locked")) + .and(body_json(json!({"password": "hunter@22"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "generation": "v1", + "link": { + "alias": "locked", + "short_url": "https://spoo.me/locked", + "status": "active", + "block_bots": true, + "password_protected": true + }, + "stats": {} + }))) + .expect(1) + .mount(&server) + .await; + + let stats = client + .public() + .stats("locked") + .password("hunter@22") + .send() + .await + .expect("password stats load"); + assert!(stats.link.password_protected); +} + +#[tokio::test] +async fn preview_reveals_nothing_for_protected_links() { + let (server, _) = common::server_and_client().await; + let client = common::anonymous_client(&server).await; + Mock::given(method("GET")) + .and(path("/api/v1/public/preview/locked")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "generation": "v2", + "alias": "locked", + "short_url": "https://spoo.me/locked", + "status": "active", + "created_at": "2026-01-01T00:00:00Z", + "password_protected": true, + "destination": null, + "geo_destinations": null + }))) + .expect(1) + .mount(&server) + .await; + + let preview = client + .public() + .preview("locked") + .await + .expect("preview loads"); + assert!(preview.destination.is_none()); +} + +#[tokio::test] +async fn emoji_set_revalidates_with_etag_and_serves_304_from_cache() { + let (server, client) = common::server_and_client().await; + let body = json!({ + "accept_max_version": 15.1, + "generate_max_version": 14.0, + "max_graphemes": 15, + "emoji": [ + {"c": "🚀", "n": "rocket", "g": "Travel & Places", "gen": true}, + {"c": "🎉", "n": "party popper", "g": "Activities", "gen": true, "k": ["tada"]} + ] + }); + Mock::given(method("GET")) + .and(path("/api/v1/emoji-set")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("etag", "\"v42\"") + .set_body_json(body), + ) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/emoji-set")) + .and(header("if-none-match", "\"v42\"")) + .respond_with(ResponseTemplate::new(304)) + .expect(1) + .mount(&server) + .await; + + let first = client.emoji().set().await.expect("first fetch"); + assert_eq!(first.emoji.len(), 2); + assert_eq!( + first.emoji[1].keywords.as_deref(), + Some(&["tada".to_owned()][..]) + ); + + let second = client.emoji().set().await.expect("cached fetch"); + assert_eq!(second.emoji.len(), 2); + assert_eq!(second.max_graphemes, 15); +} + +#[tokio::test] +async fn auth_me_unwraps_the_user_envelope() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/auth/me")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "user": { + "id": "665f0c2f9e7a4b1d2c3d4e5f", + "email": "z@spoo.me", + "email_verified": true, + "plan": "free", + "password_set": true, + "auth_providers": [{"provider": "github"}] + } + }))) + .expect(1) + .mount(&server) + .await; + + let user = client.auth().me().await.expect("me loads"); + assert_eq!(user.email.as_deref(), Some("z@spoo.me")); + assert_eq!(user.auth_providers.len(), 1); +} diff --git a/tests/stats.rs b/tests/stats.rs new file mode 100644 index 0000000..8f83715 --- /dev/null +++ b/tests/stats.rs @@ -0,0 +1,223 @@ +//! Stats and export wire tests, including the filename law. + +mod common; + +use serde_json::json; +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, ResponseTemplate}; + +use spoo_me::{Dimension, ExportFormat, FilterDimension, Metric}; + +fn stats_body() -> serde_json::Value { + json!({ + "scope": "all", + "filters": {"browser": ["Chrome"]}, + "group_by": ["time"], + "timezone": "UTC", + "time_range": {"start_date": "2026-01-01T00:00:00Z", "end_date": "2026-01-08T00:00:00Z"}, + "summary": { + "total_clicks": 120, + "unique_clicks": 80, + "first_click": "2026-01-01T10:00:00Z", + "last_click": "2026-01-07T22:00:00Z", + "avg_redirection_time": 42.5 + }, + "metrics": { + "clicks_by_time": [ + {"time": "2026-01-01", "clicks": 60, "clicks_percentage": 50.0}, + {"time": "2026-01-02", "clicks": 60, "clicks_percentage": 50.0} + ] + }, + "generated_at": "2026-01-08T00:00:01Z", + "time_bucket_info": { + "strategy": "daily", + "mongo_format": "%Y-%m-%d", + "display_format": "MMM D", + "timezone": "UTC" + }, + "computed_metrics": { + "unique_click_rate": 0.66, + "repeat_click_rate": 0.34, + "average_clicks_per_visitor": 1.5 + } + }) +} + +#[tokio::test] +async fn account_stats_query_encoding() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/stats")) + .and(query_param("group_by", "time,browser")) + .and(query_param("metrics", "clicks,unique_clicks")) + .and(query_param("timezone", "Asia/Kolkata")) + .and(query_param( + "filters", + r#"{"browser":["Chrome"],"url_id":["665f0c2f9e7a4b1d2c3d4e5f"]}"#, + )) + .respond_with(ResponseTemplate::new(200).set_body_json(stats_body())) + .expect(1) + .mount(&server) + .await; + + let report = client + .stats() + .account() + .group_by(Dimension::Time) + .group_by(Dimension::Browser) + .metric(Metric::Clicks) + .metric(Metric::UniqueClicks) + .timezone("Asia/Kolkata") + .filter(FilterDimension::Browser, ["Chrome"]) + .url_ids(["665f0c2f9e7a4b1d2c3d4e5f"]) + .send() + .await + .expect("stats load"); + assert_eq!(report.summary.total_clicks, 120); + assert_eq!(report.metrics["clicks_by_time"].len(), 2); + assert_eq!( + report + .time_bucket_info + .expect("bucket info present") + .strategy, + "daily" + ); +} + +#[tokio::test] +async fn link_stats_carries_identity() { + let (server, client) = common::server_and_client().await; + let mut body = stats_body(); + body["url_id"] = json!("665f0c2f9e7a4b1d2c3d4e5f"); + body["alias"] = json!("launch"); + Mock::given(method("GET")) + .and(path("/api/v1/stats/links/665f0c2f9e7a4b1d2c3d4e5f")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(&server) + .await; + + let report = client + .stats() + .for_link("665f0c2f9e7a4b1d2c3d4e5f") + .send() + .await + .expect("link stats load"); + assert_eq!(report.alias, "launch"); + assert_eq!(report.stats.summary.unique_clicks, 80); +} + +#[tokio::test] +async fn export_uses_server_filename_when_safe() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/export/links/665f0c2f9e7a4b1d2c3d4e5f")) + .and(query_param("format", "json")) + .respond_with( + ResponseTemplate::new(200) + .insert_header( + "content-disposition", + "attachment; filename=\"launch-stats.json\"", + ) + .set_body_raw("{\"rows\":[]}", "application/json"), + ) + .expect(1) + .mount(&server) + .await; + + let export = client + .stats() + .export_link("665f0c2f9e7a4b1d2c3d4e5f") + .format(ExportFormat::Json) + .send() + .await + .expect("export downloads"); + assert_eq!(export.filename, "launch-stats.json"); + assert_eq!(export.content_type, "application/json"); + let bytes = export.bytes().await.expect("body buffers"); + assert_eq!(bytes, b"{\"rows\":[]}"); +} + +#[tokio::test] +async fn hostile_export_filenames_fall_back() { + let cases = [ + "attachment; filename=\"../../../evil.json\"", + "attachment; filename=\"/tmp/absolute-evil.json\"", + "attachment; filename*=utf-8''%2e%2e%2f%2e%2e%2fesc.json", + "attachment; filename=\"..\"", + ]; + for disposition in cases { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/export")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-disposition", disposition) + .set_body_string("{}"), + ) + .mount(&server) + .await; + + let export = client + .stats() + .export() + .format(ExportFormat::Json) + .send() + .await + .expect("export downloads"); + assert!( + !export.filename.contains('/') + && !export.filename.contains('\\') + && export.filename != ".." + && !export.filename.is_empty(), + "unsafe filename escaped for {disposition:?}: {}", + export.filename + ); + } +} + +#[tokio::test] +async fn csv_export_fallback_extension_is_zip() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/export")) + .and(query_param("format", "csv")) + .respond_with(ResponseTemplate::new(200).set_body_raw(b"PK".to_vec(), "application/zip")) + .expect(1) + .mount(&server) + .await; + + let export = client + .stats() + .export() + .format(ExportFormat::Csv) + .send() + .await + .expect("export downloads"); + assert_eq!(export.filename, "spoo-export.zip"); +} + +#[tokio::test] +#[cfg(feature = "stream")] +async fn export_body_streams() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/export")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![7u8; 64 * 1024])) + .mount(&server) + .await; + + use futures_util::StreamExt as _; + let export = client + .stats() + .export() + .send() + .await + .expect("export downloads"); + let mut total = 0usize; + let mut stream = export.bytes_stream(); + while let Some(chunk) = stream.next().await { + total += chunk.expect("chunk ok").len(); + } + assert_eq!(total, 64 * 1024); +} diff --git a/tests/transport.rs b/tests/transport.rs new file mode 100644 index 0000000..ec61906 --- /dev/null +++ b/tests/transport.rs @@ -0,0 +1,274 @@ +//! Transport behavior: retries, error mapping, headers. + +mod common; + +use serde_json::json; +use std::time::Duration; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use spoo_me::Error; + +fn ok_link() -> serde_json::Value { + json!({ + "id": "665f0c2f9e7a4b1d2c3d4e5f", + "alias": "a", + "short_url": "https://spoo.me/a", + "long_url": "https://example.com/", + "created_at": 1704067200, + "status": "ACTIVE" + }) +} + +#[tokio::test] +async fn client_tag_header_is_sent() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/urls/x")) + .and(header( + "x-spoo-client", + format!("sdk-rust/{}", env!("CARGO_PKG_VERSION")).as_str(), + )) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"id": "x", "password_set": false})), + ) + .expect(1) + .mount(&server) + .await; + client + .links() + .get("x") + .await + .expect("request carries the tag"); +} + +#[tokio::test] +async fn get_retries_transient_500_then_succeeds() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/urls/x")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/urls/x")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"id": "x", "password_set": false})), + ) + .expect(1) + .mount(&server) + .await; + + client.links().get("x").await.expect("retry recovers"); +} + +#[tokio::test] +async fn post_does_not_retry_500() { + let (server, client) = common::server_and_client().await; + Mock::given(method("POST")) + .and(path("/api/v1/shorten")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ + "error": "exploded", "code": "internal" + }))) + .expect(1) + .mount(&server) + .await; + + let err = client + .links() + .create("https://example.com/") + .send() + .await + .expect_err("500 on POST must surface, not replay"); + assert_eq!(err.status(), Some(500)); +} + +#[tokio::test] +async fn post_retries_429_honoring_retry_after() { + let (server, client) = common::server_and_client().await; + Mock::given(method("POST")) + .and(path("/api/v1/shorten")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("retry-after", "1") + .set_body_json(json!({"error": "slow down", "code": "rate_limit_exceeded"})), + ) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/v1/shorten")) + .respond_with(ResponseTemplate::new(201).set_body_json(ok_link())) + .expect(1) + .mount(&server) + .await; + + let started = std::time::Instant::now(); + client + .links() + .create("https://example.com/") + .send() + .await + .expect("429 retry recovers"); + assert!( + started.elapsed() >= Duration::from_millis(900), + "Retry-After of 1s was not honored: {:?}", + started.elapsed() + ); +} + +#[tokio::test] +async fn envelope_error_maps_fields_and_rate_limit() { + let (server, client) = common::server_and_client().await; + Mock::given(method("POST")) + .and(path("/api/v1/shorten")) + .respond_with( + ResponseTemplate::new(422) + .insert_header("x-request-id", "req-123") + .insert_header("x-ratelimit-limit", "50") + .insert_header("x-ratelimit-remaining", "49") + .insert_header("x-ratelimit-reset", "1767225599") + .set_body_json(json!({ + "error": "alias is taken", + "code": "conflict", + "field": "alias" + })), + ) + .expect(1) + .mount(&server) + .await; + + let err = client + .links() + .create("https://example.com/") + .alias("taken") + .send() + .await + .expect_err("422 surfaces"); + let api = err.api().expect("api error"); + assert_eq!(api.code, "conflict"); + assert_eq!(api.field.as_deref(), Some("alias")); + assert_eq!(api.request_id.as_deref(), Some("req-123")); + assert_eq!(api.rate_limit.limit, Some(50)); + assert_eq!( + api.rate_limit.reset.map(|t| t.timestamp()), + Some(1767225599) + ); + assert!(api.body.is_none(), "envelope bodies are not preserved raw"); +} + +#[tokio::test] +async fn non_envelope_body_is_never_the_message() { + let (server, client) = common::server_and_client().await; + let html = "

502 Bad Gateway

"; + Mock::given(method("GET")) + .and(path("/api/v1/urls/x")) + .respond_with( + ResponseTemplate::new(502) + .insert_header("x-error-code", "bad_gateway") + .set_body_string(html), + ) + .mount(&server) + .await; + + let err = client.links().get("x").await.expect_err("502 surfaces"); + let api = err.api().expect("api error"); + assert_eq!(api.message, "HTTP 502"); + assert_eq!( + api.code, "bad_gateway", + "X-Error-Code header is the fallback" + ); + assert_eq!(api.body.as_deref(), Some(html)); +} + +#[tokio::test] +async fn error_predicates_branch_without_string_matching() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/urls/gone")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "error": "no such URL", "code": "not_found" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/urls/blocked")) + .respond_with(ResponseTemplate::new(451).set_body_json(json!({ + "error": "unavailable", "code": "blocked" + }))) + .mount(&server) + .await; + + let not_found = client.links().get("gone").await.expect_err("404"); + assert!(not_found.is_not_found()); + assert!(!not_found.is_blocked()); + + let blocked = client.links().get("blocked").await.expect_err("451"); + assert!(blocked.is_blocked()); +} + +#[tokio::test] +async fn password_required_predicate_uses_error_code() { + let (server, _) = common::server_and_client().await; + let client = common::anonymous_client(&server).await; + Mock::given(method("GET")) + .and(path("/api/v1/public/stats/locked")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("x-error-code", "password_required") + .set_body_json(json!({ + "error": "password required", "code": "password_required" + })), + ) + .mount(&server) + .await; + + let err = client + .public() + .stats("locked") + .send() + .await + .expect_err("401 surfaces"); + assert!(err.is_password_required()); + assert!(!matches!(err, Error::SessionExpired)); +} + +#[tokio::test] +async fn escape_hatch_reuses_auth_and_error_mapping() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/some/new/endpoint")) + .and(header("authorization", "Bearer spoo_test_key")) + .and(wiremock::matchers::query_param("k", "v")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"answer": 42}))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/v1/some/new/endpoint")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "error": "nope", "code": "not_found" + }))) + .expect(1) + .mount(&server) + .await; + + #[derive(Debug, serde::Deserialize)] + struct Answer { + answer: u32, + } + let got: Answer = client + .get("/api/v1/some/new/endpoint", &[("k", "v")]) + .await + .expect("raw get succeeds"); + assert_eq!(got.answer, 42); + + let err = client + .post::("/api/v1/some/new/endpoint", &json!({"x": 1})) + .await + .expect_err("raw post maps errors"); + assert!(err.is_not_found()); +} From 431018446ae834072470472b5d4871a3da05520c Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 01:15:08 +0530 Subject: [PATCH 06/13] docs: README, migration guide, examples, MIT license The README doubles as the crate docs so its examples compile in CI. MIGRATION.md maps every 0.1.x call to its 0.2.0 shape. License moves to MIT for the rewrite; 0.1.x releases remain Apache-2.0 and the original crate's author stays credited. --- CHANGELOG.md | 37 ++++ LICENSE | 222 +++------------------- MIGRATION.md | 35 ++++ README.md | 334 ++++++++++++++++++++++++++++++++-- examples/analytics.rs | 45 +++++ examples/shorten.rs | 41 +++++ examples/sign_in_with_spoo.rs | 43 +++++ 7 files changed, 544 insertions(+), 213 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 MIGRATION.md create mode 100644 examples/analytics.rs create mode 100644 examples/shorten.rs create mode 100644 examples/sign_in_with_spoo.rs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..be8712a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +## 0.2.0 (unreleased) + +Rewrite against the spoo.me v1 API. Every endpoint the 0.1.x crate called +was removed in the platform's v1 overhaul, so this is a clean break; see +MIGRATION.md. The design language of the original crate by +[rdni](https://github.com/rdni) carries over: consuming builders, layered +errors, documentation on every public item. + +- v1 coverage: shorten (alphanumeric and emoji aliases), alias check, link + management (list/get/get-by-address/update/set-status/delete), bulk + delete/status/expiry/domain, claiming anonymous links, account and + per-link statistics, streaming exports, public stats and previews, the + emoji catalogue (ETag-cached), identity read. +- Authentication: API keys, anonymous mode, and Sign in with Spoo behind + the `oauth` feature (PKCE, device-code exchange, self-refreshing + single-flight sessions). +- Tri-state PATCH updates: untouched fields keep their stored values, + `remove_*` methods clear a setting explicitly. +- Typed errors with the backend's machine-readable codes, rate-limit + metadata, and predicates (`is_not_found`, `is_rate_limited`, + `is_blocked`, `is_password_required`). +- Automatic retries with jittered backoff capped at 8 seconds, honoring + both legal `Retry-After` forms; POST and PATCH replay only where the + server provably did no work. +- Server-suggested export filenames are sanitized to safe bare names. +- Raw typed passthroughs (`client.get/post/patch/delete`) for endpoints the + SDK does not cover yet. +- `#![forbid(unsafe_code)]`, panic-free library paths enforced by lints, + wasm32 support, `stream`/`oauth`/`tracing`/`native-tls` features. +- License changed to MIT (0.1.x releases remain Apache-2.0). + +## 0.1.1 and earlier + +The original crate by rdni, targeting the pre-2026 spoo.me API +(form-encoded shorten/stats/export endpoints). Apache-2.0. diff --git a/LICENSE b/LICENSE index ba370d0..80fd6b7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2025 rdni - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file +MIT License + +Copyright (c) 2026 spoo.me + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..77a65c7 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,35 @@ +# Migrating from 0.1.x to 0.2.0 + +0.2.0 is a rewrite. The 0.1.x crate targeted the original spoo.me endpoints, +which were removed in the platform's v1 overhaul, so every 0.1.x method has +stopped working against production regardless of SDK version. 0.2.0 targets +the v1 API, adds authentication, and keeps the original crate's design +language: consuming builders, layered errors, documented everything. + +## The shape of the change + +| 0.1.x | 0.2.0 | +|---|---| +| `UrlShortenerClient::new()` | `Client::new(api_key)` / `Client::anonymous()` / `Client::builder()` | +| `ShortenRequest::new(url).alias(a)` + `client.shorten(req)` | `client.links().create(url).alias(a).send()` | +| `EmojiRequest::new(url)` + `client.emoji(req)` | `client.links().create(url).alias("🚀🔥").send()` or `.alias_kind(AliasKind::Emoji)` | +| `StatsRequest::new(code)` + `client.stats(req)` | `client.public().stats(code).send()` (public) or `client.stats().for_link(id).send()` (authenticated) | +| `ExportRequest::new(code, fmt)` + `client.export(req)` | `client.stats().export_link(id).format(fmt).send()` | +| `UrlShortenerError::{Validation, Api, Http, Json}` | `Error::{Api, Transport, Decode, SessionExpired, Config}` | +| `blocking` feature replacing async | not yet available in 0.2.0 (planned as an additive feature) | +| `custom_url` feature | always available: `Client::builder().base_url(...)` | + +## Behavior changes worth knowing + +- Client-side validation of aliases, passwords and URLs is gone. The server + is the validator; its typed errors carry a machine-readable `code` and the + offending `field`. +- Requests authenticate with an API key or a Sign in with Spoo session. + Anonymous creation still works and returns a one-time `claim_token`. +- Retries are built in: transient failures retry twice with jittered + backoff, honoring `Retry-After`. +- Timestamps are `chrono::DateTime` everywhere. +- Exports stream and their filenames are sanitized to safe bare names. +- Links are addressed by their `id` (the value management endpoints use), + not by short code. `links().get_by_address(domain, alias)` resolves a + short code to the full record. diff --git a/README.md b/README.md index b74ebe0..b72a4cd 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,322 @@ -Rust bindings for accessing the spoo.me API -=========================== - -This crate provides a client that can access all endpoints provided by spoo.me, with support for self hosted instances of it. - -
- -## Optional features -=========================== - -- **`blocking`** - a synchronous version of the api -- **`custom_url`** - support for changing the base URL \ No newline at end of file +# spoo.me Rust SDK + +The official Rust SDK for the [spoo.me](https://spoo.me) link management API. + +```rust,no_run +# async fn run() -> Result<(), spoo_me::Error> { +let client = spoo_me::Client::new("spoo_your_api_key"); + +let link = client + .links() + .create("https://example.com/launch") + .alias("launch") // or emoji: "🚀🔥" + .max_clicks(10_000) + .send() + .await?; +println!("{}", link.short_url); // https://spoo.me/launch +# Ok(()) +# } +``` + +- Async-first on `reqwest` with rustls, wasm32 supported +- Typed errors, automatic retries, streaming pagination and exports +- Timestamps in and out as `chrono::DateTime`, whatever the wire format +- Anonymous, API key, and Sign in with Spoo authentication +- `#![forbid(unsafe_code)]`, panic-free library paths, thin dependency tree + +## Install + +```sh +cargo add spoo-me +``` + +Requires Rust 1.85 or newer. + +## Authentication + +Create an API key from your [spoo.me dashboard](https://spoo.me) and pass it +explicitly: + +```rust,no_run +# fn run() -> Result<(), spoo_me::Error> { +let client = spoo_me::Client::from_env()?; +# Ok(()) +# } +``` + +`Client::from_env()` reads `SPOO_API_KEY` for you; that constructor is the +only place the crate touches the environment. `Client::anonymous()` works +without an account: anonymous shortening and the public endpoints (stats, +previews, the emoji set) need no credentials. + +Self-hosting spoo.me, sharing a connection pool, or tagging your app: + +```rust,no_run +# fn run() -> Result<(), spoo_me::Error> { +let client = spoo_me::Client::builder() + .api_key("spoo_...") + .base_url("https://links.example.com") + .http_client(reqwest::Client::new()) + .client_tag("my-app/1.0") + .build()?; +# Ok(()) +# } +``` + +The client is `Send + Sync + Clone` and cheap to clone: share one across +tasks. + +## Shorten links + +```rust,no_run +# async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> { +use chrono::{Duration, Utc}; + +let link = client + .links() + .create("https://example.com/launch") + .alias("launch") + .password("secure@123") + .max_clicks(10_000) + .expire_after(Utc::now() + Duration::days(30)) + .send() + .await?; +# Ok(()) +# } +``` + +Anonymous creations return a one-time `claim_token`. Store it and the link +can be claimed into an account later: + +```rust,no_run +# async fn run(client: spoo_me::Client, link: spoo_me::Link) -> Result<(), spoo_me::Error> { +use spoo_me::ClaimRequest; + +let token = link.claim_token.unwrap_or_default(); +let outcome = client + .links() + .claim([ClaimRequest::new(&link.id, token)]) + .await?; +# Ok(()) +# } +``` + +## Manage links + +```rust,no_run +# async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> { +use spoo_me::{SettableStatus, SortBy}; + +// Paginated listing with typed filters. +let page = client + .links() + .list() + .page_size(50) + .sort_by(SortBy::TotalClicks) + .status(SettableStatus::Active) + .search("promo") + .send() + .await?; + +// Or walk everything lazily (`stream` feature, on by default). +use futures_util::TryStreamExt as _; +let mut all = client.links().list().send().await?.stream(); +while let Some(link) = all.try_next().await? { + println!("{}", link.id); +} + +// Updates only touch what you set; remove_* clears a setting explicitly. +client + .links() + .update("665f0c2f9e7a4b1d2c3d4e5f") + .long_url("https://example.com/v2") + .remove_password() + .send() + .await?; + +// Bulk operations report per-item outcomes instead of failing the batch. +let outcome = client + .links() + .bulk_set_status(["id1", "id2"], SettableStatus::Inactive) + .await?; +for row in &outcome.results { + if !row.ok { + println!("{}: {:?}", row.id, row.error_code); + } +} +# Ok(()) +# } +``` + +## Statistics and exports + +```rust,no_run +# async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> { +use spoo_me::{Dimension, ExportFormat, FilterDimension}; + +let report = client + .stats() + .account() + .group_by(Dimension::Time) + .group_by(Dimension::Country) + .filter(FilterDimension::Browser, ["Chrome"]) + .send() + .await?; +println!("{} clicks", report.summary.total_clicks); + +let per_link = client.stats().for_link("665f0c2f9e7a4b1d2c3d4e5f").send().await?; + +// Exports stream; filenames from the server are reduced to a safe bare +// name before you see them, so joining into a directory is safe. +let export = client + .stats() + .export_link("665f0c2f9e7a4b1d2c3d4e5f") + .format(ExportFormat::Csv) + .send() + .await?; +println!("saving {}", export.filename); +let bytes = export.bytes().await?; // or .bytes_stream() +# Ok(()) +# } +``` + +Account-wide downloads come from `stats().export()`; per-link downloads with +per-link filenames come from `stats().export_link(id)`. + +## Public endpoints + +```rust,no_run +# async fn run() -> Result<(), spoo_me::Error> { +let client = spoo_me::Client::anonymous(); + +let stats = client.public().stats("launch").send().await?; +let locked = client.public().stats("locked").password("hunter@22").send().await?; + +// Where does this short link lead? Never reveals what the redirect would +// refuse to serve. +let preview = client.public().preview("launch").await?; + +// The emoji-alias catalogue, ETag-cached on the client. +let set = client.emoji().set().await?; +# Ok(()) +# } +``` + +## Errors + +Everything returns `Result`, and the library never +panics. API failures carry the parsed error envelope plus predicates so you +can branch without string matching: + +```rust,no_run +# async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> { +match client.links().get("gone").await { + Ok(link) => println!("{:?}", link.alias), + Err(err) if err.is_not_found() => println!("no such link"), + Err(err) if err.is_rate_limited() => println!("wait {:?}", err.retry_after()), + Err(err) if err.is_blocked() => println!("taken down"), + Err(err) => return Err(err), +} +# Ok(()) +# } +``` + +Transient failures (408, 429, 5xx, connection errors) are retried twice with +jittered exponential backoff capped at 8 seconds, honoring `Retry-After` in +both its legal forms. Requests that could duplicate work on replay (POST, +PATCH) retry only where the server provably did nothing (429, 503). + +## Sign in with Spoo + +The `oauth` feature carries the client half of the connected-apps flow: +PKCE, the device-code exchange, and a self-refreshing session. + +```rust,ignore +// Requires the `oauth` feature. +# async fn run() -> Result<(), spoo_me::Error> { +use std::sync::Arc; +use spoo_me::oauth::{generate_pkce_pair, generate_state, Session, TokenPair}; + +let anon = spoo_me::Client::anonymous(); +let pkce = generate_pkce_pair(); +let url = anon + .oauth() + .authorization_url("your_app_id", generate_state(), &pkce.challenge) + .redirect_uri("http://127.0.0.1:8000/callback") + .build()?; +// Open `url` in a browser; your callback receives a one-time code. + +let tokens = anon.oauth().exchange_code("the-code", pkce.verifier).await?; + +let session = Arc::new(Session::new(tokens.tokens()).on_refresh(|pair| { + // Persist the rotated pair: the previous refresh token is dead. + let _ = pair; +})); +let client = spoo_me::Client::builder().session(session).build()?; +let me = client.auth().me().await?; +# Ok(()) +# } +``` + +Sessions refresh proactively before the access token expires and once more +after an unexpected 401. Refreshes are single-flight across tasks, and a +dead refresh token surfaces as `Error::SessionExpired`. + +## Scope + +This SDK covers the v1 data plane: shortening (including emoji aliases), +link management, bulk operations, claiming, statistics, exports, public link +surfaces, the emoji catalogue, identity read, and Sign in with Spoo. Account +administration (API key management, profile editing), service endpoints +(health, contact), and the legacy v0 API are deliberately out of scope. + +| Area | Methods | +|---|---| +| Shorten | `links().create()`, `links().check_alias()` | +| Manage | `links().list()`, `get()`, `get_by_address()`, `update()`, `set_status()`, `delete()`, `delete_all_on_domain()` | +| Bulk | `bulk_delete()`, `bulk_set_status()`, `bulk_set_expiry()`, `bulk_move_domain()` | +| Claim | `links().claim()` | +| Stats | `stats().account()`, `stats().for_link()` | +| Export | `stats().export()`, `stats().export_link()` | +| Public | `public().stats()`, `public().preview()` | +| Emoji | `emoji().set()` | +| Identity | `auth().me()` | +| Sign in with Spoo | `oauth().authorization_url()`, `exchange_code()`, `refresh_tokens()`, `Session` | + +## Raw requests + +For v1 endpoints the SDK does not cover yet, the client exposes typed +passthroughs that reuse its auth, retries, timeout and error mapping: + +```rust,no_run +# async fn run(client: spoo_me::Client) -> Result<(), spoo_me::Error> { +#[derive(serde::Deserialize)] +struct Whatever { ok: bool } + +let value: Whatever = client.get("/api/v1/new-endpoint", &[("k", "v")]).await?; +# Ok(()) +# } +``` + +These are a supported pressure valve. If you need one, the surface has a gap +worth an issue on this repo. + +## Features + +| Feature | Default | What it adds | +|---|---|---| +| `stream` | yes | Lazy pagination and export bodies as `futures_core::Stream` | +| `oauth` | no | Sign in with Spoo: PKCE, exchange, refreshing sessions | +| `tracing` | no | Per-request spans and retry events | +| `native-tls` | no | Platform TLS instead of rustls | + +## Credits + +The original `spoo-me` crate was created and designed by +[rdni](https://github.com/rdni). This SDK keeps that crate's design +language: consuming builders, layered errors, and documented-everything +discipline. + +## License + +MIT. Versions up to 0.1.1 were published under Apache-2.0. diff --git a/examples/analytics.rs b/examples/analytics.rs new file mode 100644 index 0000000..ff9e0d8 --- /dev/null +++ b/examples/analytics.rs @@ -0,0 +1,45 @@ +//! Pull a statistics breakdown and download an export. +//! +//! Run with: SPOO_API_KEY=spoo_... cargo run --example analytics + +use spoo_me::{Client, Dimension, ExportFormat}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Client::from_env()?; + + let report = client + .stats() + .account() + .group_by(Dimension::Time) + .group_by(Dimension::Country) + .send() + .await?; + println!( + "{} clicks ({} unique) between {:?} and {:?}", + report.summary.total_clicks, + report.summary.unique_clicks, + report.time_range.start_date, + report.time_range.end_date, + ); + for (series, points) in &report.metrics { + println!(" {series}: {} data points", points.len()); + } + + let export = client + .stats() + .export() + .format(ExportFormat::Json) + .send() + .await?; + // The filename is sanitized by the SDK, so joining it is safe. + let filename = export.filename.clone(); + let bytes = export.bytes().await?; + let path = std::env::temp_dir().join("spoo").join(filename); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, bytes)?; + println!("export saved to {}", path.display()); + Ok(()) +} diff --git a/examples/shorten.rs b/examples/shorten.rs new file mode 100644 index 0000000..6701174 --- /dev/null +++ b/examples/shorten.rs @@ -0,0 +1,41 @@ +//! Create a link, check an alias, list your links. +//! +//! Run with: SPOO_API_KEY=spoo_... cargo run --example shorten + +use spoo_me::{Client, SortBy}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Client::from_env()?; + + let check = client.links().check_alias("launch").send().await?; + println!("alias 'launch' available: {}", check.available); + + let link = client + .links() + .create("https://example.com/launch") + .max_clicks(10_000) + .send() + .await?; + println!("created {} -> {}", link.short_url, link.long_url); + + let page = client + .links() + .list() + .page_size(10) + .sort_by(SortBy::CreatedAt) + .send() + .await?; + println!("{} links total, showing {}:", page.total, page.items.len()); + for item in &page.items { + println!( + " {} {} clicks", + item.alias.as_deref().unwrap_or("-"), + item.total_clicks.unwrap_or(0) + ); + } + + client.links().delete(&link.id).await?; + println!("cleaned up {}", link.id); + Ok(()) +} diff --git a/examples/sign_in_with_spoo.rs b/examples/sign_in_with_spoo.rs new file mode 100644 index 0000000..fb45b36 --- /dev/null +++ b/examples/sign_in_with_spoo.rs @@ -0,0 +1,43 @@ +//! The Sign in with Spoo flow: build the consent URL, exchange the code, +//! attach a self-refreshing session. +//! +//! Run with: cargo run --example sign_in_with_spoo --features oauth + +use std::sync::Arc; + +use spoo_me::oauth::{Session, generate_pkce_pair, generate_state}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let anon = spoo_me::Client::anonymous(); + + let pkce = generate_pkce_pair(); + let url = anon + .oauth() + .authorization_url("your_app_id", generate_state(), &pkce.challenge) + .redirect_uri("http://127.0.0.1:8000/callback") + .build()?; + println!("open this in a browser:\n {url}"); + println!("then paste the one-time code from your callback:"); + + let mut code = String::new(); + std::io::stdin().read_line(&mut code)?; + let tokens = anon + .oauth() + .exchange_code(code.trim(), pkce.verifier) + .await?; + println!("signed in as {:?}", tokens.user.email); + + // The session refreshes itself; persist rotated pairs in on_refresh. + let session = Arc::new(Session::new(tokens.tokens()).on_refresh(|pair| { + println!( + "tokens rotated; persist refresh token {}...", + &pair.refresh_token[..8.min(pair.refresh_token.len())] + ); + })); + let client = spoo_me::Client::builder().session(session).build()?; + + let me = client.auth().me().await?; + println!("plan: {}", me.plan); + Ok(()) +} From c11680d4ba2e7e1678c983654b11cbc6c697831d Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 01:15:08 +0530 Subject: [PATCH 07/13] ci: full gate and trusted publishing fmt, clippy with warnings denied, nextest on stable and the MSRV, doctests, feature matrix, wasm32 check, cargo-deny, semver-checks, docs build, and spec drift against the backend's openapi.json. Publishing runs on GitHub release via crates.io trusted publishing. --- .github/workflows/ci.yml | 107 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 28 +++++++++ 2 files changed, 135 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ba7c02a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,107 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check + + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-features --all-targets -- -D warnings + - run: cargo clippy --no-default-features --all-targets -- -D warnings + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + toolchain: [stable, "1.85"] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.toolchain }} + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + - run: cargo nextest run --all-features + # nextest does not run doctests; the README compiles here. + - run: cargo test --all-features --doc + + features: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check --no-default-features + - run: cargo check --no-default-features --features oauth + - run: cargo check --no-default-features --features tracing + - run: cargo check --all-features + - run: cargo check --no-default-features --features native-tls + + wasm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - run: cargo check --target wasm32-unknown-unknown + - run: cargo check --target wasm32-unknown-unknown --features oauth + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo doc --all-features --no-deps + env: + RUSTDOCFLAGS: -D warnings + + deny: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: EmbarkStudios/cargo-deny-action@v2 + + semver: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: obi1kenobi/cargo-semver-checks-action@v2 + with: + feature-group: all-features + + spec-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Compare committed openapi.json with the backend's + run: | + curl -fsSL https://raw.githubusercontent.com/spoo-me/spoo/main/openapi.json -o upstream-openapi.json + diff -q openapi.json upstream-openapi.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..97add59 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +# Publishes to crates.io via Trusted Publishing (OIDC) when a GitHub +# release is published. Requires the crates.io trusted-publisher +# configuration for this repository. +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --all-features + - uses: rust-lang/crates-io-auth-action@v1 + id: auth + - run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} From 878ddfb07c0c2e04cc8d160bc21f723f3febbf72 Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 01:46:31 +0530 Subject: [PATCH 08/13] fix: stable filter ordering and wasm transport retries The stats filters map is now a BTreeMap so the serialized JSON has a deterministic key order. On wasm32 reqwest exposes no error classification, so transport failures surface immediately instead of calling methods that do not exist on that target. --- src/http.rs | 10 ++++++++++ src/resources/stats.rs | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/http.rs b/src/http.rs index f9255fa..dac3003 100644 --- a/src/http.rs +++ b/src/http.rs @@ -209,6 +209,7 @@ fn retryable_status(method: &Method, status: StatusCode) -> bool { } } +#[cfg(not(target_arch = "wasm32"))] fn retryable_transport(method: &Method, err: &reqwest::Error) -> bool { if idempotent(method) { err.is_connect() || err.is_timeout() || err.is_request() @@ -220,6 +221,15 @@ fn retryable_transport(method: &Method, err: &reqwest::Error) -> bool { } } +// On wasm the host runtime owns connections and reqwest exposes no error +// classification, so nothing distinguishes "never sent" from "maybe +// processed". Transport failures surface immediately; status-based retries +// still apply. +#[cfg(target_arch = "wasm32")] +fn retryable_transport(_method: &Method, _err: &reqwest::Error) -> bool { + false +} + fn idempotent(method: &Method) -> bool { matches!( *method, diff --git a/src/resources/stats.rs b/src/resources/stats.rs index eae637e..284b4ec 100644 --- a/src/resources/stats.rs +++ b/src/resources/stats.rs @@ -378,7 +378,8 @@ struct QueryCore { group_by: Vec, metrics: Vec, timezone: Option, - filters: HashMap<&'static str, Vec>, + // BTreeMap so the serialized filters JSON has a stable key order. + filters: std::collections::BTreeMap<&'static str, Vec>, } impl QueryCore { From 668b577f02ed1b8dcd5ebb2fd9ace91e285753e1 Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 01:49:13 +0530 Subject: [PATCH 09/13] fix: silence the unused timeout field on wasm The field's only read sits behind a native-target cfg, so wasm builds flagged it as dead code under denied warnings. --- src/http.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/http.rs b/src/http.rs index dac3003..a785d2d 100644 --- a/src/http.rs +++ b/src/http.rs @@ -26,6 +26,8 @@ pub(crate) struct Transport { pub(crate) auth: Auth, pub(crate) client_tag: String, pub(crate) max_retries: u32, + // Read only on native targets: wasm hosts own request lifetimes. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) timeout: Duration, } From c18d4a2abaf259e50d6960163628ca642d438c73 Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 01:54:56 +0530 Subject: [PATCH 10/13] fix: wire getrandom's wasm backend for the oauth feature rand needs getrandom, and on wasm32 getrandom 0.3 requires the wasm_js feature plus a backend cfg set by the final binary. The feature is now enabled on the wasm target and CI checks the combination the way a consumer would build it; the README documents the RUSTFLAGS line. --- .github/workflows/ci.yml | 4 ++++ Cargo.lock | 3 +++ Cargo.toml | 5 ++++- README.md | 4 ++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba7c02a..15e2592 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,11 @@ jobs: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - run: cargo check --target wasm32-unknown-unknown + # getrandom's wasm backend is chosen by the final binary via RUSTFLAGS; + # this check builds the way a wasm consumer of the oauth feature must. - run: cargo check --target wasm32-unknown-unknown --features oauth + env: + RUSTFLAGS: -D warnings --cfg getrandom_backend="wasm_js" docs: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 38fb5c2..00b3ddf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -450,9 +450,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1509,6 +1511,7 @@ dependencies = [ "chrono", "futures-core", "futures-util", + "getrandom 0.3.4", "gloo-timers", "rand 0.9.5", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 539b08a..5fcbbd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ default = ["stream"] # Lazy pagination and export bodies as `futures_core::Stream`. stream = ["dep:futures-core", "dep:bytes"] # Sign in with Spoo: PKCE helpers, device-code exchange, refreshing sessions. -oauth = ["dep:sha2", "dep:base64", "dep:rand", "dep:tokio"] +oauth = ["dep:sha2", "dep:base64", "dep:rand", "dep:tokio", "dep:getrandom"] # Per-request tracing spans and retry events. tracing = ["dep:tracing"] # Use the platform TLS stack instead of rustls. @@ -48,6 +48,9 @@ tokio = { version = "1", default-features = false, features = ["time"] } [target.'cfg(target_arch = "wasm32")'.dependencies] gloo-timers = { version = "0.3", features = ["futures"] } +# rand's randomness source; on wasm the final binary must also build with +# RUSTFLAGS='--cfg getrandom_backend="wasm_js"' (getrandom's documented model). +getrandom = { version = "0.3", features = ["wasm_js"], optional = true } [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } diff --git a/README.md b/README.md index b72a4cd..c825f8f 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,10 @@ worth an issue on this repo. | `tracing` | no | Per-request spans and retry events | | `native-tls` | no | Platform TLS instead of rustls | +Using `oauth` on wasm32? getrandom requires the final binary to pick its +backend: add `--cfg getrandom_backend="wasm_js"` to your RUSTFLAGS (usually +via `.cargo/config.toml`). + ## Credits The original `spoo-me` crate was created and designed by From 3f764c4901fad068dfed9ddc830cad395997752d Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 02:15:52 +0530 Subject: [PATCH 11/13] fix: harden the transport chrono gains wasmbind so Utc::now() works on wasm; backoff jitter is a process-lifetime counter instead of clock noise; an honored Retry-After is capped at 60s, beyond which the response surfaces with the full wait readable on the error; caller-supplied path segments are percent encoded; the HTTP client builds lazily so construction cannot panic; base URLs are parsed, not prefix-checked; the emoji ETag revalidation rides the normal transport with 304 as a success. --- Cargo.lock | 2 + Cargo.toml | 3 +- src/client.rs | 15 ++++-- src/http.rs | 112 +++++++++++++++++++++++++++++++++++----- src/resources/emoji.rs | 42 +++++++-------- src/resources/links.rs | 29 ++++++++--- src/resources/public.rs | 6 +-- src/resources/stats.rs | 6 +-- tests/transport.rs | 54 +++++++++++++++++++ 9 files changed, 213 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00b3ddf..a63b7bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,8 +146,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] diff --git a/Cargo.toml b/Cargo.toml index 5fcbbd8..c2222cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,6 @@ include = [ "/Cargo.toml", "/LICENSE", "/README.md", - "/openapi.json", ] keywords = ["spoo", "url-shortener", "api", "sdk", "async"] categories = ["api-bindings", "web-programming::http-client"] @@ -34,7 +33,7 @@ reqwest = { version = "0.13", default-features = false, features = ["json", "str serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" -chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } +chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "wasmbind"] } futures-core = { version = "0.3", optional = true } bytes = { version = "1", optional = true } sha2 = { version = "0.10", optional = true } diff --git a/src/client.rs b/src/client.rs index 79f893a..66d6c88 100644 --- a/src/client.rs +++ b/src/client.rs @@ -223,11 +223,16 @@ impl ClientBuilder { /// Build the client, validating configuration. pub fn build(self) -> Result { if let Some(url) = &self.base_url { - if !url.starts_with("http://") && !url.starts_with("https://") { + let parsed = reqwest::Url::parse(url) + .map_err(|e| Error::Config(format!("base_url {url:?} is not a valid URL: {e}")))?; + if !matches!(parsed.scheme(), "http" | "https") { return Err(Error::Config(format!( - "base_url must start with http:// or https://, got {url:?}" + "base_url must use http or https, got {url:?}" ))); } + if parsed.host_str().is_none() { + return Err(Error::Config(format!("base_url {url:?} has no host"))); + } } Ok(self.build_unchecked()) } @@ -255,7 +260,8 @@ impl ClientBuilder { .trim_end_matches('/') .to_owned(); let transport = Transport { - http: self.http_client.unwrap_or_default(), + injected_http: self.http_client, + lazy_http: std::sync::OnceLock::new(), base_url, auth, client_tag: self @@ -284,6 +290,9 @@ mod tests { #[test] fn builder_rejects_bad_base_url() { assert!(Client::builder().base_url("spoo.me").build().is_err()); + assert!(Client::builder().base_url("http://").build().is_err()); + assert!(Client::builder().base_url("https://?").build().is_err()); + assert!(Client::builder().base_url("ftp://spoo.me").build().is_err()); assert!( Client::builder() .base_url("https://spoo.me/") diff --git a/src/http.rs b/src/http.rs index a785d2d..13e967c 100644 --- a/src/http.rs +++ b/src/http.rs @@ -20,7 +20,12 @@ pub(crate) enum Auth { } pub(crate) struct Transport { - pub(crate) http: reqwest::Client, + /// A caller-injected `reqwest::Client`, when one was provided. + pub(crate) injected_http: Option, + /// Otherwise the client is built lazily on first use, because + /// `reqwest::Client::new` panics when TLS or resolver setup fails and + /// this crate's constructors promise not to. + pub(crate) lazy_http: std::sync::OnceLock>, /// Base URL with no trailing slash, e.g. `https://spoo.me`. pub(crate) base_url: String, pub(crate) auth: Auth, @@ -37,6 +42,7 @@ pub(crate) struct RequestSpec { pub(crate) method: Method, pub(crate) path: String, pub(crate) query: Vec<(String, String)>, + pub(crate) headers: Vec<(String, String)>, pub(crate) body: Option, } @@ -46,6 +52,7 @@ impl RequestSpec { method, path: path.into(), query: Vec::new(), + headers: Vec::new(), body: None, } } @@ -57,6 +64,11 @@ impl RequestSpec { self } + pub(crate) fn header(mut self, name: &str, value: impl Into) -> Self { + self.headers.push((name.to_owned(), value.into())); + self + } + pub(crate) fn json(mut self, body: &impl serde::Serialize) -> Result { self.body = Some(serde_json::to_value(body).map_err(Error::Decode)?); Ok(self) @@ -64,6 +76,22 @@ impl RequestSpec { } impl Transport { + /// The HTTP client: the injected one, or a lazily built default. Build + /// failure surfaces as [`Error::Config`] instead of reqwest's panic. + pub(crate) fn http(&self) -> Result<&reqwest::Client, Error> { + if let Some(client) = &self.injected_http { + return Ok(client); + } + self.lazy_http + .get_or_init(|| { + reqwest::Client::builder() + .build() + .map_err(|e| e.to_string()) + }) + .as_ref() + .map_err(|e| Error::Config(format!("failed to initialize the HTTP client: {e}"))) + } + /// Send the request and decode a JSON response body. pub(crate) async fn execute(&self, spec: RequestSpec) -> Result { let response = self.send(spec).await?; @@ -80,7 +108,11 @@ impl Transport { &self, spec: RequestSpec, ) -> Result { - let response = self.dispatch(&spec, None).await.map_err(Error::Transport)?; + let http = self.http()?; + let response = self + .dispatch(http, &spec, None) + .await + .map_err(Error::Transport)?; if !response.status().is_success() { return Err(Error::Api(Box::new(map_error(response).await))); } @@ -89,7 +121,10 @@ impl Transport { } /// Send the request and return the raw successful response (exports). + /// 304 Not Modified counts as success so conditional requests (the emoji + /// catalogue's ETag revalidation) can observe it. pub(crate) async fn send(&self, spec: RequestSpec) -> Result { + let http = self.http()?; let mut attempt: u32 = 0; #[cfg(feature = "oauth")] let mut refreshed = false; @@ -108,9 +143,12 @@ impl Transport { Some(token) } }; - let result = self.dispatch(&spec, bearer.as_deref()).await; + let result = self.dispatch(http, &spec, bearer.as_deref()).await; match result { - Ok(response) if response.status().is_success() => { + Ok(response) + if response.status().is_success() + || response.status() == StatusCode::NOT_MODIFIED => + { #[cfg(feature = "tracing")] tracing::debug!( method = %spec.method, @@ -133,11 +171,17 @@ impl Transport { continue; } } + // A server-mandated wait beyond the ceiling is not worth + // blocking for: surface the response as the error, with + // the full wait readable on it. + let retry_after = retry_after_header(&response); + let waitable = retry_after.is_none_or(|wait| wait <= MAX_RETRY_AFTER); if attempt < self.max_retries + && waitable && retryable_status(&spec.method, response.status()) { attempt += 1; - let wait = backoff_delay(attempt, retry_after_header(&response)); + let wait = backoff_delay(attempt, retry_after); #[cfg(feature = "tracing")] tracing::debug!( path = %spec.path, @@ -174,12 +218,12 @@ impl Transport { async fn dispatch( &self, + http: &reqwest::Client, spec: &RequestSpec, bearer: Option<&str>, ) -> Result { let url = format!("{}{}", self.base_url, spec.path); - let mut request = self - .http + let mut request = http .request(spec.method.clone(), url) .header("X-Spoo-Client", &self.client_tag); #[cfg(not(target_arch = "wasm32"))] @@ -189,6 +233,9 @@ impl Transport { if !spec.query.is_empty() { request = request.query(&spec.query); } + for (name, value) in &spec.headers { + request = request.header(name, value); + } if let Some(token) = bearer { request = request.bearer_auth(token); } @@ -239,6 +286,12 @@ fn idempotent(method: &Method) -> bool { ) } +/// The longest server-mandated wait the client will sit through. A +/// `Retry-After` beyond this is not worth blocking a task for: the 429/503 +/// surfaces instead, with the full wait readable on the error, and the +/// caller decides how to schedule it. +const MAX_RETRY_AFTER: Duration = Duration::from_secs(60); + fn retry_after_header(response: &Response) -> Option { response .headers() @@ -248,19 +301,20 @@ fn retry_after_header(response: &Response) -> Option { } /// Jittered exponential backoff: 0.5s, 1s, 2s, ... capped at 8s, scaled to -/// 50-100% of the base. A server-sent `Retry-After` overrides the computed -/// wait. +/// 50-100% of the base. A server-sent `Retry-After` (already bounded by +/// [`MAX_RETRY_AFTER`] in the retry decision) overrides the computed wait. fn backoff_delay(attempt: u32, retry_after: Option) -> Duration { if let Some(wait) = retry_after { return wait; } let exp = attempt.saturating_sub(1).min(16); let base_ms = (500u64.saturating_mul(1u64 << exp)).min(8_000); - // Cheap jitter without a rand dependency: sub-millisecond clock noise. - let noise = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| u64::from(d.subsec_nanos())) - .unwrap_or(0); + // Cheap jitter without a rand dependency (rand is oauth-gated): a + // process-lifetime counter stepped by a large odd constant spreads + // concurrent retries deterministically and never touches the clock, + // which wasm targets cannot read through std. + static JITTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let noise = JITTER.fetch_add(0x9E37_79B9_7F4A_7C15, std::sync::atomic::Ordering::Relaxed); let jittered = base_ms / 2 + noise % (base_ms / 2 + 1); Duration::from_millis(jittered) } @@ -347,6 +401,26 @@ fn parse_rate_limit(response: &Response) -> RateLimit { } } +/// Percent-encode a caller-supplied path segment (the RFC 3986 unreserved +/// set stays literal). Keeps ids, aliases and domains from rewriting the +/// request target with `/`, `?` or `#`. +pub(crate) fn encode_segment(segment: &str) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(segment.len()); + for byte in segment.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(char::from(byte)); + } + _ => { + // Writing to a String cannot fail. + let _ = write!(out, "%{byte:02X}"); + } + } + } + out +} + /// Reduce a server-suggested filename to a safe bare filename. /// /// Wire-supplied paths are untrusted: consumers hand this value to @@ -455,6 +529,16 @@ mod tests { assert_eq!(content_disposition_filename(None, f), f); } + #[test] + fn path_segments_encode_target_rewriting_characters() { + assert_eq!(encode_segment("abc-DEF_1.2~"), "abc-DEF_1.2~"); + assert_eq!(encode_segment("a/b"), "a%2Fb"); + assert_eq!(encode_segment("a?x=1"), "a%3Fx%3D1"); + assert_eq!(encode_segment("a#b"), "a%23b"); + assert_eq!(encode_segment("../x"), "..%2Fx"); + assert_eq!(encode_segment("🚀"), "%F0%9F%9A%80"); + } + #[test] fn backoff_caps_at_eight_seconds() { for attempt in 1..=10 { diff --git a/src/resources/emoji.rs b/src/resources/emoji.rs index 8b3344d..a472c00 100644 --- a/src/resources/emoji.rs +++ b/src/resources/emoji.rs @@ -78,33 +78,29 @@ impl Emoji { } } + /// Conditional revalidation through the normal transport, so auth, + /// retries, timeout and error mapping all still apply; the transport + /// treats 304 as success on purpose. async fn fetch_with_etag(&self, etag: &str) -> Result { - let url = format!("{}/api/v1/emoji-set", self.client.transport.base_url); - let response = self - .client - .transport - .http - .get(url) - .header("X-Spoo-Client", &self.client.transport.client_tag) - .header("If-None-Match", etag) - .send() - .await - .map_err(Error::Transport)?; + let spec = RequestSpec::new(Method::GET, "/api/v1/emoji-set").header("If-None-Match", etag); + let response = self.client.transport.send(spec).await?; if response.status() == reqwest::StatusCode::NOT_MODIFIED { - let cache = lock(&self.client.emoji_cache); - if let Some(cached) = cache.as_ref() { - return Ok(cached.set.clone()); + { + let cache = lock(&self.client.emoji_cache); + if let Some(cached) = cache.as_ref() { + return Ok(cached.set.clone()); + } } + // The cache vanished between requests: a 304 has no body, so + // refetch unconditionally. + let fresh = self + .client + .transport + .send(RequestSpec::new(Method::GET, "/api/v1/emoji-set")) + .await?; + return self.store(fresh).await; } - if response.status().is_success() { - return self.store(response).await; - } - // An error on a cache revalidation: fall back to the full path so - // retries and error mapping apply. - self.client - .transport - .execute(RequestSpec::new(Method::GET, "/api/v1/emoji-set")) - .await + self.store(response).await } async fn store(&self, response: reqwest::Response) -> Result { diff --git a/src/resources/links.rs b/src/resources/links.rs index 1da8b12..c5026a0 100644 --- a/src/resources/links.rs +++ b/src/resources/links.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::client::Client; use crate::error::Error; -use crate::http::RequestSpec; +use crate::http::{RequestSpec, encode_segment}; use crate::page::Page; use crate::patch::Patch; @@ -486,7 +486,10 @@ impl Links { pub async fn get(&self, id: &str) -> Result { self.client .transport - .execute(RequestSpec::new(Method::GET, format!("/api/v1/urls/{id}"))) + .execute(RequestSpec::new( + Method::GET, + format!("/api/v1/urls/{}", encode_segment(id)), + )) .await } @@ -497,7 +500,11 @@ impl Links { .transport .execute(RequestSpec::new( Method::GET, - format!("/api/v1/urls/{domain}/{alias}"), + format!( + "/api/v1/urls/{}/{}", + encode_segment(domain), + encode_segment(alias) + ), )) .await } @@ -514,8 +521,11 @@ impl Links { /// Enable or disable a link's redirects. pub async fn set_status(&self, id: &str, status: SettableStatus) -> Result { - let spec = RequestSpec::new(Method::PATCH, format!("/api/v1/urls/{id}/status")) - .json(&serde_json::json!({ "status": status }))?; + let spec = RequestSpec::new( + Method::PATCH, + format!("/api/v1/urls/{}/status", encode_segment(id)), + ) + .json(&serde_json::json!({ "status": status }))?; self.client.transport.execute(spec).await } @@ -525,7 +535,7 @@ impl Links { .transport .execute(RequestSpec::new( Method::DELETE, - format!("/api/v1/urls/{id}"), + format!("/api/v1/urls/{}", encode_segment(id)), )) .await } @@ -1107,8 +1117,11 @@ impl UpdateLinkBuilder { /// Apply the update. pub async fn send(self) -> Result { - let spec = RequestSpec::new(Method::PATCH, format!("/api/v1/urls/{}", self.id)) - .json(&self.body)?; + let spec = RequestSpec::new( + Method::PATCH, + format!("/api/v1/urls/{}", encode_segment(&self.id)), + ) + .json(&self.body)?; self.client.transport.execute(spec).await } } diff --git a/src/resources/public.rs b/src/resources/public.rs index aecc3f8..d15a14e 100644 --- a/src/resources/public.rs +++ b/src/resources/public.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use crate::client::Client; use crate::error::Error; -use crate::http::RequestSpec; +use crate::http::{RequestSpec, encode_segment}; /// Public link surfaces, from [`crate::Client::public`]. pub struct Public { @@ -164,7 +164,7 @@ impl Public { .transport .execute(RequestSpec::new( Method::GET, - format!("/api/v1/public/preview/{short_code}"), + format!("/api/v1/public/preview/{}", encode_segment(short_code)), )) .await } @@ -188,7 +188,7 @@ impl PublicStatsBuilder { /// Fetch the stats page data. Sends a plain GET, or a POST carrying the /// password when one was provided: one method, both wire forms. pub async fn send(self) -> Result { - let path = format!("/api/v1/public/stats/{}", self.short_code); + let path = format!("/api/v1/public/stats/{}", encode_segment(&self.short_code)); let spec = match self.password { Some(password) => RequestSpec::new(Method::POST, path) .json(&serde_json::json!({ "password": password }))?, diff --git a/src/resources/stats.rs b/src/resources/stats.rs index 284b4ec..423c1c7 100644 --- a/src/resources/stats.rs +++ b/src/resources/stats.rs @@ -8,7 +8,7 @@ use serde::Deserialize; use crate::client::Client; use crate::error::Error; -use crate::http::{RequestSpec, content_disposition_filename}; +use crate::http::{RequestSpec, content_disposition_filename, encode_segment}; /// Statistics and exports, from [`crate::Client::stats`]. pub struct Stats { @@ -363,7 +363,7 @@ impl Stats { pub fn export_link(&self, url_id: impl Into) -> ExportBuilder { ExportBuilder { client: self.client.clone(), - path: format!("/api/v1/export/links/{}", url_id.into()), + path: format!("/api/v1/export/links/{}", encode_segment(&url_id.into())), format: None, core: QueryCore::default(), } @@ -528,7 +528,7 @@ impl LinkStatsBuilder { pub async fn send(self) -> Result { let spec = self.core.apply(RequestSpec::new( Method::GET, - format!("/api/v1/stats/links/{}", self.url_id), + format!("/api/v1/stats/links/{}", encode_segment(&self.url_id)), ))?; self.client.transport.execute(spec).await } diff --git a/tests/transport.rs b/tests/transport.rs index ec61906..08febde 100644 --- a/tests/transport.rs +++ b/tests/transport.rs @@ -120,6 +120,60 @@ async fn post_retries_429_honoring_retry_after() { ); } +#[tokio::test] +async fn retry_after_beyond_the_ceiling_surfaces_instead_of_waiting() { + let (server, client) = common::server_and_client().await; + Mock::given(method("GET")) + .and(path("/api/v1/urls/x")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("retry-after", "86400") + .set_body_json(json!({"error": "slow down", "code": "rate_limit_exceeded"})), + ) + .expect(1) + .mount(&server) + .await; + + let started = std::time::Instant::now(); + let err = client + .links() + .get("x") + .await + .expect_err("must not park the task for a day"); + assert!(err.is_rate_limited()); + assert_eq!( + err.retry_after(), + Some(Duration::from_secs(86400)), + "the full mandated wait stays readable on the error" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "the client waited instead of surfacing: {:?}", + started.elapsed() + ); +} + +#[tokio::test] +async fn caller_supplied_segments_cannot_rewrite_the_target() { + let (server, client) = common::server_and_client().await; + // Nothing is mounted for /api/v1/urls/x: a raw "x?y" id would hit that + // path with a query. The encoded segment must arrive as one segment. + Mock::given(method("GET")) + .and(path("/api/v1/urls/x%3Fy")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"id": "x?y", "password_set": false})), + ) + .expect(1) + .mount(&server) + .await; + + client + .links() + .get("x?y") + .await + .expect("encoded segment reaches the encoded route"); +} + #[tokio::test] async fn envelope_error_maps_fields_and_rate_limit() { let (server, client) = common::server_and_client().await; From 8ca8bfbb9d1ebb61eef5e1341d3eee6991c4ff3b Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 02:15:52 +0530 Subject: [PATCH 12/13] fix: redact tokens and unblock the session lock Debug on TokenPair and DeviceTokens prints [redacted] instead of credentials. The on_refresh hook now runs after the session lock is released, so a slow persistence write cannot stall concurrent requests sharing the session. --- src/oauth.rs | 63 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/src/oauth.rs b/src/oauth.rs index 221092a..e4bbe90 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -61,7 +61,10 @@ fn random_string(len: usize) -> String { /// An access/refresh token pair. Refresh tokens rotate: after a refresh the /// pair you held before is dead. -#[derive(Debug, Clone, Deserialize)] +/// +/// `Debug` deliberately redacts both tokens: a refresh token in a log line +/// is a long-lived credential leak. +#[derive(Clone, Deserialize)] #[non_exhaustive] pub struct TokenPair { /// JWT access token. @@ -80,8 +83,19 @@ impl TokenPair { } } +impl std::fmt::Debug for TokenPair { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TokenPair") + .field("access_token", &"[redacted]") + .field("refresh_token", &"[redacted]") + .finish() + } +} + /// The result of a device-code exchange: tokens plus the signed-in user. -#[derive(Debug, Clone, Deserialize)] +/// +/// `Debug` deliberately redacts both tokens. +#[derive(Clone, Deserialize)] #[non_exhaustive] pub struct DeviceTokens { /// JWT access token. @@ -99,6 +113,16 @@ impl DeviceTokens { } } +impl std::fmt::Debug for DeviceTokens { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DeviceTokens") + .field("access_token", &"[redacted]") + .field("refresh_token", &"[redacted]") + .field("user", &self.user) + .finish() + } +} + /// Sign in with Spoo protocol calls, from [`crate::Client::oauth`]. pub struct OAuth { pub(crate) client: Client, @@ -239,7 +263,9 @@ impl Session { } /// Called after every successful refresh with the rotated pair. - /// Persist it there: the previous refresh token is already dead. + /// Persist it there: the previous refresh token is already dead. The + /// hook runs on the async executor, so keep it quick; move slow writes + /// onto a blocking task. pub fn on_refresh(mut self, hook: impl Fn(&TokenPair) + Send + Sync + 'static) -> Session { self.on_refresh = Some(Box::new(hook)); self @@ -266,10 +292,19 @@ impl Session { let stale = state .expires_at .is_some_and(|exp| utc_now_secs() + self.expiry_skew.as_secs() as i64 >= exp); - if stale { - self.rotate_locked(&mut state, transport).await?; + let rotated = if stale { + Some(self.rotate_locked(&mut state, transport).await?) + } else { + None + }; + let result = (state.tokens.access_token.clone(), state.generation); + // The hook runs outside the lock: a synchronous persistence write in + // it must not stall every other request sharing this session. + drop(state); + if let Some(pair) = rotated { + self.notify(&pair); } - Ok((state.tokens.access_token.clone(), state.generation)) + Ok(result) } /// Refresh after a 401, unless another task already rotated past the @@ -283,22 +318,28 @@ impl Session { if state.generation != seen_generation { return Ok(()); } - self.rotate_locked(&mut state, transport).await + let pair = self.rotate_locked(&mut state, transport).await?; + drop(state); + self.notify(&pair); + Ok(()) } async fn rotate_locked( &self, state: &mut SessionState, transport: &Transport, - ) -> Result<(), Error> { + ) -> Result { let pair = refresh_call(transport, &state.tokens.refresh_token).await?; state.expires_at = decode_exp(&pair.access_token); - state.tokens = pair; + state.tokens = pair.clone(); state.generation += 1; + Ok(pair) + } + + fn notify(&self, pair: &TokenPair) { if let Some(hook) = &self.on_refresh { - hook(&state.tokens); + hook(pair); } - Ok(()) } } From d1852c8353ac72771c8811763aebe759b26e9451 Mon Sep 17 00:00:00 2001 From: zingzy Date: Thu, 20 Aug 2026 02:15:52 +0530 Subject: [PATCH 13/13] docs: review follow-ups The oauth example and README verify the echoed state before exchanging the code. The install section lists the companion crates snippets use, the export filename claim is scoped to path traversal, the wasm bullet says compiles-for rather than supported, and openapi.json no longer ships in the published package. --- README.md | 20 ++++++++++++++------ examples/sign_in_with_spoo.rs | 26 ++++++++++++++++++-------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c825f8f..5f2de4a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ println!("{}", link.short_url); // https://spoo.me/launch # } ``` -- Async-first on `reqwest` with rustls, wasm32 supported +- Async-first on `reqwest` with rustls; compiles for wasm32 (checked in CI) - Typed errors, automatic retries, streaming pagination and exports - Timestamps in and out as `chrono::DateTime`, whatever the wire format - Anonymous, API key, and Sign in with Spoo authentication @@ -30,7 +30,11 @@ println!("{}", link.short_url); // https://spoo.me/launch cargo add spoo-me ``` -Requires Rust 1.85 or newer. +Requires Rust 1.85 or newer. The command installs the SDK only; snippets on +this page also use `chrono` (timestamps in the public types), and some use +`reqwest` (client injection), `futures-util` (stream combinators) or `serde` +with the `derive` feature (escape-hatch response types). Add the ones your +code names. ## Authentication @@ -166,8 +170,9 @@ println!("{} clicks", report.summary.total_clicks); let per_link = client.stats().for_link("665f0c2f9e7a4b1d2c3d4e5f").send().await?; -// Exports stream; filenames from the server are reduced to a safe bare -// name before you see them, so joining into a directory is safe. +// Exports stream; filenames from the server are reduced to a bare name +// (no separators or dot-segments), so joining one into a directory cannot +// traverse out of it. Choosing a safe directory remains your job. let export = client .stats() .export_link("665f0c2f9e7a4b1d2c3d4e5f") @@ -239,12 +244,15 @@ use spoo_me::oauth::{generate_pkce_pair, generate_state, Session, TokenPair}; let anon = spoo_me::Client::anonymous(); let pkce = generate_pkce_pair(); +let state = generate_state(); let url = anon .oauth() - .authorization_url("your_app_id", generate_state(), &pkce.challenge) + .authorization_url("your_app_id", &state, &pkce.challenge) .redirect_uri("http://127.0.0.1:8000/callback") .build()?; -// Open `url` in a browser; your callback receives a one-time code. +// Open `url` in a browser; your callback receives `code` and `state`. +// Verify the echoed state matches the one you sent BEFORE exchanging the +// code, and reject the flow on a mismatch (CSRF protection). let tokens = anon.oauth().exchange_code("the-code", pkce.verifier).await?; diff --git a/examples/sign_in_with_spoo.rs b/examples/sign_in_with_spoo.rs index fb45b36..dea608f 100644 --- a/examples/sign_in_with_spoo.rs +++ b/examples/sign_in_with_spoo.rs @@ -7,25 +7,35 @@ use std::sync::Arc; use spoo_me::oauth::{Session, generate_pkce_pair, generate_state}; +fn prompt(label: &str) -> Result { + println!("{label}"); + let mut value = String::new(); + std::io::stdin().read_line(&mut value)?; + Ok(value.trim().to_owned()) +} + #[tokio::main] async fn main() -> Result<(), Box> { let anon = spoo_me::Client::anonymous(); let pkce = generate_pkce_pair(); + let state = generate_state(); let url = anon .oauth() - .authorization_url("your_app_id", generate_state(), &pkce.challenge) + .authorization_url("your_app_id", &state, &pkce.challenge) .redirect_uri("http://127.0.0.1:8000/callback") .build()?; println!("open this in a browser:\n {url}"); - println!("then paste the one-time code from your callback:"); - let mut code = String::new(); - std::io::stdin().read_line(&mut code)?; - let tokens = anon - .oauth() - .exchange_code(code.trim(), pkce.verifier) - .await?; + // The callback URL carries `code` and `state`. Verify the echoed state + // against the one sent before exchanging the code: a mismatch means the + // code was not produced by this flow, so it must be rejected (CSRF). + let returned_state = prompt("paste the state parameter from your callback URL:")?; + if returned_state != state { + return Err("state mismatch: aborting the sign-in".into()); + } + let code = prompt("paste the one-time code:")?; + let tokens = anon.oauth().exchange_code(code, pkce.verifier).await?; println!("signed in as {:?}", tokens.user.email); // The session refreshes itself; persist rotated pairs in on_refresh.