Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,44 @@ make format_check # confere Black e isort
Os alvos aceitam `compose=<arquivo>`; o padrão é `local.yml`. Não publique
arquivos de configuração de ambiente que contenham credenciais.

## API COUNTER e política de acesso

As rotas GET sob `/r51/` oferecem descoberta, relatórios COUNTER R5.1 e
extensões, incluindo downloads. `COUNTER_API_ACCESS_MODE` seleciona:

- **`public` (padrão):** sem `api_key`, quota anônima; com chave válida, quota da chave.
- **`key_required`:** toda leitura de dados exige `api_key` válido.

Chave ausente quando obrigatória, vazia, inválida, revogada, expirada ou repetida
retorna HTTP 401 / COUNTER 2020, sem fallback anônimo.
`/r51/status` é sempre público, ignora chaves e não consome quota.
Swagger (`/r51/`) e schema (`/r51/schema/`) são públicos e descrevem o modo ativo.

| Variável | Padrão | Uso |
|---|---|---|
| `COUNTER_API_ACCESS_MODE` | `public` | `public` ou `key_required`. |
| `COUNTER_API_ANONYMOUS_QUOTA` | `30` | Requisições por IP/janela. |
| `COUNTER_API_KEY_QUOTA` | `300` | Requisições por chave/janela. |
| `COUNTER_API_RATE_WINDOW_SECONDS` | `60` | Janela fixa em segundos, iniciada na primeira requisição. |
| `REDIS_URL` | Obrigatória | Redis compartilhado por todos os workers da API. |

Use quotas e janela positivas. A quota é compartilhada entre rotas e formatos;
se esgotada, retorna 429/1020 com `Retry-After` em segundos. Falhas de Redis
retornam 503/1000 sem executar a consulta, sem fallback local.
Anônimos usam `REMOTE_ADDR`, não `X-Forwarded-For` ou `Forwarded`: proxy/NAT pode
compartilhar quota. Configure o IP somente via proxy confiável que remova headers
externos; instâncias independentes devem usar bancos Redis separados.

Emita uma chave para o consumidor:
```bash
python manage.py issue_counter_api_key 'OCABr backend'
```
Guarde a chave no backend, nunca no browser; envie `api_key` via HTTPS e não
registre query strings em logs, APM ou erros. A chave só é exibida uma vez.

O acesso anônimo diverge da seção 8.2 do COUNTER R5.1; nenhum modo implica
certificação COUNTER.

## Configuração das tarefas

As opções de parsing aceitam listas separadas por vírgulas:
Expand Down
31 changes: 22 additions & 9 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,10 @@
"root": {"level": "INFO", "handlers": ["console"]},
}

# Redis
# ------------------------------------------------------------------------------
REDIS_URL = env.str("REDIS_URL")

# Celery
# ------------------------------------------------------------------------------
if USE_TZ:
Expand Down Expand Up @@ -468,9 +472,18 @@
"TITLE": "SciELO COUNTER API",
"DESCRIPTION": (
"COUNTER Project Release 5.1 API with SciELO extensions. "
"Access mode is configured as public (optional api_key for a higher quota) "
"or key_required. Supplied invalid, empty, expired or revoked keys are "
"rejected, except on status, which is always public and unmetered. "
"Anonymous access diverges from COUNTER R5.1 section 8.2; this API "
"description does not assert COUNTER certification. "
"Report availability follows completed months in OpenSearch."
),
"VERSION": "5.1",
"POSTPROCESSING_HOOKS": [
"drf_spectacular.hooks.postprocess_schema_enums",
"counter_api.openapi.apply_access_policy",
],
"SERVE_INCLUDE_SCHEMA": False,
"SERVE_AUTHENTICATION": [],
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
Expand All @@ -479,15 +492,6 @@
"displayOperationId": True,
"persistAuthorization": False,
},
"APPEND_COMPONENTS": {
"securitySchemes": {
"CounterAPIKey": {
"type": "apiKey",
"in": "query",
"name": "api_key",
}
}
},
}

# JWT
Expand Down Expand Up @@ -539,6 +543,15 @@
COUNTER_CUSTOMER_ID = env("COUNTER_CUSTOMER_ID", default="0000000000000000")
COUNTER_INSTITUTION_NAME = env("COUNTER_INSTITUTION_NAME", default="The World")
COUNTER_CREATED_BY = env("COUNTER_CREATED_BY", default="SciELO")

