Skip to content

Commit cc504f2

Browse files
committed
feat: the spoo.me Kotlin Multiplatform SDK
Kotlin 2.4 and Ktor 3.5 targeting Android and the JVM, with the full v1 data plane, Sign in with Spoo sessions, typed sealed errors, the shared retry envelope, sanitized export filenames, Flow pagination and a MockEngine wire-level test suite. Publishing lands on Maven Central as me.spoo:spoo via the Central Publisher Portal.
0 parents  commit cc504f2

42 files changed

Lines changed: 20532 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v7
16+
- uses: actions/setup-java@v5
17+
with:
18+
distribution: temurin
19+
java-version: 21
20+
- uses: gradle/actions/setup-gradle@v5
21+
- name: Build, test, verify ABI
22+
run: ./gradlew build apiCheck
23+
24+
contract:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v7
28+
- uses: actions/checkout@v7
29+
with:
30+
repository: spoo-me/spoo
31+
path: backend
32+
- name: Start the backend stack
33+
working-directory: backend
34+
run: |
35+
docker compose up -d --wait || docker compose up -d
36+
for i in $(seq 1 60); do
37+
if curl -fsS http://127.0.0.1:8000/health >/dev/null 2>&1; then
38+
echo "backend is up"; exit 0
39+
fi
40+
sleep 2
41+
done
42+
echo "backend never became healthy"
43+
docker compose logs --tail 50
44+
exit 1
45+
- uses: actions/setup-java@v5
46+
with:
47+
distribution: temurin
48+
java-version: 21
49+
- uses: gradle/actions/setup-gradle@v5
50+
- name: Contract tests against the real backend
51+
env:
52+
SPOO_CONTRACT_BASE_URL: http://127.0.0.1:8000
53+
run: ./gradlew jvmTest --tests "me.spoo.ContractTest" --rerun
54+
55+
spec-drift:
56+
runs-on: ubuntu-latest
57+
steps:
58+
- uses: actions/checkout@v7
59+
- name: Compare committed openapi.json with the backend's
60+
run: |
61+
curl -fsSL https://raw.githubusercontent.com/spoo-me/spoo/main/openapi.json -o upstream-openapi.json
62+
diff -q openapi.json upstream-openapi.json

