Modelo do encurtador em FastAPI

Gerado a partir de deploy-templates/fastapi (dado.cloud 4c2a882).
This commit is contained in:
dado.cloud
2026-10-21 12:27:03 +00:00
commit 239d0dee0a
8 changed files with 633 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
.env
.DS_Store
+1
View File
@@ -0,0 +1 @@
3.13
+86
View File
@@ -0,0 +1,86 @@
# Encurtador de links em FastAPI
Um encurtador de links pronto para publicar na [Dado](https://dado.cloud): cole um link
comprido, receba um endereço curto e acompanhe quantas vezes ele foi aberto.
Este repositório nasceu de um modelo da Dado. Cada `git push` na branch `main` publica
uma versão nova do seu app, sozinho.
## O que tem aqui
- `main.py`: o app FastAPI — página, API (`POST/GET /api/links`) e o redirecionamento `GET /<código>` (302).
- `links.py`: onde os links ficam guardados (na memória) e a validação dos endereços.
- `paginas/index.html`: a página (HTML, CSS e o JavaScript do formulário). Mude à vontade.
- `paginas/nao-encontrado.html`: a página de link que não existe.
- `requirements.txt`: as dependências, com versão fixa.
Não tem banco de dados: os links ficam na memória do app e **somem quando ele é publicado
de novo**. Veja no fim como guardar de vez.
## Rodar no seu computador
Precisa de Python 3.11 ou mais novo.
```bash
git clone https://git.dado.cloud/<sua-conta>/encurtador-fastapi.git
cd encurtador-fastapi
python3 -m venv .venv
source .venv/bin/activate # no Windows: .venv\Scripts\activate
pip install -r requirements.txt
uvicorn main:app --reload
```
Abra http://localhost:8000.
Quando o `git clone` pedir usuário e senha, use o usuário da sua conta de Repositórios e,
como senha, o **token de Repositórios** (no painel da Dado, em Repositórios). O comando
certinho do seu app aparece no painel, em Deploys, na visão geral do app.
## Mudar e publicar
Edite o que quiser e mande para a Dado:
```bash
git add .
git commit -m "Minha primeira mudança"
git push
```
O push na branch `main` publica uma versão nova. Dá para acompanhar o andamento em
Deploys, no painel.
## Variável de ambiente de exemplo
A mensagem em "Sobre este deploy" vem da variável `MENSAGEM_BOAS_VINDAS`. Para trocar,
abra Deploys no painel, clique em "Variáveis de ambiente" no seu app, salve e publique de
novo. No seu computador:
```bash
MENSAGEM_BOAS_VINDAS="Oi, mundo" uvicorn main:app --reload
```
## API
```bash
# Encurtar
curl -X POST https://<endereço-do-seu-app>/api/links \
-H 'Content-Type: application/json' \
-d '{"url": "https://exemplo.com.br/uma-pagina"}'
# 201 {"codigo":"a1b2c3","url":"https://exemplo.com.br/uma-pagina","curto":"https://<endereço-do-seu-app>/a1b2c3","cliques":0,"criadoEm":"..."}
# Últimos 20 links
curl https://<endereço-do-seu-app>/api/links
# 200 {"links":[...]}
```
`GET /<código>` redireciona (302) para o link original e soma um clique. A API aceita
chamadas de qualquer site (CORS aberto só em `/api/*`), então outro app seu pode usá-la.
Para evitar abuso, cada IP cria no máximo 30 links a cada 10 minutos.
A documentação interativa da API (do próprio FastAPI) fica em `/docs`.
## Guardar os links de vez
Para os links sobreviverem a uma nova publicação, ligue um Postgres no app (painel, em
Deploys, aba "Banco de dados" do app). A conexão chega ao app na variável `DATABASE_URL`;
aí é trocar o que está em `links.py` por consultas ao banco.
+78
View File
@@ -0,0 +1,78 @@
"""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
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)
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 texto
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
+151
View File
@@ -0,0 +1,151 @@
"""Encurtador de links em FastAPI, pronto para publicar na Dado.
Rodar local: uvicorn main:app --reload (ou: python main.py)
"""
import html
import os
import platform
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import fastapi
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from pydantic import BaseModel
from links import FORMATO_CODIGO, ErroDeValidacao, criar_link, listar_links, registrar_clique
# Variável de ambiente de exemplo: na Dado, ela se configura no painel do app.
MENSAGEM = os.environ.get("MENSAGEM_BOAS_VINDAS") or "Defina a variável MENSAGEM_BOAS_VINDAS para trocar este texto."
INICIO = datetime.now(timezone.utc)
# Brasília não tem horário de verão desde 2019: UTC-3 o ano todo.
_inicio_brasilia = INICIO.astimezone(timezone(timedelta(hours=-3)))
INICIO_FORMATADO = _inicio_brasilia.strftime("%d/%m/%Y às %H:%M") + " (horário de Brasília)"
PASTA_PAGINAS = Path(__file__).parent / "paginas"
# A página é montada uma vez só: nada nela muda enquanto o processo vive.
PAGINA_INICIAL = (
(PASTA_PAGINAS / "index.html")
.read_text(encoding="utf-8")
.replace("{{RUNTIME}}", html.escape(f"FastAPI {fastapi.__version__} · Python {platform.python_version()}"))
.replace("{{INICIO_ISO}}", INICIO.isoformat(timespec="seconds").replace("+00:00", "Z"))
.replace("{{INICIO}}", html.escape(INICIO_FORMATADO))
.replace("{{MENSAGEM}}", html.escape(MENSAGEM))
)
PAGINA_NAO_ENCONTRADA = (PASTA_PAGINAS / "nao-encontrado.html").read_text(encoding="utf-8")
# Freio simples contra abuso: no máximo 30 links novos a cada 10 minutos por IP.
LIMITE_POR_IP = 30
JANELA_SEGUNDOS = 10 * 60
_tentativas: dict[str, tuple[int, float]] = {}
def passou_do_limite(ip: str) -> bool:
agora = time.monotonic()
if len(_tentativas) > 10_000:
_tentativas.clear()
total, expira_em = _tentativas.get(ip, (0, 0.0))
if expira_em <= agora:
_tentativas[ip] = (1, agora + JANELA_SEGUNDOS)
return False
_tentativas[ip] = (total + 1, expira_em)
return total + 1 > LIMITE_POR_IP
app = FastAPI(title="Encurtador de links", description="Encurta links e conta os cliques.")
CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
@app.middleware("http")
async def cors_so_na_api(request: Request, call_next):
"""CORS aberto só na API: qualquer site (ou outro app seu) pode chamar /api/*."""
if not request.url.path.startswith("/api/"):
return await call_next(request)
if request.method == "OPTIONS":
resposta = Response(status_code=204)
else:
resposta = await call_next(request)
resposta.headers.update(CORS)
return resposta
def endereco_base(request: Request) -> str:
# O app roda atrás de um proxy HTTPS, que avisa o protocolo e o host originais.
protocolo = request.headers.get("x-forwarded-proto", request.url.scheme).split(",")[0].strip()
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
return f"{protocolo}://{host}"
def para_json(link: dict, request: Request) -> dict:
return {
"codigo": link["codigo"],
"url": link["url"],
"curto": f"{endereco_base(request)}/{link['codigo']}",
"cliques": link["cliques"],
"criadoEm": link["criadoEm"],
}
class NovoLink(BaseModel):
url: str | None = None
@app.exception_handler(RequestValidationError)
async def corpo_invalido(request: Request, erro: RequestValidationError):
return JSONResponse(
status_code=400,
content={"erro": 'Corpo inválido. Envie JSON no formato {"url": "https://..."}.'},
)
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
def pagina_inicial():
return PAGINA_INICIAL
@app.get("/api/links", summary="Lista os últimos 20 links")
def listar(request: Request):
return JSONResponse(
{"links": [para_json(link, request) for link in listar_links()]},
headers={"Cache-Control": "no-store"},
)
@app.post("/api/links", status_code=201, summary="Encurta um link")
def criar(novo: NovoLink, request: Request):
ip = request.headers.get("cf-connecting-ip") or (request.client.host if request.client else "?")
if passou_do_limite(ip):
return JSONResponse(
status_code=429,
content={"erro": "Muitos links em pouco tempo. Espere alguns minutos e tente de novo."},
)
try:
link = criar_link(novo.url)
except ErroDeValidacao as erro:
return JSONResponse(status_code=400, content={"erro": str(erro)})
return JSONResponse(status_code=201, content=para_json(link, request))
# O redirecionamento é feito pelo servidor: GET /<código> → 302 para o link.
# Fica por último: as rotas fixas acima têm prioridade.
@app.get("/{codigo}", include_in_schema=False)
def redirecionar(codigo: str):
link = registrar_clique(codigo) if FORMATO_CODIGO.match(codigo) else None
if link is None:
return HTMLResponse(PAGINA_NAO_ENCONTRADA, status_code=404)
return RedirectResponse(link["url"], status_code=302, headers={"Cache-Control": "no-store"})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8000")))
+278
View File
@@ -0,0 +1,278 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Encurtador de links</title>
<meta name="description" content="Encurte links e acompanhe quantas vezes cada um foi aberto." />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%233b5bdb'/%3E%3Cpath d='M13.5 18.5l5-5M12 15l-2 2a3.5 3.5 0 005 5l2-2M20 17l2-2a3.5 3.5 0 00-5-5l-2 2' stroke='white' stroke-width='2.2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"
/>
<style>
/* Mesmo visual nos modelos Next.js, Express e FastAPI. Mude à vontade. */
:root {
--fundo: #f6f7f9;
--cartao: #ffffff;
--texto: #14161a;
--suave: #5b6270;
--borda: #e3e6eb;
--destaque: #3b5bdb;
--destaque-texto: #ffffff;
--destaque-suave: #edf1ff;
--erro: #c92a2a;
--raio: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--fundo: #0f1115;
--cartao: #171a21;
--texto: #eceef2;
--suave: #9aa1ad;
--borda: #2a2f3a;
--destaque: #8299ff;
--destaque-texto: #0f1115;
--destaque-suave: #1e2540;
--erro: #ff8787;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--fundo);
color: var(--texto);
line-height: 1.5;
-webkit-text-size-adjust: 100%;
}
.pagina { max-width: 640px; margin: 0 auto; padding: 56px 16px 32px; }
.topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.marca {
flex: none; width: 40px; height: 40px; border-radius: 10px;
background: var(--destaque); color: var(--destaque-texto);
display: grid; place-items: center;
}
h1 { font-size: 28px; line-height: 1.2; margin: 0; letter-spacing: -0.02em; }
h2 { font-size: 16px; margin: 32px 0 12px; }
.lead { color: var(--suave); margin: 0 0 24px; }
.cartao { background: var(--cartao); border: 1px solid var(--borda); border-radius: var(--raio); padding: 20px; }
.formulario { display: flex; gap: 8px; }
.formulario input {
flex: 1; min-width: 0; font: inherit; padding: 12px 14px;
border: 1px solid var(--borda); border-radius: 10px;
background: var(--fundo); color: var(--texto);
}
.formulario input:focus { outline: 2px solid var(--destaque); outline-offset: 1px; }
button {
font: inherit; font-weight: 600; padding: 12px 20px; border: 0; border-radius: 10px;
background: var(--destaque); color: var(--destaque-texto); cursor: pointer; white-space: nowrap;
}
button:disabled { opacity: 0.6; cursor: progress; }
button:focus-visible, a:focus-visible { outline: 2px solid var(--destaque); outline-offset: 2px; }
.secundario { background: var(--cartao); color: var(--destaque); border: 1px solid var(--borda); padding: 8px 14px; }
.erro { color: var(--erro); margin: 10px 0 0; font-size: 14px; }
.resultado {
margin-top: 16px; display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: 10px; background: var(--destaque-suave);
}
.resultado a {
flex: 1; min-width: 0; font-family: var(--mono); font-weight: 600;
color: var(--destaque); text-decoration: none; overflow-wrap: anywhere;
}
.lista { list-style: none; margin: 0; padding: 0; }
.lista li { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-top: 1px solid var(--borda); }
.lista li:first-child { border-top: 0; padding-top: 0; }
.lista li:last-child { padding-bottom: 0; }
.link-info { flex: 1; min-width: 0; }
.link-info a { font-family: var(--mono); font-weight: 600; color: var(--texto); text-decoration: none; overflow-wrap: anywhere; }
.link-info a:hover { color: var(--destaque); }
.destino { display: block; color: var(--suave); font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cliques { font-family: var(--mono); font-size: 14px; color: var(--suave); white-space: nowrap; }
.vazio { color: var(--suave); margin: 0; }
.aviso {
margin: 16px 0 0; font-size: 14px; color: var(--suave);
border: 1px dashed var(--borda); border-radius: var(--raio); padding: 14px 16px;
}
.aviso a { color: var(--destaque); font-weight: 600; }
.sobre { margin-top: 32px; }
.sobre h2 { margin-top: 0; }
.sobre dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; font-size: 14px; }
.sobre dt { color: var(--suave); }
.sobre dd { margin: 0; overflow-wrap: anywhere; }
.dica { font-size: 13px; color: var(--suave); margin: 14px 0 0; }
code { font-family: var(--mono); font-size: 0.92em; }
.rodape { margin-top: 32px; text-align: center; font-size: 13px; color: var(--suave); }
.rodape a { color: inherit; }
.so-leitor {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
@media (max-width: 480px) {
.pagina { padding-top: 32px; }
h1 { font-size: 24px; }
.formulario { flex-direction: column; }
.resultado { flex-direction: column; align-items: stretch; }
.sobre dl { grid-template-columns: 1fr; gap: 0; }
.sobre dd { margin-bottom: 8px; }
}
</style>
</head>
<body>
<main class="pagina">
<header class="topo">
<span class="marca" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M10 14l4-4M8.5 11.5l-1.8 1.8a3.2 3.2 0 004.5 4.5l1.8-1.8M15.5 12.5l1.8-1.8a3.2 3.2 0 00-4.5-4.5l-1.8 1.8" />
</svg>
</span>
<h1>Encurtador de links</h1>
</header>
<p class="lead">Cole um link comprido e receba um endereço curto, pronto para compartilhar.</p>
<section class="cartao">
<form id="formulario" class="formulario" novalidate>
<label for="url" class="so-leitor">Link para encurtar</label>
<input id="url" name="url" type="url" inputmode="url" maxlength="2048" placeholder="Cole seu link aqui" autocomplete="off" required />
<button type="submit" id="encurtar">Encurtar</button>
</form>
<p id="erro" class="erro" role="alert" hidden></p>
<div id="resultado" class="resultado" hidden>
<a id="curto" href="#" target="_blank" rel="noopener"></a>
<button type="button" id="copiar" class="secundario">Copiar</button>
</div>
</section>
<h2>Últimos links</h2>
<section class="cartao">
<ul id="lista" class="lista"></ul>
<p id="vazio" class="vazio">Nenhum link ainda. Encurte o primeiro aí em cima.</p>
</section>
<p class="aviso">
Os links ficam na memória do app e somem quando ele é publicado de novo.
Para guardar os links de vez, <a href="https://app.dado.cloud/deploys" target="_blank" rel="noopener">ligue um Postgres</a>.
</p>
<section class="sobre cartao" aria-labelledby="sobre-titulo">
<h2 id="sobre-titulo">Sobre este deploy</h2>
<dl>
<dt>Runtime</dt>
<dd>{{RUNTIME}}</dd>
<dt>Região</dt>
<dd>Rodando em São Paulo</dd>
<dt>No ar desde</dt>
<dd><time datetime="{{INICIO_ISO}}">{{INICIO}}</time></dd>
<dt>Mensagem</dt>
<dd>{{MENSAGEM}}</dd>
</dl>
<p class="dica">
A mensagem vem da variável <code>MENSAGEM_BOAS_VINDAS</code>. Troque o valor nas
configurações do app, no painel, e publique de novo.
</p>
</section>
<footer class="rodape">Publicado na <a href="https://dado.cloud" target="_blank" rel="noopener">Dado</a></footer>
</main>
<script>
const formulario = document.getElementById('formulario');
const campo = document.getElementById('url');
const botao = document.getElementById('encurtar');
const erro = document.getElementById('erro');
const resultado = document.getElementById('resultado');
const curto = document.getElementById('curto');
const copiar = document.getElementById('copiar');
const lista = document.getElementById('lista');
const vazio = document.getElementById('vazio');
const semProtocolo = (url) => url.replace(/^https?:\/\//, '');
function itemDaLista(link) {
const li = document.createElement('li');
const info = document.createElement('div');
info.className = 'link-info';
const a = document.createElement('a');
a.href = link.curto;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = semProtocolo(link.curto);
const destino = document.createElement('span');
destino.className = 'destino';
destino.textContent = link.url;
destino.title = link.url;
info.append(a, destino);
const cliques = document.createElement('span');
cliques.className = 'cliques';
cliques.textContent = link.cliques === 1 ? '1 clique' : `${link.cliques} cliques`;
li.append(info, cliques);
return li;
}
async function carregarLinks() {
try {
const resposta = await fetch('/api/links', { cache: 'no-store' });
if (!resposta.ok) return;
const { links } = await resposta.json();
lista.replaceChildren(...links.map(itemDaLista));
vazio.hidden = links.length > 0;
} catch {
// Sem rede: a lista fica como estava.
}
}
function mostrarErro(mensagem) {
erro.textContent = mensagem;
erro.hidden = false;
}
formulario.addEventListener('submit', async (evento) => {
evento.preventDefault();
erro.hidden = true;
let url = campo.value.trim();
if (!url) return mostrarErro('Cole um link para encurtar.');
// Quem digita "exemplo.com.br" quer dizer https://exemplo.com.br.
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) url = `https://${url}`;
botao.disabled = true;
try {
const resposta = await fetch('/api/links', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const dados = await resposta.json().catch(() => ({}));
if (!resposta.ok) return mostrarErro(dados.erro || 'Não deu para encurtar esse link. Tente de novo.');
curto.href = dados.curto;
curto.textContent = semProtocolo(dados.curto);
copiar.textContent = 'Copiar';
resultado.hidden = false;
campo.value = '';
await carregarLinks();
} catch {
mostrarErro('Sem conexão com o servidor. Tente de novo.');
} finally {
botao.disabled = false;
}
});
copiar.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(curto.href);
copiar.textContent = 'Copiado!';
} catch {
// Navegador sem permissão de área de transferência: seleciona o link.
getSelection().selectAllChildren(curto);
}
});
// Voltou para a aba depois de testar um link? Atualiza a contagem de cliques.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') carregarLinks();
});
carregarLinks();
</script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Link não encontrado</title>
<style>
:root { color-scheme: light dark; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 16px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f6f7f9; color: #14161a; line-height: 1.5;
}
main { max-width: 420px; text-align: center; }
h1 { font-size: 24px; margin: 0 0 8px; }
p { color: #5b6270; margin: 0 0 20px; }
a { color: #3b5bdb; font-weight: 600; }
@media (prefers-color-scheme: dark) {
body { background: #0f1115; color: #eceef2; }
p { color: #9aa1ad; }
a { color: #8299ff; }
}
</style>
</head>
<body>
<main>
<h1>Link não encontrado</h1>
<p>Esse link curto não existe ou sumiu quando o app foi publicado de novo.</p>
<a href="/">Encurtar um link</a>
</main>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
fastapi==0.156.8
uvicorn==0.54.0