COUNTER_API_ACCESS_MODE = env.str("COUNTER_API_ACCESS_MODE", default="public")
COUNTER_API_ANONYMOUS_QUOTA = env.int("COUNTER_API_ANONYMOUS_QUOTA", default=30)
COUNTER_API_KEY_QUOTA = env.int("COUNTER_API_KEY_QUOTA", default=300)
COUNTER_API_RATE_WINDOW_SECONDS = env.int("COUNTER_API_RATE_WINDOW_SECONDS", default=60)

if COUNTER_API_ACCESS_MODE not in {"public", "key_required"}:
raise ValueError("COUNTER_API_ACCESS_MODE must be public or key_required")

COUNTER_QUERY_BUDGET_SECONDS = env.int(
"COUNTER_QUERY_BUDGET_SECONDS",
default=25,
Expand Down
38 changes: 32 additions & 6 deletions counter_api/access.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,51 @@
from django.conf import settings
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.views import APIView

from counter_api.exceptions import CounterAPIError
from counter_api.models import APIKey
from counter_api.negotiation import CounterContentNegotiation
from counter_api.rate_limit import RateLimitExceeded, check_rate_limit


class CounterAPIView(APIView):
"""Read-only COUNTER policy, independent of global JWT/admin access rules."""

access_exempt = False

authentication_classes = []
permission_classes = []
renderer_classes = [JSONRenderer]
content_negotiation_class = CounterContentNegotiation
http_method_names = ["get"]
throttle_classes = []

def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if request.method != "GET" or self.access_exempt:
return

supplied = "api_key" in request.query_params
key = None
if supplied:
values = request.query_params.getlist("api_key")
if len(values) == 1:
key = APIKey.authenticate(values[0])

if (supplied and key is None) or (
not supplied and settings.COUNTER_API_ACCESS_MODE == "key_required"
):
raise CounterAPIError(2020, "APIKey Invalid", 401)

check_rate_limit(request, key)

def get_authentication_error(request):
key = APIKey.authenticate(request.query_params.get("api_key"))
if key:
return None
def handle_exception(self, exc):
if isinstance(exc, CounterAPIError):
headers = {}
if isinstance(exc, RateLimitExceeded):
headers["Retry-After"] = str(exc.retry_after)

error = CounterAPIError(2020, "APIKey Invalid", 401)
return Response(exc.as_dict(), status=exc.status_code, headers=headers)

return Response(error.as_dict(), status=error.status_code)
return super().handle_exception(exc)
33 changes: 10 additions & 23 deletions counter_api/extensions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.response import Response

from counter_api.access import CounterAPIView, get_authentication_error
from counter_api.access import CounterAPIView
from counter_api.availability import available_months
from counter_api.constants import METRICS
from counter_api.dates import parse_period
Expand All @@ -29,7 +29,6 @@
from counter_api.extensions.ranking import RankingQuery
from counter_api.openapi import (
BEGIN_DATE_PARAMETER,
COUNTER_AUTH,
END_DATE_PARAMETER,
EXCEL_CONTENT_TYPE,
PLATFORM_PARAMETER,
Expand All @@ -52,7 +51,7 @@ def _download_response(payload, kind, platform, output_format):
class RankingView(CounterAPIView):
@extend_schema(
tags=["SciELO extensions"],
auth=COUNTER_AUTH,
auth=[],
operation_id="extension_ranking",
summary="Rank descendants of a collection, journal or book",
parameters=[
Expand Down Expand Up @@ -80,10 +79,6 @@ class RankingView(CounterAPIView):
},
)
def get(self, request):
authentication_error = get_authentication_error(request)
if authentication_error:
return authentication_error

try:
output_format = request.query_params.get("format", "json")
if output_format not in DOWNLOAD_FORMATS:
Expand Down Expand Up @@ -113,17 +108,13 @@ def get(self, request):
class CapabilitiesView(CounterAPIView):
@extend_schema(
tags=["SciELO extensions"],
auth=COUNTER_AUTH,
auth=[],
operation_id="extension_capabilities",
summary="Discover SciELO extension capabilities for a platform",
parameters=[PLATFORM_PARAMETER],
responses={200: CapabilitiesSerializer, 400: ExceptionSerializer},
)
def get(self, request):
authentication_error = get_authentication_error(request)
if authentication_error:
return authentication_error

try:
platform = get_platform(request.query_params.get("platform"))
months = available_months(platform)
Expand All @@ -145,20 +136,20 @@ def get(self, request):
"distribution_dimensions": list(DISTRIBUTION_CAPABILITIES),
"country_language_granularity": "year",
"other_granularity": "month",
"first_month_available": months[0].strftime("%Y-%m")
if months
else None,
"last_month_available": months[-1].strftime("%Y-%m")
if months
else None,
"first_month_available": (
months[0].strftime("%Y-%m") if months else None
),
"last_month_available": (
months[-1].strftime("%Y-%m") if months else None
),
}
)


