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

Changeset
4bfc43d99c1eaa65076b2b8ac18f718d29f3b6af
Parents
c51786881518fc4092da3bf81f8d7d8118b15789

View source at this commit

Comments

No comments yet.

Log in to comment

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">&larr; Back to Safety</a>
+    <div class="below">
+      <a href="/" target="_blank" class="back-link">erd.sh</a> &middot; <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">&larr; Back to Safety</a>
+    <div class="below">
+      <a href="/" target="_blank" class="back-link">erd.sh</a> &middot; <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">&larr; Back to Safety</a>
+    <div class="below">
+      <a href="/" target="_blank" class="back-link">erd.sh</a> &middot; <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="/">&larr; Back</a>