{"openapi": "3.0.3", "info": {"title": "tturl", "version": "2.0.0", "description": "Servicio acortador de URLs con recolección detallada de metadata de visitas.\n\n## Autenticación Machine-to-Machine (v1)\n\nLa API de gestión (`/api/v1/...`) requiere una **API key** en la cabecera `X-API-Key`.\n\n## Deduplicación\n\n`POST /api/v1/links` **no crea un segundo acortado** si la URL destino ya existe:\n**201** al crear, **200** si ya existía.\n\n## Redirección pública\n\n`GET /{code}` es público: resuelve URLs cortas, registra visitas con metadata\n(IP, User-Agent, dispositivo, UTM, geo opcional)."}, "servers": [{"url": "http://127.0.0.1:8000", "description": "Desarrollo local"}, {"url": "https://tturl.co", "description": "Producción"}], "tags": [{"name": "Health", "description": "Estado del servicio."}, {"name": "Public", "description": "Redirección y recursos públicos."}, {"name": "Legacy (CakePHP)", "description": "API compatible con CakePHP. Respuestas envueltas en `{ response: ... }` excepto `/users/token`."}, {"name": "Links v1", "description": "Gestión M2M de enlaces. Requiere API key."}, {"name": "Analytics v1", "description": "Estadísticas y series temporales. Requiere API key."}], "components": {"securitySchemes": {"ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key", "description": "API key M2M (variable `API_KEYS`)."}, "LegacyJwtAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT", "description": "JWT legacy firmado con `HASHIDS_SALT` (HS256). Obtener en `POST /api/users/token`."}}, "schemas": {"Health": {"type": "object", "properties": {"status": {"type": "string", "example": "ok"}, "service": {"type": "string"}, "version": {"type": "string"}, "environment": {"type": "string"}, "database": {"type": "string", "enum": ["ok", "error"]}}}, "LegacyWrapped": {"description": "Cuerpo JSON raíz. En CakePHP, `serialize => 'response'` (string) emite el valor de `response` sin envolver en `{\"response\": ...}`."}, "LegacyLinkEntity": {"type": "object", "properties": {"id": {"type": "integer"}, "url": {"type": "string"}, "visit_count": {"type": "integer", "nullable": true}, "created": {"type": "string", "format": "date-time", "nullable": true}, "modified": {"type": "string", "format": "date-time", "nullable": true}, "user_id": {"type": "integer", "nullable": true}, "short_tt": {"type": "string", "nullable": true}}}, "LegacyCreateResponse": {"type": "object", "properties": {"url": {"type": "string", "example": "https://tturl.co/Ab3xY"}, "shorten": {"type": "string"}, "url_long": {"type": "string"}}}, "LegacyTokenRequest": {"type": "object", "required": ["email", "password"], "properties": {"email": {"type": "string", "format": "email"}, "password": {"type": "string"}}}, "LegacyTokenResponse": {"type": "object", "properties": {"user": {"type": "object", "properties": {"id": {"type": "integer"}, "name": {"type": "string", "nullable": true}, "last_name": {"type": "string", "nullable": true}, "email": {"type": "string"}, "created": {"type": "string", "nullable": true}, "modified": {"type": "string", "nullable": true}, "token": {"type": "string"}}}}}, "LegacyLinkCreate": {"type": "object", "required": ["url"], "properties": {"url": {"type": "string", "format": "uri"}}}, "LegacyViewHashRequest": {"type": "object", "required": ["id"], "properties": {"id": {"type": "integer"}, "short_tt": {"type": "string", "nullable": true}}}, "LegacyUpdateShortRequest": {"type": "object", "properties": {"short_tt": {"type": "string"}, "url": {"type": "string", "format": "uri"}}}, "LegacyVisitCountRequest": {"type": "object", "properties": {"short_tt": {"type": "string"}}}, "LegacyOperationResult": {"type": "object", "properties": {"success": {"type": "boolean"}, "message": {"type": "string"}, "short_tt": {"type": "string", "nullable": true}, "url": {"type": "string", "nullable": true}, "visit_count": {"type": "integer", "nullable": true}}}, "Link": {"type": "object", "properties": {"id": {"type": "integer"}, "short_code": {"type": "string", "nullable": true}, "short_url": {"type": "string", "nullable": true}, "url": {"type": "string"}, "visit_count": {"type": "integer"}, "is_active": {"type": "boolean"}, "is_expired": {"type": "boolean"}, "expires_at": {"type": "string", "format": "date-time", "nullable": true}, "created_at": {"type": "string", "format": "date-time", "nullable": true}, "updated_at": {"type": "string", "format": "date-time", "nullable": true}}}, "LinkCreate": {"type": "object", "required": ["url"], "properties": {"url": {"type": "string", "format": "uri"}}}, "LinkUpdate": {"type": "object", "properties": {"url": {"type": "string", "format": "uri"}}}, "LinkList": {"type": "object", "properties": {"total": {"type": "integer"}, "limit": {"type": "integer"}, "offset": {"type": "integer"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/Link"}}}}, "VisitList": {"type": "object", "properties": {"total": {"type": "integer"}, "limit": {"type": "integer"}, "offset": {"type": "integer"}, "items": {"type": "array", "items": {"type": "object"}}}}, "LinkStats": {"type": "object", "properties": {"short_code": {"type": "string"}, "total_visits": {"type": "integer", "description": "Total oficial (`links.visit_count`: histórico CakePHP + redirects)."}, "tracked_visits": {"type": "integer", "description": "Filas en `visits` con tracking detallado Django (puede ser menor)."}, "unique_ips": {"type": "integer"}, "bot_visits": {"type": "integer"}, "human_visits": {"type": "integer"}, "first_visit": {"type": "string", "nullable": true}, "last_visit": {"type": "string", "nullable": true}, "by_device_type": {"type": "array", "items": {"$ref": "#/components/schemas/BreakdownItem"}}, "by_browser": {"type": "array", "items": {"$ref": "#/components/schemas/BreakdownItem"}}, "by_os": {"type": "array", "items": {"$ref": "#/components/schemas/BreakdownItem"}}, "by_country": {"type": "array", "items": {"$ref": "#/components/schemas/BreakdownItem"}}, "by_referer_domain": {"type": "array", "items": {"$ref": "#/components/schemas/BreakdownItem"}}, "by_day": {"type": "array", "items": {"$ref": "#/components/schemas/BreakdownItem"}}}}, "BreakdownItem": {"type": "object", "properties": {"value": {}, "count": {"type": "integer"}}}, "LinkAnalytics": {"type": "object", "description": "Snapshot analítico completo (volumen, temporal, origen, geo, tech, calidad).", "additionalProperties": true}, "Timeseries": {"type": "object", "properties": {"short_code": {"type": "string"}, "granularity": {"type": "string", "enum": ["minute", "hour", "day", "week", "month"]}, "timezone": {"type": "string"}, "points": {"type": "array", "items": {"type": "object", "properties": {"bucket": {"type": "string"}, "clicks": {"type": "integer"}, "unique": {"type": "integer"}, "bots": {"type": "integer"}, "humans": {"type": "integer"}}}}}}, "ErrorMessage": {"type": "object", "properties": {"message": {"type": "string"}}}}, "parameters": {"PaginationLimit": {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 50, "minimum": 1, "maximum": 200}}, "PaginationOffset": {"name": "offset", "in": "query", "schema": {"type": "integer", "default": 0, "minimum": 0}}, "VisitLimit": {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 100, "minimum": 1, "maximum": 500}}, "ShortCode": {"name": "code", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Código corto (`short_tt` o hashid)."}, "LegacyHashQuery": {"name": "id", "in": "query", "schema": {"type": "string"}, "description": "Código hashids del enlace."}, "DateFrom": {"name": "date_from", "in": "query", "schema": {"type": "string", "format": "date-time"}}, "DateTo": {"name": "date_to", "in": "query", "schema": {"type": "string", "format": "date-time"}}, "Timezone": {"name": "timezone", "in": "query", "schema": {"type": "string", "default": "America/Bogota"}}, "ExcludeBots": {"name": "exclude_bots", "in": "query", "schema": {"type": "boolean", "default": true}}, "ExcludePreviews": {"name": "exclude_previews", "in": "query", "schema": {"type": "boolean", "default": true}}, "Granularity": {"name": "granularity", "in": "query", "schema": {"type": "string", "enum": ["minute", "hour", "day", "week", "month"], "default": "hour"}}}, "responses": {"UnauthorizedLegacy": {"description": "JWT inválido o ausente.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}, "example": {"message": "Unauthorized"}}}}, "UnauthorizedApiKey": {"description": "API key inválida o ausente.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}, "example": {"message": "API key inválida o ausente."}}}}, "NotFound": {"description": "Recurso no encontrado.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}}, "paths": {"/health": {"get": {"tags": ["Health"], "summary": "Estado del servicio", "description": "Comprueba que el servicio y la base de datos responden.", "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Health"}}}}}}}, "/{code}": {"get": {"tags": ["Public"], "summary": "Redirigir enlace corto", "description": "Resuelve un código (hashid o `short_tt`), registra la visita con metadata y redirige (302). Bots de vista previa social reciben HTML Open Graph sin incrementar el contador.", "parameters": [{"name": "code", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "utm_source", "in": "query", "schema": {"type": "string"}}, {"name": "utm_medium", "in": "query", "schema": {"type": "string"}}, {"name": "utm_campaign", "in": "query", "schema": {"type": "string"}}], "responses": {"302": {"description": "Redirect a URL destino."}, "404": {"description": "Enlace no encontrado."}, "410": {"description": "Enlace inactivo o expirado."}}}}, "/api/v1/links": {"get": {"tags": ["Links v1"], "summary": "Listar enlaces", "security": [{"ApiKeyAuth": []}], "parameters": [{"$ref": "#/components/parameters/PaginationLimit"}, {"$ref": "#/components/parameters/PaginationOffset"}], "responses": {"200": {"description": "Página de enlaces.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LinkList"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedApiKey"}}}, "post": {"tags": ["Links v1"], "summary": "Acortar URL (dedup)", "description": "201 si es nuevo; 200 si la URL ya existía.", "security": [{"ApiKeyAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LinkCreate"}}}}, "responses": {"201": {"description": "Enlace nuevo.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "200": {"description": "URL ya acortada.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedApiKey"}}}}, "/api/v1/links/{code}": {"get": {"tags": ["Links v1"], "summary": "Detalle de enlace", "security": [{"ApiKeyAuth": []}], "parameters": [{"$ref": "#/components/parameters/ShortCode"}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedApiKey"}, "404": {"$ref": "#/components/responses/NotFound"}}}, "patch": {"tags": ["Links v1"], "summary": "Actualizar enlace", "security": [{"ApiKeyAuth": []}], "parameters": [{"$ref": "#/components/parameters/ShortCode"}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/LinkUpdate"}}}}, "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Link"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedApiKey"}, "404": {"$ref": "#/components/responses/NotFound"}}}, "delete": {"tags": ["Links v1"], "summary": "Eliminar enlace", "security": [{"ApiKeyAuth": []}], "parameters": [{"$ref": "#/components/parameters/ShortCode"}], "responses": {"204": {"description": "Eliminado."}, "401": {"$ref": "#/components/responses/UnauthorizedApiKey"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/links/{code}/visits": {"get": {"tags": ["Links v1"], "summary": "Listar visitas", "security": [{"ApiKeyAuth": []}], "parameters": [{"$ref": "#/components/parameters/ShortCode"}, {"$ref": "#/components/parameters/VisitLimit"}, {"$ref": "#/components/parameters/PaginationOffset"}, {"$ref": "#/components/parameters/DateFrom"}, {"$ref": "#/components/parameters/DateTo"}, {"name": "is_bot", "in": "query", "schema": {"type": "boolean"}}, {"name": "country_code", "in": "query", "schema": {"type": "string"}}, {"name": "device_type", "in": "query", "schema": {"type": "string"}}, {"name": "utm_campaign", "in": "query", "schema": {"type": "string"}}, {"name": "response_type", "in": "query", "schema": {"type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VisitList"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedApiKey"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/links/{code}/stats": {"get": {"tags": ["Analytics v1"], "summary": "Estadísticas agregadas", "description": "Preferir `/analytics` para dashboards completos. `total_visits` es el contador oficial (`visit_count`); `tracked_visits` cuenta filas en `visits`.", "security": [{"ApiKeyAuth": []}], "parameters": [{"$ref": "#/components/parameters/ShortCode"}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/LinkStats"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedApiKey"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/links/{code}/analytics": {"get": {"tags": ["Analytics v1"], "summary": "Snapshot analítico completo", "security": [{"ApiKeyAuth": []}], "parameters": [{"$ref": "#/components/parameters/ShortCode"}, {"$ref": "#/components/parameters/DateFrom"}, {"$ref": "#/components/parameters/DateTo"}, {"$ref": "#/components/parameters/Timezone"}, {"$ref": "#/components/parameters/ExcludeBots"}, {"$ref": "#/components/parameters/ExcludePreviews"}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/LinkAnalytics"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedApiKey"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/v1/links/{code}/analytics/timeseries": {"get": {"tags": ["Analytics v1"], "summary": "Serie temporal de clics", "security": [{"ApiKeyAuth": []}], "parameters": [{"$ref": "#/components/parameters/ShortCode"}, {"$ref": "#/components/parameters/Granularity"}, {"$ref": "#/components/parameters/DateFrom"}, {"$ref": "#/components/parameters/DateTo"}, {"$ref": "#/components/parameters/Timezone"}, {"$ref": "#/components/parameters/ExcludeBots"}, {"$ref": "#/components/parameters/ExcludePreviews"}], "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Timeseries"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedApiKey"}, "404": {"$ref": "#/components/responses/NotFound"}}}}, "/api/users/token": {"post": {"tags": ["Legacy (CakePHP)"], "summary": "Login legacy (JWT)", "description": "Emite JWT compatible con CakePHP. Valida bcrypt `$2y$` contra tabla `users`.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyTokenRequest"}}}}, "responses": {"200": {"description": "Token emitido.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyTokenResponse"}}}}, "404": {"description": "Credenciales inválidas.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}, "example": {"message": "User not found"}}}}}}}, "/api/links": {"get": {"tags": ["Legacy (CakePHP)"], "summary": "Buscar enlace por hash", "security": [{"LegacyJwtAuth": []}], "parameters": [{"$ref": "#/components/parameters/LegacyHashQuery"}], "responses": {"200": {"description": "Lista con 0 o 1 enlace.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyWrapped"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedLegacy"}}}, "post": {"tags": ["Legacy (CakePHP)"], "summary": "Crear enlace o devolver existente (dedup)", "description": "URL nueva → `null`. URL existente → `{ url, shorten, url_long }` (raíz JSON, sin wrapper). Asigna `user_id` desde JWT `sub`.", "security": [{"LegacyJwtAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyLinkCreate"}}}}, "responses": {"200": {"description": "Creado (null) o existente.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyWrapped"}}}}, "400": {"description": "URL ausente.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyWrapped"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedLegacy"}}}}, "/api/viewHash": {"post": {"tags": ["Legacy (CakePHP)"], "summary": "Obtener o regenerar hash (short_tt)", "security": [{"LegacyJwtAuth": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyViewHashRequest"}}}}, "responses": {"200": {"description": "Hash y entidad.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyWrapped"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedLegacy"}, "404": {"description": "Enlace no encontrado."}}}}, "/api/updateLinks": {"get": {"tags": ["Legacy (CakePHP)"], "summary": "Backfill short_tt faltantes", "description": "Rellena códigos desde 2024-01-01 (excluye URLs con `eva.seguro`).", "security": [{"LegacyJwtAuth": []}], "responses": {"200": {"description": "`null` (paridad CakePHP).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyWrapped"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedLegacy"}}}}, "/api/updateshortlinks": {"post": {"tags": ["Legacy (CakePHP)"], "summary": "Actualizar URL destino por short_tt", "description": "Público (sin JWT), como en CakePHP.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyUpdateShortRequest"}}}}, "responses": {"200": {"description": "Resultado de operación.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyWrapped"}}}}}}}, "/api/viewvisitcounts": {"post": {"tags": ["Legacy (CakePHP)"], "summary": "Contador de visitas por short_tt", "description": "Público (sin JWT).", "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyVisitCountRequest"}}}}, "responses": {"200": {"description": "Contador.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyWrapped"}}}}}}}, "/api/latest-links": {"get": {"tags": ["Legacy (CakePHP)"], "summary": "Últimos 10 enlaces", "security": [{"LegacyJwtAuth": []}], "responses": {"200": {"description": "Lista url/short_tt.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LegacyWrapped"}}}}, "401": {"$ref": "#/components/responses/UnauthorizedLegacy"}}}}}}