class DistributionView(CounterAPIView):
@extend_schema(
tags=["SciELO extensions"],
auth=COUNTER_AUTH,
auth=[],
operation_id="extension_distribution",
summary="Distribute accesses by one dimension or a supported pair",
parameters=[
Expand All @@ -180,10 +171,6 @@ class DistributionView(CounterAPIView):
},
)
def get(self, request):
authentication_error = get_authentication_error(request)
if authentication_error:
return authentication_error

try:
output_format = request.query_params.get("format", "json")
if output_format not in DOWNLOAD_FORMATS:
Expand Down
69 changes: 67 additions & 2 deletions counter_api/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from counter_api.constants import REPORTS

EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
COUNTER_AUTH = [{"CounterAPIKey": []}]


class ExceptionSerializer(serializers.Serializer):
Expand Down Expand Up @@ -225,8 +224,74 @@ class CounterReportSerializer(serializers.Serializer):
(200, EXCEL_CONTENT_TYPE): OpenApiTypes.BINARY,
(200, "text/tab-separated-values"): OpenApiTypes.BINARY,
400: ExceptionSerializer,
401: ExceptionSerializer,
403: ExceptionSerializer,
404: OpenApiResponse(description="Report unavailable for the platform."),
503: ExceptionSerializer,
}


def apply_access_policy(result, generator, request, public):
"""Describe the running mode without duplicating policy on every view."""
required = settings.COUNTER_API_ACCESS_MODE == "key_required"
result["info"]["description"] += (
f" Current access mode: {settings.COUNTER_API_ACCESS_MODE}. "
f"Fixed-window quotas: {settings.COUNTER_API_ANONYMOUS_QUOTA} anonymous / "
f"{settings.COUNTER_API_KEY_QUOTA} per key, across all data routes, per "
f"{settings.COUNTER_API_RATE_WINDOW_SECONDS} seconds. "
"Redis failures deny data access with HTTP 503 / COUNTER 1000."
)

result["components"].setdefault("securitySchemes", {})["CounterAPIKey"] = {
"type": "apiKey",
"in": "query",
"name": "api_key",
"description": (
"Required in key_required mode; optional in public mode for the higher "
"quota. A supplied key must be nonempty, active and unexpired. "
"Use HTTPS and redact query strings from logs. Status ignores keys."
),
}

error_schema = {
"type": "object",
"required": ["Code", "Message"],
"properties": {
"Code": {"type": "integer"},
"Message": {"type": "string"},
"Data": {"type": "string"},
},
}

for path, operations in result["paths"].items():
if not path.startswith("/r51/") or "get" not in operations:
continue

operation = operations["get"]
if path == "/r51/status":
operation["security"] = []
operation["description"] = "Always public, ignores keys, no rate limit."
continue

operation["security"] = [{"CounterAPIKey": []}]
if not required:
operation["security"].append({})

for code, description in (
("401", "COUNTER 2020: missing required key or supplied invalid key."),
("429", "COUNTER 1020: rate quota exhausted. Retry after the window."),
("503", "COUNTER 1000: service or shared rate limiter unavailable."),
):
response = {
"description": description,
"content": {"application/json": {"schema": error_schema}},
}
if code == "429":
response["headers"] = {
"Retry-After": {
"description": "Seconds until the fixed window expires.",
"schema": {"type": "integer", "minimum": 1},
}
}
operation["responses"][code] = response

return result
Loading
Loading