.github/workflows/release.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Release
2+
3+
# Publishes to Maven Central via the Central Publisher Portal when a GitHub
4+
# release is published. The Portal has no OIDC trusted publishing, so this
5+
# is the one spoo SDK registry on long-lived secrets: a Portal user token
6+
# and an in-memory GPG key, as the standard vanniktech plugin secrets.
7+
on:
8+
release:
9+
types: [published]
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
publish:
16+
runs-on: ubuntu-latest
17+
environment: release
18+
steps:
19+
- uses: actions/checkout@v7
20+
- uses: actions/setup-java@v5
21+
with:
22+
distribution: temurin
23+
java-version: 21
24+
- uses: gradle/actions/setup-gradle@v5
25+
- name: Test
26+
run: ./gradlew build apiCheck
27+
- name: Publish to Maven Central
28+
env:
29+
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
30+
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
31+
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }}
32+
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }}
33+
run: ./gradlew publishToMavenCentral --no-configuration-cache

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.gradle/
2+
build/
3+
local.properties
4+
.kotlin/
5+
.idea/
6+
*.iml
7+
.DS_Store

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Changelog
2+
3+
## 0.1.0 (unreleased)
4+
5+
First release: a Kotlin Multiplatform SDK for the spoo.me v1 API, targeting
6+
Android and the JVM.
7+
8+
- Full v1 coverage: shorten (alphanumeric and emoji aliases), alias check,
9+
link management, bulk delete/status/expiry/domain, claiming anonymous
10+
links, account and per-link statistics, streaming exports, public stats
11+
and previews, the emoji catalogue (ETag-cached), identity read.
12+
- Authentication: API keys, anonymous mode, and Sign in with Spoo (PKCE,
13+
code exchange, self-refreshing single-flight sessions with redacting
14+
token types).
15+
- Coroutines-first surface: suspend functions, Flow pagination, coroutine
16+
cancellation always rethrown untouched.
17+
- Tri-state updates: untouched fields keep their stored values, remove*
18+
methods clear a setting explicitly.
19+
- Typed sealed errors with the backend's machine-readable codes,
20+
rate-limit metadata, and the 401 trichotomy (session expired, link
21+
password, plain unauthorized) kept distinguishable.
22+
- Automatic retries with jittered backoff capped at 8 seconds, honoring
23+
both legal Retry-After forms with a 60 second ceiling; POST and PATCH
24+
replay only where the server provably did no work.
25+
- Server-suggested export filenames are sanitized to safe bare names.
26+
- Raw typed passthroughs (get/post/patch/delete) for endpoints the SDK
27+
does not cover yet.
28+
- Consumer R8 rules ship in the artifact; Ktor engine is injectable with
29+
OkHttp as the platform default.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 spoo.me
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
# spoo.me Kotlin SDK
2+
3+
The official Kotlin Multiplatform SDK for the [spoo.me](https://spoo.me)
4+
link management API. Android and JVM today; the KMP structure keeps iOS and
5+
JS additive.
6+
7+
```kotlin
8+
val spoo = SpooClient(apiKey = "spoo_your_api_key")
9+
10+
val link = spoo.links.create {
11+
longUrl = "https://example.com/launch"
12+
alias = "launch" // or emoji: "🚀🔥"
13+
maxClicks = 10_000
14+
}
15+
println(link.shortUrl) // https://spoo.me/launch
16+
```
17+
18+
- Coroutines-first: suspend functions everywhere, `Flow` pagination
19+
- Typed sealed errors, automatic retries, streaming exports
20+
- Timestamps in and out as `kotlin.time.Instant`, whatever the wire format
21+
- Anonymous, API key, and Sign in with Spoo authentication
22+
- Thin tree: Ktor client and kotlinx.serialization, nothing else
23+
24+
## Install
25+
26+
```kotlin
27+
dependencies {
28+
implementation("me.spoo:spoo:0.1.0")
29+
}
30+
```
31+
32+
Requires Kotlin 2.4+. On Android the minimum SDK is 21; consumer R8 rules
33+
ship in the artifact. The snippets on this page also use
34+
`kotlinx-coroutines` (already a transitive dependency) and, where named,
35+
`kotlinx.serialization` for your own types.
36+
37+
## Authentication
38+
39+
Create an API key from your [spoo.me dashboard](https://spoo.me) and pass
40+
it explicitly:
41+
42+
```kotlin
43+
val spoo = SpooClient(apiKey = "spoo_...")
44+
```
45+
46+
`SpooClient()` with no credentials works too: anonymous shortening and the
47+
public endpoints (stats, previews, the emoji set) need no account.
48+
49+
Self-hosting spoo.me, injecting an engine, or tagging your app:
50+
51+
```kotlin
52+
val spoo = SpooClient(SpooConfig(
53+
apiKey = "spoo_...",
54+
baseUrl = "https://links.example.com",
55+
engine = OkHttp.create(), // shared pools, proxies, tests
56+
clientTag = "my-app/1.0", // X-Spoo-Client override
57+
))
58+
```
59+
60+
The client is safe to share across coroutines. `close()` releases the owned
61+
engine; injected engines stay yours to manage.
62+
63+
## Shorten links
64+
65+
```kotlin
66+
val link = spoo.links.create {
67+
longUrl = "https://example.com/launch"
68+
password = "secure@123"
69+
expireAfter = Clock.System.now() + 30.days
70+
}
71+
```
72+
73+
Anonymous creations return a one-time `claimToken`. Store it and the link
74+
can be claimed into an account later:
75+
76+
```kotlin
77+
spoo.links.claim(listOf(ClaimRequest(urlId = link.id, token = link.claimToken!!)))
78+
```
79+
80+
## Manage links
81+
82+
```kotlin
83+
// Paginated listing with typed filters.
84+
val page = spoo.links.list(ListLinksRequest(
85+
pageSize = 50,
86+
sortBy = SortBy.TOTAL_CLICKS,
87+
status = SettableStatus.ACTIVE,
88+
search = "promo",
89+
))
90+
91+
// Or walk everything lazily.
92+
spoo.links.listPaginated().items().collect { println(it.id) }
93+
94+
// Updates only touch what you set; remove* clears a setting explicitly.
95+
spoo.links.update(link.id) {
96+
longUrl("https://example.com/v2")
97+
removePassword()
98+
}
99+
100+
// Bulk operations report per-item outcomes instead of failing the batch.
101+
val outcome = spoo.links.bulkSetStatus(ids, SettableStatus.INACTIVE)
102+
outcome.results.filterNot { it.ok }.forEach { println("${it.id}: ${it.errorCode}") }
103+
```
104+
105+
## Statistics and exports
106+
107+
```kotlin
108+
val report = spoo.stats.account(AccountStatsRequest(
109+
query = StatsQuery(
110+
groupBy = listOf(Dimension.TIME, Dimension.COUNTRY),
111+
filters = mapOf(FilterDimension.BROWSER to listOf("Chrome")),
112+
),
113+
))
114+
println("${report.summary.totalClicks} clicks")
115+
116+
val perLink = spoo.stats.forLink(link.id)
117+
118+
// Exports stream; filenames from the server are reduced to a bare name
119+
// (no separators or dot-segments), so joining one into a directory cannot
120+
// traverse out of it. Choosing a safe directory remains your job.
121+
val export = spoo.stats.exportLink(link.id, ExportFormat.CSV)
122+
File(downloads, export.filename).writeBytes(export.bytes())
123+
```
124+
125+
Account-wide downloads come from `stats.export()`; per-link downloads with
126+
per-link filenames come from `stats.exportLink(id)`.
127+
128+
## Public endpoints
129+
130+
```kotlin
131+
val anon = SpooClient()
132+
val stats = anon.publicLinks.stats("launch")
133+
val locked = anon.publicLinks.stats("locked", password = "hunter@22")
134+
val preview = anon.publicLinks.preview("launch") // never reveals what the redirect refuses
135+
val emoji = anon.emoji.set() // ETag-cached on the client
136+
```
137+
138+
## Errors
139+
140+
Every failure is a `SpooException`, and coroutine cancellation is always
141+
rethrown untouched. API failures are a sealed hierarchy carrying the
142+
backend's machine-readable code, the offending field, request id and
143+
rate-limit state:
144+
145+
```kotlin
146+
try {
147+
spoo.links.get("gone")
148+
} catch (e: NotFoundException) {
149+
println("no such link")
150+
} catch (e: RateLimitException) {
151+
println("wait ${e.rateLimit.retryAfter}")
152+
} catch (e: ContentBlockedException) {
153+
println("taken down")
154+
} catch (e: AuthenticationException) {
155+
if (e.isPasswordRequired) promptForLinkPassword()
156+
}
157+
```
158+
159+
Transient failures (408, 429, 5xx) retry twice with jittered exponential
160+
backoff capped at 8 seconds, honoring both legal `Retry-After` forms with a
161+
60 second ceiling: a longer mandated wait surfaces immediately with the
162+
full wait readable on the exception. Requests that could duplicate work on
163+
replay (POST, PATCH) retry only where the server provably did nothing
164+
(429, 503). Default timeout is 30 seconds.
165+
166+
## Sign in with Spoo
167+
168+
The client half of the connected-apps flow: PKCE, the code exchange, and a
169+
self-refreshing session.
170+
171+
```kotlin
172+
val anon = SpooClient()
173+
val pkce = generatePkcePair()
174+
val state = generateState()
175+
val url = anon.oauth.authorizationUrl(
176+
appId = "your_app_id",
177+
state = state,
178+
codeChallenge = pkce.challenge,
179+
redirectUri = "https://your.app/callback",
180+
)
181+
// Open url in a browser (Custom Tabs on Android); the callback carries
182+
// code and state. Verify the echoed state matches BEFORE exchanging the
183+
// code, and reject the flow on a mismatch.
184+
185+
val tokens = anon.oauth.exchangeCode(code, pkce.verifier)
186+
187+
val session = Session(tokens.tokens(), onRefresh = { pair ->
188+
// Persist the rotated pair: the previous refresh token is dead.
189+
})
190+
val spoo = SpooClient(session = session)
191+
val me = spoo.auth.me()
192+
```
193+
194+
Sessions refresh proactively before the access token expires and once more
195+
after an unexpected 401. Refreshes are single-flight across coroutines, and
196+
a dead refresh token surfaces as `SessionExpiredException`. Token pairs
197+
redact themselves in `toString()`, so they never leak into logs.
198+
199+
## Scope
200+
201+
This SDK covers the v1 data plane: shortening (including emoji aliases),
202+
link management, bulk operations, claiming, statistics, exports, public
203+
link surfaces, the emoji catalogue, identity read, and Sign in with Spoo.
204+
Account administration (API key management, profile editing), service
205+
endpoints (health, contact), and the legacy v0 API are deliberately out of
206+
scope.
207+
208+
| Area | Methods |
209+
|---|---|
210+
| Shorten | `links.create`, `links.checkAlias` |
211+
| Manage | `links.list`, `get`, `getByAddress`, `update`, `setStatus`, `delete`, `deleteAllOnDomain` |
212+
| Bulk | `bulkDelete`, `bulkSetStatus`, `bulkSetExpiry`, `bulkMoveDomain` |
213+
| Claim | `links.claim` |
214+
| Stats | `stats.account`, `stats.forLink` |
215+
| Export | `stats.export`, `stats.exportLink` |
216+
| Public | `publicLinks.stats`, `publicLinks.preview` |
217+
| Emoji | `emoji.set` |
218+
| Identity | `auth.me` |
219+
| Sign in with Spoo | `oauth.authorizationUrl`, `exchangeCode`, `refreshTokens`, `Session` |
220+
221+
## Raw requests
222+
223+
For v1 endpoints the SDK does not cover yet, typed passthroughs reuse the
224+
client's auth, retries, timeout and error mapping:
225+
226+
```kotlin
227+
@Serializable data class Whatever(val ok: Boolean)
228+
val value: Whatever = spoo.get("/api/v1/new-endpoint", listOf("k" to "v"))
229+
```
230+
231+
These are a supported pressure valve. If you need one, the surface has a
232+
gap worth an issue on this repo.
233+
234+
## License
235+
236+
MIT

0 commit comments

Comments
 (0)