patx/micropie
improved the api functionality in url_shortener example to show micropie able to handle real world apis. added nice http error code templates.
Commit 4bfc43d · patx · 2026-01-04T21:40:29-05:00
Comments
No comments yet.
Diff
diff --git a/examples/url_shortener/Procfile b/examples/url_shortener/Procfile
deleted file mode 100644
index 278ec11..0000000
--- a/examples/url_shortener/Procfile
+++ /dev/null
@@ -1,2 +0,0 @@
-web: uvicorn main:app --host=0.0.0.0 --port=${PORT}
-
diff --git a/examples/url_shortener/main.py b/examples/url_shortener/main.py
index 06ceb43..fff0a99 100644
--- a/examples/url_shortener/main.py
+++ b/examples/url_shortener/main.py
@@ -33,7 +33,7 @@ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from string import ascii_letters, digits
from secrets import choice
-from datetime import datetime
+from datetime import datetime, timedelta
from micropie import App
from pymongo import AsyncMongoClient, ReturnDocument
@@ -53,9 +53,66 @@ CSRF_KEY = "wzDf0CcZr3LgrgPVc2RqHFVUmyXsYT-k8kjGt41bMGU"
mongo = AsyncMongoClient(MONGO_URI)
urls = mongo[DB_NAME][COLLECTION]
+
def _generate_id(length: int = 6) -> str:
return "".join(choice(ascii_letters + digits) for _ in range(length))
-
+
+
+def _parse_expires_in(value) -> int | None:
+ """
+ API-only: expires_in seconds.
+ Returns None if missing/invalid. Clamps to a sane max (30 days).
+ """
+ if value is None:
+ return None
+ try:
+ n = int(value)
+ except Exception:
+ return None
+ if n <= 0:
+ return None
+ MAX_EXPIRES_IN = 60 * 60 * 24 * 30 # 30 days
+ return min(n, MAX_EXPIRES_IN)
+
+
+def _parse_max_clicks(value) -> int | None:
+ """
+ API-only: max_clicks.
+ Returns None if missing/invalid. Clamps to a sane max.
+ """
+ if value is None:
+ return None
+ try:
+ n = int(value)
+ except Exception:
+ return None
+ if n <= 0:
+ return None
+ MAX_MAX_CLICKS = 1_000_000 # safety cap
+ return min(n, MAX_MAX_CLICKS)
+
+
+def _parse_hide_stats_on_expire(value) -> bool | None:
+ """
+ API-only: hide_stats_on_expire.
+ Returns True/False if provided, else None (unset).
+ Accepts: true/false, 1/0, "true"/"false", "yes"/"no", "on"/"off".
+ """
+ if value is None:
+ return None
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return bool(int(value))
+ if isinstance(value, str):
+ v = value.strip().lower()
+ if v in {"true", "1", "yes", "y", "on"}:
+ return True
+ if v in {"false", "0", "no", "n", "off"}:
+ return False
+ return None
+
+
class Shorty(App):
async def index(self, url_str: str | None = None):
@@ -71,8 +128,8 @@ class Shorty(App):
return await self._render_template("index.html", request=self.request)
async def _create_short_link(self, url_str: str):
- if not isinstance(url_str, str) or not url_str.startswith(("http://", "https://")):
- return self._redirect("/")
+ if not isinstance(url_str, str) or not url_str.startswith(("https://")):
+ return 400, await self._render_template("400.html")
while True:
short_id = _generate_id()
@@ -95,35 +152,75 @@ class Shorty(App):
url_root=URL_ROOT,
)
-
async def _redirect_link(self, url_str: str):
+ """
+ Enforces API-only constraints if present:
+ - expires_at (datetime): link is invalid once now >= expires_at
+ - max_clicks (int): link is invalid once clicks >= max_clicks
+
+ We enforce atomically by filtering the update so the increment only happens
+ when the link is still valid.
+ """
+ now = datetime.utcnow()
+ query = {
+ "_id": url_str,
+ "$and": [
+ {
+ "$or": [
+ {"expires_at": {"$exists": False}},
+ {"expires_at": None},
+ {"expires_at": {"$gt": now}},
+ ]
+ },
+ {
+ "$or": [
+ {"max_clicks": {"$exists": False}},
+ {"max_clicks": None},
+ {"$expr": {"$lt": ["$clicks", "$max_clicks"]}},
+ ]
+ },
+ ],
+ }
+
doc = await urls.find_one_and_update(
- {"_id": url_str},
+ query,
{
"$inc": {"clicks": 1},
- "$set": {"last_clicked_at": datetime.utcnow()},
+ "$set": {"last_clicked_at": now},
},
return_document=ReturnDocument.AFTER,
)
+
if not doc:
- return self._redirect("/")
+ return 404, await self._render_template("404.html")
+
return self._redirect(doc["url"])
-
async def _stats_page(self, url_str: str):
short_code = url_str[:-1]
doc = await urls.find_one({"_id": short_code})
-
+
if not doc:
- return self._redirect("/")
-
+ return 404, await self._render_template("404.html")
+
+ # If configured, hide stats once the link is invalid (expired or maxed).
+ if doc.get("hide_stats_on_expire") is True:
+ now = datetime.utcnow()
+ expires_at = doc.get("expires_at")
+ max_clicks = doc.get("max_clicks")
+ clicks = int(doc.get("clicks", 0))
+
+ expired = isinstance(expires_at, datetime) and now >= expires_at
+ maxed = isinstance(max_clicks, int) and clicks >= max_clicks
+
+ if expired or maxed:
+ return 410, await self._render_template("410.html")
+
return await self._render_template(
"stats.html",
short_code=short_code,
url=doc.get("url"),
clicks=int(doc.get("clicks", 0)),
- created_at=doc.get("created_at"),
- last_clicked_at=doc.get("last_clicked_at"),
url_root=URL_ROOT,
)
@@ -143,9 +240,16 @@ class ApiApp(App):
if not isinstance(url_str, str):
return 400, {"error": "Invalid JSON or missing 'url' key"}
- if not url_str.startswith(("http://", "https://")):
+ if not url_str.startswith(("https://")):
return 400, {"error": "Invalid URL"}
+ expires_in = _parse_expires_in(data.get("expires_in"))
+ expires_at = (datetime.utcnow() + timedelta(seconds=expires_in)) if expires_in else None
+
+ max_clicks = _parse_max_clicks(data.get("max_clicks"))
+
+ hide_stats_on_expire = _parse_hide_stats_on_expire(data.get("hide_stats_on_expire"))
+
while True:
short_id = _generate_id()
exists = await urls.find_one({"_id": short_id})
@@ -158,6 +262,10 @@ class ApiApp(App):
"clicks": 0,
"created_at": datetime.utcnow(),
"last_clicked_at": None,
+ # API-only controls:
+ **({"expires_at": expires_at} if expires_at else {}),
+ **({"max_clicks": max_clicks} if max_clicks else {}),
+ **({"hide_stats_on_expire": hide_stats_on_expire} if hide_stats_on_expire is not None else {}),
})
return {
@@ -165,6 +273,9 @@ class ApiApp(App):
"long_url": url_str,
"short_id": short_id,
"short_url": f"{URL_ROOT}{short_id}",
+ "expires_at": expires_at.isoformat() + "Z" if expires_at else None,
+ "max_clicks": max_clicks,
+ "hide_stats_on_expire": hide_stats_on_expire,
}
async def stats(self, short_id: str):
@@ -178,8 +289,24 @@ class ApiApp(App):
if not doc:
return 404, {"error": "Not Found"}
+ # If configured, hide stats once the link is invalid (expired or maxed).
+ if doc.get("hide_stats_on_expire") is True:
+ now = datetime.utcnow()
+ expires_at = doc.get("expires_at")
+ max_clicks = doc.get("max_clicks")
+ clicks = int(doc.get("clicks", 0))
+
+ if isinstance(expires_at, datetime) and now >= expires_at:
+ return 410, {"error": "Gone"}
+
+ if isinstance(max_clicks, int) and clicks >= max_clicks:
+ return 410, {"error": "Gone"}
+
created_at = doc.get("created_at")
last_clicked_at = doc.get("last_clicked_at")
+ expires_at = doc.get("expires_at")
+ max_clicks = doc.get("max_clicks")
+ hide_stats_on_expire = doc.get("hide_stats_on_expire")
return {
"status": "success",
@@ -189,10 +316,12 @@ class ApiApp(App):
"clicks": int(doc.get("clicks", 0)),
"created_at": created_at.isoformat() + "Z" if created_at else None,
"last_clicked_at": last_clicked_at.isoformat() + "Z" if last_clicked_at else None,
+ "expires_at": expires_at.isoformat() + "Z" if isinstance(expires_at, datetime) else None,
+ "max_clicks": int(max_clicks) if isinstance(max_clicks, int) else None,
+ "hide_stats_on_expire": hide_stats_on_expire if isinstance(hide_stats_on_expire, bool) else None,
}
-
app = Shorty(
session_backend=MkvSessionBackend(
mongo_uri=MONGO_URI,
@@ -207,6 +336,7 @@ app.middlewares.append(
allowed_hosts=None,
trust_proxy_headers=False,
require_cf_ray=False,
+ limit_methods={"GET", "POST"},
)
)
@@ -227,3 +357,4 @@ app.middlewares.append(
subapp=ApiApp(),
)
)
+
diff --git a/examples/url_shortener/templates/400.html b/examples/url_shortener/templates/400.html
new file mode 100644
index 0000000..95d90a1
--- /dev/null
+++ b/examples/url_shortener/templates/400.html
@@ -0,0 +1,162 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>400</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
+<style>
+ :root{
+ color-scheme: dark;
+ --bg: #070a0f;
+ --card: rgba(255,255,255,.06);
+ --card2: rgba(255,255,255,.04);
+ --border: rgba(255,255,255,.10);
+ --text: rgba(255,255,255,.92);
+ --muted: rgba(255,255,255,.60);
+ --focus: rgba(255,255,255,.22);
+ --shadow: 0 18px 55px rgba(0,0,0,.55);
+ --radius: 16px;
+ }
+
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+
+ body{
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ background:
+ radial-gradient(900px 500px at 20% 15%, rgba(255,255,255,.05), transparent 55%),
+ radial-gradient(800px 500px at 80% 85%, rgba(255,255,255,.04), transparent 55%),
+ var(--bg);
+ color: var(--text);
+ padding: 24px;
+ }
+
+ .card{
+ width: 100%;
+ max-width: 640px;
+ padding: 22px;
+ }
+
+ h1{
+ font-size: 2rem;
+ letter-spacing: .2px;
+ font-weight: 600;
+ margin-bottom: 6px;
+ }
+
+ p{
+ font-size: .95rem;
+ color: var(--muted);
+ line-height: 1.35;
+ }
+
+ form{
+ display: flex;
+ gap: 10px;
+ margin-top: 16px;
+ }
+
+ .field{
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ input[type="url"]{
+ width: 100%;
+ padding: 12px 14px;
+ font-size: 1rem;
+ color: var(--text);
+ background: rgba(0,0,0,.30);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ outline: none;
+ transition: border-color .15s ease, box-shadow .15s ease;
+ font-family: inherit;
+ }
+
+ input[type="url"]::placeholder{
+ color: rgba(255,255,255,.38);
+ }
+
+ input[type="url"]:focus{
+ border-color: var(--focus);
+ box-shadow: 0 0 0 4px rgba(255,255,255,.06);
+ }
+
+ button{
+ flex: 0 0 auto;
+ padding: 12px 16px;
+ font-size: 0.98rem;
+ font-weight: 600;
+ color: var(--text);
+ background: rgba(255,255,255,.10);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ cursor: pointer;
+ transition: transform .08s ease, background .15s ease, border-color .15s ease;
+ user-select: none;
+ white-space: nowrap;
+ font-family: inherit;
+ }
+
+ button:hover{
+ background: rgba(255,255,255,.14);
+ border-color: rgba(255,255,255,.14);
+ }
+
+ button:active{
+ transform: scale(.98);
+ }
+
+ .below{
+ position: fixed;
+ bottom: 18px;
+ left: 50%;
+ transform: translateX(-50%);
+ text-align: center;
+}
+
+ .back-link{
+ display: inline-block;
+ font-size: .9rem;
+ color: var(--muted);
+ text-decoration: none;
+ }
+
+ .back-link:hover{
+ color: var(--text);
+ }
+
+ code{
+ background:#363434;
+ padding:0 .25em;
+ border-radius:6px;
+ }
+ /* mobile: stack */
+ @media (max-width: 560px){
+ form{ flex-direction: column; }
+ button{ width: 100%; }
+ }
+</style>
+</head>
+
+<body>
+ <main class="card">
+ <h1>400</h1>
+ The server has encountered a bad request. The URL submitted was not valid. URLs must start with <code>https://</code>
+ <br><br>
+ <a href="/" class="back-link">← Back to Safety</a>
+ <div class="below">
+ <a href="/" target="_blank" class="back-link">erd.sh</a> · <a href="/api/v1" class="back-link">API Docs (v1)</a>
+ </div>
+ </main>
+</body>
+</html>
+
diff --git a/examples/url_shortener/templates/404.html b/examples/url_shortener/templates/404.html
new file mode 100644
index 0000000..2a509bf
--- /dev/null
+++ b/examples/url_shortener/templates/404.html
@@ -0,0 +1,157 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>404</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
+<style>
+ :root{
+ color-scheme: dark;
+ --bg: #070a0f;
+ --card: rgba(255,255,255,.06);
+ --card2: rgba(255,255,255,.04);
+ --border: rgba(255,255,255,.10);
+ --text: rgba(255,255,255,.92);
+ --muted: rgba(255,255,255,.60);
+ --focus: rgba(255,255,255,.22);
+ --shadow: 0 18px 55px rgba(0,0,0,.55);
+ --radius: 16px;
+ }
+
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+
+ body{
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ background:
+ radial-gradient(900px 500px at 20% 15%, rgba(255,255,255,.05), transparent 55%),
+ radial-gradient(800px 500px at 80% 85%, rgba(255,255,255,.04), transparent 55%),
+ var(--bg);
+ color: var(--text);
+ padding: 24px;
+ }
+
+ .card{
+ width: 100%;
+ max-width: 640px;
+ padding: 22px;
+ }
+
+ h1{
+ font-size: 2rem;
+ letter-spacing: .2px;
+ font-weight: 600;
+ margin-bottom: 6px;
+ }
+
+ p{
+ font-size: .95rem;
+ color: var(--muted);
+ line-height: 1.35;
+ }
+
+ form{
+ display: flex;
+ gap: 10px;
+ margin-top: 16px;
+ }
+
+ .field{
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ input[type="url"]{
+ width: 100%;
+ padding: 12px 14px;
+ font-size: 1rem;
+ color: var(--text);
+ background: rgba(0,0,0,.30);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ outline: none;
+ transition: border-color .15s ease, box-shadow .15s ease;
+ font-family: inherit;
+ }
+
+ input[type="url"]::placeholder{
+ color: rgba(255,255,255,.38);
+ }
+
+ input[type="url"]:focus{
+ border-color: var(--focus);
+ box-shadow: 0 0 0 4px rgba(255,255,255,.06);
+ }
+
+ button{
+ flex: 0 0 auto;
+ padding: 12px 16px;
+ font-size: 0.98rem;
+ font-weight: 600;
+ color: var(--text);
+ background: rgba(255,255,255,.10);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ cursor: pointer;
+ transition: transform .08s ease, background .15s ease, border-color .15s ease;
+ user-select: none;
+ white-space: nowrap;
+ font-family: inherit;
+ }
+
+ button:hover{
+ background: rgba(255,255,255,.14);
+ border-color: rgba(255,255,255,.14);
+ }
+
+ button:active{
+ transform: scale(.98);
+ }
+
+ .below{
+ position: fixed;
+ bottom: 18px;
+ left: 50%;
+ transform: translateX(-50%);
+ text-align: center;
+}
+
+ .back-link{
+ display: inline-block;
+ font-size: .9rem;
+ color: var(--muted);
+ text-decoration: none;
+ }
+
+ .back-link:hover{
+ color: var(--text);
+ }
+
+ /* mobile: stack */
+ @media (max-width: 560px){
+ form{ flex-direction: column; }
+ button{ width: 100%; }
+ }
+</style>
+</head>
+
+<body>
+ <main class="card">
+ <h1>404</h1>
+ The page you have tried accessing can not be not be found. This short link is invalid or has expired.
+ <br><br>
+ <a href="/" class="back-link">← Back to Safety</a>
+ <div class="below">
+ <a href="/" target="_blank" class="back-link">erd.sh</a> · <a href="/api/v1" class="back-link">API Docs (v1)</a>
+ </div>
+ </main>
+</body>
+</html>
+
diff --git a/examples/url_shortener/templates/410.html b/examples/url_shortener/templates/410.html
new file mode 100644
index 0000000..bd778b7
--- /dev/null
+++ b/examples/url_shortener/templates/410.html
@@ -0,0 +1,157 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>410</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
+<style>
+ :root{
+ color-scheme: dark;
+ --bg: #070a0f;
+ --card: rgba(255,255,255,.06);
+ --card2: rgba(255,255,255,.04);
+ --border: rgba(255,255,255,.10);
+ --text: rgba(255,255,255,.92);
+ --muted: rgba(255,255,255,.60);
+ --focus: rgba(255,255,255,.22);
+ --shadow: 0 18px 55px rgba(0,0,0,.55);
+ --radius: 16px;
+ }
+
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+
+ body{
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ background:
+ radial-gradient(900px 500px at 20% 15%, rgba(255,255,255,.05), transparent 55%),
+ radial-gradient(800px 500px at 80% 85%, rgba(255,255,255,.04), transparent 55%),
+ var(--bg);
+ color: var(--text);
+ padding: 24px;
+ }
+
+ .card{
+ width: 100%;
+ max-width: 640px;
+ padding: 22px;
+ }
+
+ h1{
+ font-size: 2rem;
+ letter-spacing: .2px;
+ font-weight: 600;
+ margin-bottom: 6px;
+ }
+
+ p{
+ font-size: .95rem;
+ color: var(--muted);
+ line-height: 1.35;
+ }
+
+ form{
+ display: flex;
+ gap: 10px;
+ margin-top: 16px;
+ }
+
+ .field{
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ input[type="url"]{
+ width: 100%;
+ padding: 12px 14px;
+ font-size: 1rem;
+ color: var(--text);
+ background: rgba(0,0,0,.30);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ outline: none;
+ transition: border-color .15s ease, box-shadow .15s ease;
+ font-family: inherit;
+ }
+
+ input[type="url"]::placeholder{
+ color: rgba(255,255,255,.38);
+ }
+
+ input[type="url"]:focus{
+ border-color: var(--focus);
+ box-shadow: 0 0 0 4px rgba(255,255,255,.06);
+ }
+
+ button{
+ flex: 0 0 auto;
+ padding: 12px 16px;
+ font-size: 0.98rem;
+ font-weight: 600;
+ color: var(--text);
+ background: rgba(255,255,255,.10);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ cursor: pointer;
+ transition: transform .08s ease, background .15s ease, border-color .15s ease;
+ user-select: none;
+ white-space: nowrap;
+ font-family: inherit;
+ }
+
+ button:hover{
+ background: rgba(255,255,255,.14);
+ border-color: rgba(255,255,255,.14);
+ }
+
+ button:active{
+ transform: scale(.98);
+ }
+
+ .below{
+ position: fixed;
+ bottom: 18px;
+ left: 50%;
+ transform: translateX(-50%);
+ text-align: center;
+}
+
+ .back-link{
+ display: inline-block;
+ font-size: .9rem;
+ color: var(--muted);
+ text-decoration: none;
+ }
+
+ .back-link:hover{
+ color: var(--text);
+ }
+
+ /* mobile: stack */
+ @media (max-width: 560px){
+ form{ flex-direction: column; }
+ button{ width: 100%; }
+ }
+</style>
+</head>
+
+<body>
+ <main class="card">
+ <h1>410</h1>
+ The page you have tried accessing is gone. This short link is invalid or has expired.
+ <br><br>
+ <a href="/" class="back-link">← Back to Safety</a>
+ <div class="below">
+ <a href="/" target="_blank" class="back-link">erd.sh</a> · <a href="/api/v1" class="back-link">API Docs (v1)</a>
+ </div>
+ </main>
+</body>
+</html>
+
diff --git a/examples/url_shortener/templates/api.html b/examples/url_shortener/templates/api.html
index 7521c7f..4d01f10 100644
--- a/examples/url_shortener/templates/api.html
+++ b/examples/url_shortener/templates/api.html
@@ -70,11 +70,15 @@
}
.note{
- font-size:.75rem;
+ font-size:.9rem;
color:var(--muted);
margin-top:10px;
}
+ .note code{
+ font-size:.88em;
+ }
+
a{
color:var(--muted);
text-decoration:none;
@@ -83,55 +87,91 @@
a:hover{
color:var(--text);
}
+
+ code{
+ background:#363434;
+ padding:0 .25em;
+ border-radius:6px;
+ }
</style>
</head>
<body>
<main class="card">
+
<h1>Create short link</h1>
- <div class="section">
- <pre>
-POST /api/v1/shorten
+ <div class="section">
+ <div class="label">HTTP</div>
+<pre>
+POST https://erd.sh/api/v1/shorten
Content-Type: application/json
{
- "url": "https://google.com"
+ "url": "https://google.com",
+ "expires_in": 3600,
+ "max_clicks": 25,
+ "hide_stats_on_expire": true
}
- </pre>
- </div>
-
- <div class="section">
- <div class="label">Response</div>
- <pre>
+</pre>
+ <div class="note">
+ <em>
+ <code>expires_in</code>, <code>max_clicks</code> and <code>hide_stats_on_expire</code> are optional (API only).
+ If omitted or null, links never expire and have unlimited clicks/analytics.
+ </em>
+ </div>
+ </div>
+
+ <div class="section">
+ <div class="label">Response</div>
+<pre>
{
"status": "success",
- "long_url": "https://harrisonerd.com",
+ "long_url": "https://google.com",
"short_id": "Ab3kQ9",
- "short_url": "https://erd.sh/Ab3kQ9"
+ "short_url": "https://erd.sh/Ab3kQ9",
+ "expires_at": "2026-01-04T10:41:12Z",
+ "max_clicks": 25,
+ "hide_stats_on_expire": true
}
- </pre>
- </div>
+</pre>
+ <div class="note">
+ <em><code>expires_at</code>, <code>max_clicks</code>, and <code>hide_stats_on_expire</code> are <code>null</code> when not set.</em>
+ </div>
+ </div>
- <h1 style="margin-top:48px;">Link stats</h1>
- <div class="section">
- <pre>GET /api/v1/stats/Ab3kQ9</pre>
+ <h1 style="margin-top:48px;">Link stats</h1>
+
+ <div class="section">
+ <div class="label">HTTP</div>
+<pre>
+GET https://erd.sh/api/v1/stats/Ab3kQ9
+</pre>
+ <div class="note">
+ <em>If <code>hide_stats_on_expire</code> is true and the link is expired/maxed, this endpoint returns <code>410</code>.</em>
</div>
-
- <div class="section">
- <div class="label">Response</div>
- <pre>
+ </div>
+
+ <div class="section">
+ <div class="label">Response</div>
+<pre>
{
"status": "success",
"short_id": "Ab3kQ9",
"short_url": "https://erd.sh/Ab3kQ9",
- "long_url": "https://harrisonerd.com",
+ "long_url": "https://google.com",
"clicks": 12,
"created_at": "2026-01-04T08:41:12Z",
- "last_clicked_at": "2026-01-04T09:02:55Z"
+ "last_clicked_at": "2026-01-04T09:02:55Z",
+ "expires_at": "2026-01-04T10:41:12Z",
+ "max_clicks": 25,
+ "hide_stats_on_expire": true
}
- </pre>
+</pre>
+ <div class="note">
+ <em><code>expires_at</code>, <code>max_clicks</code>, and <code>hide_stats_on_expire</code> are <code>null</code> when not set.</em>
</div>
+ </div>
<div class="note" style="margin-top:48px;">
<a href="/">← Back</a>