Skip to content

Commit d7b21cf

Browse files
authored
Merge pull request #291 from spoo-me/feat/stats-links-endpoints
feat(stats): per-link stats and export endpoints, url_id filter
2 parents 85b47fd + 14720a2 commit d7b21cf

14 files changed

Lines changed: 15477 additions & 120 deletions

File tree

openapi.json

Lines changed: 14210 additions & 0 deletions
Large diffs are not rendered by default.

routes/api_v1/exports.py

Lines changed: 94 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,67 @@
11
"""
2-
GET /api/v1/export — export URL stats as CSV, XLSX, JSON, or XML.
2+
GET /api/v1/export — export URL stats as CSV, XLSX, JSON, or XML.
3+
GET /api/v1/export/links/{id} — export twin of the per-link stats endpoint.
34
45
Auth is optional for scope=anon (public stats); scope=all requires auth.
6+
The per-link endpoint always requires auth — you must own the URL.
57
API key users require ``stats:read``, ``urls:read``, or ``admin:all``.
68
"""
79

810
from __future__ import annotations
911

1012
from typing import Annotated
1113

12-
from fastapi import APIRouter, Depends, Query, Request
14+
from fastapi import APIRouter, Depends, Path, Query, Request
1315
from fastapi.responses import Response
1416

1517
from dependencies import (
1618
STATS_SCOPES,
1719
CurrentUser,
1820
ExportSvc,
21+
UrlSvc,
1922
optional_scopes,
23+
require_scopes,
2024
)
2125
from middleware.openapi import EXPORT_RESPONSES, OPTIONAL_AUTH_SECURITY
2226
from middleware.rate_limiter import Limits, dynamic_limit, limiter
23-
from schemas.dto.requests.stats import ExportQuery
27+
from routes.api_v1._helpers import parse_url_id
28+
from schemas.dto.requests.stats import ExportQuery, LinkExportQuery
2429

2530
router = APIRouter(tags=["Statistics"])
2631

2732
_export_limit, _export_key = dynamic_limit(
2833
Limits.API_EXPORT_AUTHED, Limits.API_EXPORT_ANON
2934
)
3035

