Files
modelo-fastapi/links.py
T
dado.cloud 7006615b91 Modelo do encurtador em FastAPI
Gerado a partir de deploy-templates/fastapi (dado.cloud 18345d5).
2026-10-21 13:12:41 +00:00

95 lines
3.3 KiB
Python

"""Guarda dos links encurtados.
Os links ficam num dicionário na memória do processo: simples e sem banco, mas
somem toda vez que o app reinicia ou é publicado de novo. Para guardar de vez,
troque este arquivo por consultas a um Postgres.
"""
import re
import secrets
from datetime import datetime, timezone
from urllib.parse import urlsplit, urlunsplit
ALFABETO = "abcdefghijklmnopqrstuvwxyz0123456789"
TAMANHO_CODIGO = 6
TAMANHO_MAXIMO_URL = 2048
# Teto de links guardados: passou disso, o mais antigo sai (protege a memória).
MAXIMO_DE_LINKS = 1000
LINKS_NA_LISTA = 20
# Caminhos do próprio app que nunca podem virar código.
RESERVADOS = {"api", "docs"}
FORMATO_CODIGO = re.compile(r"^[a-z0-9]{6}$")
# Dicionário do Python mantém a ordem de inserção: o primeiro é o mais antigo.
_links: dict[str, dict] = {}
class ErroDeValidacao(ValueError):
pass
def _gerar_codigo() -> str:
while True:
codigo = "".join(secrets.choice(ALFABETO) for _ in range(TAMANHO_CODIGO))
if codigo not in _links and codigo not in RESERVADOS:
return codigo
def _validar_url(valor) -> str:
if not isinstance(valor, str) or not valor.strip():
raise ErroDeValidacao('Envie o campo "url" com o link que você quer encurtar.')
texto = valor.strip()
if len(texto) > TAMANHO_MAXIMO_URL:
raise ErroDeValidacao(f"Esse link é comprido demais (máximo de {TAMANHO_MAXIMO_URL} caracteres).")
try:
partes = urlsplit(texto)
porta = partes.port # porta inválida (ex.: ":abc") levanta ValueError
except ValueError:
partes = None
if not partes or partes.scheme not in ("http", "https") or not partes.hostname or any(c.isspace() for c in texto):
raise ErroDeValidacao("Esse não parece um link válido. Use um endereço que comece com http:// ou https://.")
return _normalizar(partes, porta)
def _normalizar(partes, porta) -> str:
"""Mesma forma que o `new URL(...).href` dos modelos Node: esquema e host em
minúsculas, sem a porta padrão e com "/" quando o caminho vem vazio."""
esquema = partes.scheme.lower()
host = partes.hostname.lower()
if ":" in host: # IPv6 volta entre colchetes
host = f"[{host}]"
if porta is not None and porta != {"http": 80, "https": 443}[esquema]:
host = f"{host}:{porta}"
if partes.username is not None:
credenciais = partes.username + (f":{partes.password}" if partes.password is not None else "")
host = f"{credenciais}@{host}"
return urlunsplit((esquema, host, partes.path or "/", partes.query, partes.fragment))
def criar_link(valor) -> dict:
link = {
"codigo": _gerar_codigo(),
"url": _validar_url(valor),
"cliques": 0,
"criadoEm": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
}
_links[link["codigo"]] = link
if len(_links) > MAXIMO_DE_LINKS:
del _links[next(iter(_links))]
return link
def listar_links() -> list[dict]:
"""Os últimos links criados, do mais novo para o mais antigo."""
return list(reversed(list(_links.values())[-LINKS_NA_LISTA:]))
def registrar_clique(codigo: str) -> dict | None:
"""Soma um clique e devolve o link, ou None se o código não existe."""
link = _links.get(codigo)
if link is None:
return None
link["cliques"] += 1
return link