31-
32-
@router.get(
33-
"/export",
34-
responses={
35-
**EXPORT_RESPONSES,
36-
200: {
37-
"description": "Export file download",
38-
"content": {
39-
"application/json": {
40-
"schema": {"type": "string", "format": "binary"},
41-
},
42-
"application/xml": {
43-
"schema": {"type": "string", "format": "binary"},
44-
},
45-
"application/zip": {
46-
"schema": {"type": "string", "format": "binary"},
47-
"x-description": "CSV export — ZIP archive containing summary.csv plus one file per dimension",
48-
},
49-
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
50-
"schema": {"type": "string", "format": "binary"},
51-
"x-description": "XLSX export — Excel workbook with multiple sheets",
52-
},
36+
# Shared 200 documentation for both export routes — the download body is
37+
# format-dependent, never JSON-schema'd.
38+
_EXPORT_200_RESPONSES = {
39+
**EXPORT_RESPONSES,
40+
200: {
41+
"description": "Export file download",
42+
"content": {
43+
"application/json": {
44+
"schema": {"type": "string", "format": "binary"},
45+
},
46+
"application/xml": {
47+
"schema": {"type": "string", "format": "binary"},
48+
},
49+
"application/zip": {
50+
"schema": {"type": "string", "format": "binary"},
51+
"x-description": "CSV export — ZIP archive containing summary.csv plus one file per dimension",
52+
},
53+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
54+
"schema": {"type": "string", "format": "binary"},
55+
"x-description": "XLSX export — Excel workbook with multiple sheets",
5356
},
5457
},
5558
},
59+
}
60+
61+
62+
@router.get(
63+
"/export",
64+
responses=_EXPORT_200_RESPONSES,
5665
openapi_extra=OPTIONAL_AUTH_SECURITY,
5766
operation_id="exportStats",
5867
summary="Export Statistics",
@@ -86,6 +95,10 @@ async def export_v1(
8695
- `xlsx` — Excel spreadsheet with multiple sheets
8796
- `csv` — **ZIP archive** containing `summary.csv` plus one CSV file per metrics dimension
8897
98+
**Filtering**: Same as `GET /stats` — including the `url_id` filter to
99+
slice the export to specific URLs you own. To export a single link,
100+
prefer `GET /export/links/{url_id}`.
101+
89102
**Note**: Export generation is resource-intensive. Lower rate limits apply
90103
compared to other endpoints.
91104
"""
@@ -96,3 +109,59 @@ async def export_v1(
96109
media_type=result.mimetype,
97110
headers={"Content-Disposition": f'attachment; filename="{result.filename}"'},
98111
)
112+
113+
114+
@router.get(
115+
"/export/links/{url_id}",
116+
responses=_EXPORT_200_RESPONSES,
117+
operation_id="exportLinkStats",
118+
summary="Export Link Statistics",
119+
)
120+
@limiter.limit(Limits.API_EXPORT_AUTHED)
121+
async def export_link_v1(
122+
request: Request,
123+
url_id: Annotated[
124+
str,
125+
Path(description="Unique identifier of the URL (MongoDB ObjectId)."),
126+
],
127+
query: Annotated[LinkExportQuery, Query()],
128+
export_service: ExportSvc,
129+
url_service: UrlSvc,
130+
user: CurrentUser = Depends(require_scopes(STATS_SCOPES)), # noqa: B008
131+
) -> Response:
132+
"""Export click statistics for a single URL you own.
133+
134+
The export twin of `GET /stats/links/{url_id}` — the same formats as
135+
`GET /export`, pre-scoped to one link. The suggested filename carries
136+
the link's alias.
137+
138+
**Authentication**: Required — you must own the URL.
139+
140+
**API Key Scope**: `stats:read`, `urls:read`, or `admin:all`
141+
142+
**Rate Limits**: 30/min, 1,000/day
143+
144+
**Export Formats**:
145+
146+
- `json` — single JSON file
147+
- `xml` — single XML file
148+
- `xlsx` — Excel spreadsheet with multiple sheets
149+
- `csv` — **ZIP archive** containing `summary.csv` plus one CSV file per metrics dimension
150+
151+
**Errors**:
152+
153+
- `400` — malformed id (not a valid ObjectId)
154+
- `404` — no URL with that id in your account. A URL owned by someone
155+
else answers identically; this endpoint never confirms foreign ids.
156+
157+
**Note**: Export generation is resource-intensive. Lower rate limits apply
158+
compared to other endpoints.
159+
"""
160+
oid = parse_url_id(url_id)
161+
url = await url_service.get_owned(oid, user.user_id)
162+
result = await export_service.export_link(query, url)
163+
return Response(
164+
content=result.content,
165+
media_type=result.mimetype,
166+
headers={"Content-Disposition": f'attachment; filename="{result.filename}"'},
167+
)

routes/api_v1/stats.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,36 @@
11
"""
2-
GET /api/v1/stats — URL click statistics.
2+
GET /api/v1/stats — URL click statistics (account aggregate).
3+
GET /api/v1/stats/links/{id} — click statistics for one owned URL.
34
45
Auth is optional for scope=anon (public stats); scope=all requires auth.
6+
The per-link endpoint always requires auth — you must own the URL.
57
API key users require ``stats:read``, ``urls:read``, or ``admin:all``.
8+
9+
Route-ordering note: the two paths differ in segment count, so neither can
10+
shadow the other. ``links`` is a typed segment — future siblings
11+
(``/stats/domains/{fqdn}``, ``/stats/groups/{id}``) can land without
12+
route-shadowing games.
613
"""
714

815
from __future__ import annotations
916

1017
from typing import Annotated
1118

12-
from fastapi import APIRouter, Depends, Query, Request
19+
from fastapi import APIRouter, Depends, Path, Query, Request
1320

1421
from dependencies import (
1522
STATS_SCOPES,
1623
CurrentUser,
1724
StatsSvc,
25+
UrlSvc,
1826
optional_scopes,
27+
require_scopes,
1928
)
2029
from middleware.openapi import ERROR_RESPONSES, OPTIONAL_AUTH_SECURITY
2130
from middleware.rate_limiter import Limits, dynamic_limit, limiter
22-
from schemas.dto.requests.stats import StatsQuery
23-
from schemas.dto.responses.stats import StatsResponse
31+
from routes.api_v1._helpers import parse_url_id
32+
from schemas.dto.requests.stats import LinkStatsQuery, StatsQuery
33+
from schemas.dto.responses.stats import LinkStatsResponse, StatsResponse
2434

2535
router = APIRouter(tags=["Statistics"])
2636

@@ -67,8 +77,64 @@ async def stats_v1(
6777
**Metrics**: `clicks`, `unique_clicks`
6878
6979
**Filtering**: Filter by `browser`, `os`, `country`, `city`, `referrer`,
70-
or `short_code` using query params or a JSON `filters` object.
80+
`short_code`, or `url_id` using query params or a JSON `filters` object.
81+
Filters slice your own aggregate — `url_id` values you do not own simply
82+
match nothing. For statistics on a single link, prefer
83+
`GET /stats/links/{url_id}`.
7184
"""
7285
owner_id = str(user.user_id) if user is not None else None
7386
result = await stats_service.query(query, owner_id)
7487
return StatsResponse.model_validate(result)
88+
89+
90+
@router.get(
91+
"/stats/links/{url_id}",
92+
responses=ERROR_RESPONSES,
93+
operation_id="getLinkStats",
94+
summary="Link Statistics",
95+
)
96+
@limiter.limit(Limits.API_AUTHED)
97+
async def link_stats_v1(
98+
request: Request,
99+
url_id: Annotated[
100+
str,
101+
Path(description="Unique identifier of the URL (MongoDB ObjectId)."),
102+
],
103+
query: Annotated[LinkStatsQuery, Query()],
104+
stats_service: StatsSvc,
105+
url_service: UrlSvc,
106+
user: CurrentUser = Depends(require_scopes(STATS_SCOPES)), # noqa: B008
107+
) -> LinkStatsResponse:
108+
"""Get click statistics for a single URL you own.
109+
110+
The same aggregated analytics as `GET /stats`, pre-scoped to one link —
111+
the response additionally echoes the link's `url_id` and `alias`.
112+
Custom-domain links are safe here: clicks are matched by URL id, so a
113+
same-alias link on another domain can never bleed in.
114+
115+
**Authentication**: Required — you must own the URL.
116+
117+
**API Key Scope**: `stats:read`, `urls:read`, or `admin:all`
118+
119+
**Rate Limits**: 60/min, 5,000/day
120+
121+
**Grouping Dimensions**: `time`, `browser`, `os`, `device`, `country`,
122+
`city`, `referrer`, `utm_source`, `utm_medium`, `utm_campaign`
123+
124+
**Metrics**: `clicks`, `unique_clicks`
125+
126+
**Filtering**: Filter by `browser`, `os`, `device`, `country`, `city`,
127+
`referrer`, or the `utm_*` tags using query params or a JSON `filters`
128+
object. Link-identity filters (`short_code`, `url_id`) do not exist
129+
here — the path already selects the link.
130+
131+
**Errors**:
132+
133+
- `400` — malformed id (not a valid ObjectId)
134+
- `404` — no URL with that id in your account. A URL owned by someone
135+
else answers identically; this endpoint never confirms foreign ids.
136+
"""
137+
oid = parse_url_id(url_id)
138+
url = await url_service.get_owned(oid, user.user_id)
139+
result = await stats_service.query_link(query, url)
140+
return LinkStatsResponse.model_validate(result)

schemas/dto/requests/_descriptions.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@
2222
"When `scope=all`, this is optional and filters stats to a specific URL."
2323
)
2424

25+
STATS_URL_ID_DESC = (
26+
"Comma-separated URL ids (MongoDB ObjectIds) to filter stats to specific "
27+
"URLs you own. Slices your own aggregate — ids you do not own simply "
28+
"match nothing.\n\n"
29+
"For statistics on a single link, prefer `GET /api/v1/stats/links/{url_id}`."
30+
)
31+
2532
STATS_START_DATE_DESC = (
2633
"Start of time range. Accepts ISO 8601 datetime string "
2734
"(e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds "
@@ -80,6 +87,8 @@
8087
"- `referrer` — Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n"
8188
"- `short_code` — Filter by URL alias (e.g., mylink, promo2024) — "
8289
"**not allowed** with `scope=anon`\n"
90+
"- `url_id` — Filter by URL id (MongoDB ObjectId); ids you do not own "
91+
"match nothing\n"
8392
"- `utm_source` / `utm_medium` / `utm_campaign` — Filter by campaign tags; "
8493
"`(none)` matches untagged clicks\n\n"
8594
"**Value format:** Array of strings for each dimension.\n\n"
@@ -160,6 +169,54 @@
160169
)
161170

162171

172+
# ── LinkStatsQuery / LinkExportQuery ─────────────────────────────────────────
173+
# The per-link endpoints select the link in the path, so the link-identity
174+
# dimensions (`short_code`, `url_id`) disappear from group_by and filters.
175+
176+
LINK_STATS_GROUP_BY_DESC = (
177+
"Comma-separated grouping dimensions for the statistics breakdown. "
178+
"Defaults to `time` if omitted.\n\n"
179+
"**Available dimensions:**\n\n"
180+
"- `time` — group by time buckets (day/week/month, auto-selected based on range)\n"
181+
"- `browser` — group by browser name (e.g., Chrome, Firefox, Safari)\n"
182+
"- `os` — group by operating system (e.g., Windows, macOS, Linux)\n"
183+
"- `device` — group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n"
184+
"- `country` — group by country\n"
185+
"- `city` — group by city\n"
186+
"- `referrer` — group by referrer URL\n"
187+
"- `utm_source` — group by the `utm_source` tag on the short link "
188+
"(untagged clicks appear as `(none)`)\n"
189+
"- `utm_medium` — group by the `utm_medium` tag\n"
190+
"- `utm_campaign` — group by the `utm_campaign` tag\n\n"
191+
"Multiple dimensions can be combined: `time,browser` returns time series "
192+
"broken down by browser."
193+
)
194+
195+
LINK_STATS_FILTERS_DESC = (
196+
"**Method 1: JSON Filters Object**\n\n"
197+
"JSON string containing dimension filters. "
198+
'Format: `{"dimension": ["value1", "value2"]}`\n\n'
199+
"**Available filter dimensions:**\n\n"
200+
"- `browser` — Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n"
201+
"- `os` — Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n"
202+
"- `device` — Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n"
203+
"- `country` — Filter by country name (e.g., United States, Canada, Germany)\n"
204+
"- `city` — Filter by city name (e.g., New York, London, Mumbai)\n"
205+
"- `referrer` — Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n"
206+
"- `utm_source` / `utm_medium` / `utm_campaign` — Filter by campaign tags; "
207+
"`(none)` matches untagged clicks\n\n"
208+
"**Value format:** Array of strings for each dimension.\n\n"
209+
"**Important:** Filter values are case-sensitive. Use exact capitalization "
210+
"as stored in the database.\n\n"
211+
"**Examples:**\n\n"
212+
'- `{"browser": ["Chrome", "Firefox"]}` — Chrome OR Firefox clicks\n'
213+
'- `{"country": ["United States", "Canada"], "browser": ["Chrome"]}` — '
214+
"US/CA clicks from Chrome\n\n"
215+
"**Alternative:** You can also pass filters as individual query parameters "
216+
"(see `browser`, `os`, `country`, `city`, `referrer` parameters below)."
217+
)
218+
219+
163220
# ── ListUrlsQuery ────────────────────────────────────────────────────────────
164221

165222
LIST_URLS_FILTER_DESC = (

0 commit comments

Comments
 (0)