From a85e5eb6d76681490ed2db2ff443f40136033e81 Mon Sep 17 00:00:00 2001 From: Pablo <28235457+pablodz@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:31:06 +0000 Subject: [PATCH] Separa SDKs y verifica artefactos independientes por commit --- .github/workflows/ci.yml | 93 ++----- .github/workflows/packages.yml | 41 --- .github/workflows/source-watch.yml | 20 -- README.md | 139 ++-------- conformance/cases.json | 49 ---- .../reniec/identity.prototype.schema.json | 22 -- contracts/sources.lock.json | 29 -- contracts/sunat/validity.schema.json | 31 --- docs/architecture.md | 32 +-- docs/compatibility.md | 30 --- docs/publishing.md | 31 +++ ecosystem.lock.json | 24 ++ examples/offline.py | 10 - sdk-go/LICENSE | 21 -- sdk-go/examples/offline/main.go | 30 --- sdk-go/go.mod | 3 - sdk-go/reniec/mock.go | 44 ---- sdk-go/reniec/mock_test.go | 59 ----- sdk-go/reniec/models.gen.go | 36 --- sdk-go/sunat/auth.go | 105 -------- sdk-go/sunat/errors.go | 15 -- sdk-go/sunat/http.go | 88 ------- sdk-go/sunat/models.gen.go | 66 ----- sdk-go/sunat/validity.go | 144 ---------- sdk-go/sunat/validity_test.go | 247 ------------------ sdk-python/LICENSE | 21 -- sdk-python/README.md | 22 -- sdk-python/pyproject.toml | 19 -- sdk-python/src/peru_sdk/__init__.py | 1 - sdk-python/src/peru_sdk/_http.py | 60 ----- sdk-python/src/peru_sdk/errors.py | 9 - sdk-python/src/peru_sdk/reniec/__init__.py | 32 --- sdk-python/src/peru_sdk/reniec/models.py | 32 --- sdk-python/src/peru_sdk/sunat/__init__.py | 6 - sdk-python/src/peru_sdk/sunat/auth.py | 77 ------ sdk-python/src/peru_sdk/sunat/models.py | 64 ----- sdk-python/src/peru_sdk/sunat/validity.py | 75 ------ sdk-python/tests/test_sdk.py | 210 --------------- tools/check_contracts.py | 38 --- tools/check_ecosystem.py | 93 +++++++ tools/check_packages.py | 54 ---- tools/check_sources.py | 33 --- tools/generate.py | 149 ----------- tools/requirements.txt | 3 +- 44 files changed, 201 insertions(+), 2206 deletions(-) delete mode 100644 .github/workflows/packages.yml delete mode 100644 .github/workflows/source-watch.yml delete mode 100644 conformance/cases.json delete mode 100644 contracts/reniec/identity.prototype.schema.json delete mode 100644 contracts/sources.lock.json delete mode 100644 contracts/sunat/validity.schema.json delete mode 100644 docs/compatibility.md create mode 100644 docs/publishing.md create mode 100644 ecosystem.lock.json delete mode 100644 examples/offline.py delete mode 100644 sdk-go/LICENSE delete mode 100644 sdk-go/examples/offline/main.go delete mode 100644 sdk-go/go.mod delete mode 100644 sdk-go/reniec/mock.go delete mode 100644 sdk-go/reniec/mock_test.go delete mode 100644 sdk-go/reniec/models.gen.go delete mode 100644 sdk-go/sunat/auth.go delete mode 100644 sdk-go/sunat/errors.go delete mode 100644 sdk-go/sunat/http.go delete mode 100644 sdk-go/sunat/models.gen.go delete mode 100644 sdk-go/sunat/validity.go delete mode 100644 sdk-go/sunat/validity_test.go delete mode 100644 sdk-python/LICENSE delete mode 100644 sdk-python/README.md delete mode 100644 sdk-python/pyproject.toml delete mode 100644 sdk-python/src/peru_sdk/__init__.py delete mode 100644 sdk-python/src/peru_sdk/_http.py delete mode 100644 sdk-python/src/peru_sdk/errors.py delete mode 100644 sdk-python/src/peru_sdk/reniec/__init__.py delete mode 100644 sdk-python/src/peru_sdk/reniec/models.py delete mode 100644 sdk-python/src/peru_sdk/sunat/__init__.py delete mode 100644 sdk-python/src/peru_sdk/sunat/auth.py delete mode 100644 sdk-python/src/peru_sdk/sunat/models.py delete mode 100644 sdk-python/src/peru_sdk/sunat/validity.py delete mode 100644 sdk-python/tests/test_sdk.py delete mode 100644 tools/check_contracts.py create mode 100644 tools/check_ecosystem.py delete mode 100644 tools/check_packages.py delete mode 100644 tools/check_sources.py delete mode 100644 tools/generate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31cc26c..bbe9450 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,97 +1,40 @@ name: CI - on: push: branches: [main] pull_request: workflow_dispatch: - permissions: contents: read - concurrency: - group: ci-${{ github.ref }} + group: ecosystem-${{ github.ref }} cancel-in-progress: true - -env: - PYTHONDONTWRITEBYTECODE: "1" - jobs: - contracts: - name: Contracts and generation + integration: + name: Independent SDK integration runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 env: TMPDIR: /var/tmp + PYTHONDONTWRITEBYTECODE: "1" steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e with: go-version: stable cache: false - - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with: python-version: "3.14" - run: python -m pip install -r tools/requirements.txt - - run: python tools/check_contracts.py - - run: python tools/generate.py --check - - go: - name: Go ${{ matrix.go }} - runs-on: ubuntu-latest - timeout-minutes: 10 - env: - TMPDIR: /var/tmp - strategy: - fail-fast: false - matrix: - go: ["1.26.x", stable] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + - name: Verify independent repositories and packages + run: | + stage=$(mktemp -d /var/tmp/peru-ecosystem.XXXXXXXX) + echo "ECOSYSTEM_REPORT=$stage" >> "$GITHUB_ENV" + python tools/check_ecosystem.py --output "$stage" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: - go-version: ${{ matrix.go }} - cache: false - - run: go vet ./... - working-directory: sdk-go - - run: go test -race ./... - working-directory: sdk-go - - name: Compile without cgo - run: go test ./... - working-directory: sdk-go - env: - CGO_ENABLED: "0" - - python: - name: Python ${{ matrix.python }} / ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - python: ["3.11", "3.14"] - os: [ubuntu-latest, windows-latest] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: ${{ matrix.python }} - - run: python -m pip install ./sdk-python - - run: python -m unittest discover -s sdk-python/tests -v - - packages: - name: Installed packages - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - TMPDIR: /var/tmp - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: stable - cache: false - - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: "3.14" - - run: python tools/check_packages.py + name: ecosystem-${{ github.sha }} + path: ${{ env.ECOSYSTEM_REPORT }} diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml deleted file mode 100644 index 02c586d..0000000 --- a/.github/workflows/packages.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Build preview distributions - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - TMPDIR: /var/tmp - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: stable - cache: false - - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: "3.14" - - run: python tools/generate.py --check - - run: python tools/check_packages.py - - run: python -m pip install build - - name: Build distributions - shell: bash - run: | - set -euo pipefail - stage=$(mktemp -d /var/tmp/peru-sdk-distributions.XXXXXXXX) - echo "PERU_SDK_DIST=$stage" >> "$GITHUB_ENV" - python -m build sdk-python --outdir "$stage" - git archive --format=tar.gz --output="$stage/peru-sdk-source.tar.gz" HEAD - cd "$stage" - sha256sum *.whl *.tar.gz > SHA256SUMS - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: peru-sdk-preview-${{ github.sha }} - path: ${{ env.PERU_SDK_DIST }} - retention-days: 14 diff --git a/.github/workflows/source-watch.yml b/.github/workflows/source-watch.yml deleted file mode 100644 index 2ebf3b0..0000000 --- a/.github/workflows/source-watch.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Official source watch - -on: - schedule: - - cron: "17 11 * * 1" - workflow_dispatch: - -permissions: - contents: read - -jobs: - compare: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: "3.14" - - run: python tools/check_sources.py diff --git a/README.md b/README.md index b8820dc..ff26e9e 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,42 @@ # Peru SDK -[![CI](https://github.com/DataScienceResearchPeru/peru-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/DataScienceResearchPeru/peru-sdk/actions/workflows/ci.yml) +Ecosistema MIT de SDKs nativos para servicios del Estado peruano. Cada lenguaje tiene implementación, instalación y releases independientes; se comparten contratos y casos de conformidad versionados. -SDKs nativos para integrar servicios del Estado peruano, con contratos comunes y pruebas de conformidad. Licencia MIT. +| Componente | Repositorio | Implementación en revisión | +| --- | --- | --- | +| Go | [peru-sdk-go](https://github.com/DataScienceResearchPeru/peru-sdk-go) | [PR #1](https://github.com/DataScienceResearchPeru/peru-sdk-go/pull/1) | +| Python | [peru-sdk-python](https://github.com/DataScienceResearchPeru/peru-sdk-python) | [PR #1](https://github.com/DataScienceResearchPeru/peru-sdk-python/pull/1) | +| Contratos y generación | [peru-sdk-models](https://github.com/DataScienceResearchPeru/peru-sdk-models) | [PR #1](https://github.com/DataScienceResearchPeru/peru-sdk-models/pull/1) | +| Corpus compartido | [peru-sdk-conformance](https://github.com/DataScienceResearchPeru/peru-sdk-conformance) | [PR #1](https://github.com/DataScienceResearchPeru/peru-sdk-conformance/pull/1) | -**Prueba mínima en desarrollo.** Go y Python ejecutan implementaciones propias: no requieren un motor compartido, PHP, un servidor intermedio ni un compilador adicional para instalar el paquete Python. No hay dependencias de terceros en el runtime de esta prueba. +Este repositorio contiene coordinación, documentación y un verificador de integración real entre commits públicos. El código de cada SDK se mantiene en su repositorio. Hasta fusionar las PRs, las implementaciones se pueden consumir por los commits completos registrados en ecosystem.lock.json. -## Alcance comprobado +## Qué funciona -| Capacidad | Go | Python | Evidencia | -|---|---|---|---| -| Modelos desde JSON Schema | Sí | Sí | Generación reproducible y validación del corpus | -| Consulta Integrada SUNAT | Cliente HTTP | Cliente HTTP | Solicitudes contra servidor local y respuestas sintéticas | -| OAuth2 `client_credentials` | Sí | Sí | Expiración, caché y renovación concurrente con servidor local | -| Consulta de identidad | Proveedor simulado | Proveedor simulado | Datos sintéticos; contrato provisional | -| Integración real con SUNAT o RENIEC | No verificada | No verificada | Sin credenciales ni consultas reales | -| Emisión UBL, firma y CDR | Pendiente | Pendiente | Fuera del alcance inicial | +Go y Python implementan Consulta Integrada SUNAT mediante HTTP y OAuth2 client_credentials, caché/renovación del token, importes decimales exactos y errores diferenciados. También ofrecen un proveedor de identidad sintético para comprobar la extensibilidad de la API. -Las pruebas no acreditan aceptación por una institución. El proveedor RENIEC no consulta personas ni representa un endpoint oficial. El nombre del paquete Python todavía no implica disponibilidad en PyPI. +Las pruebas HTTP se ejecutan contra servidores locales y respuestas sintéticas. No se ha verificado aún el servicio institucional con credenciales reales. El proveedor RENIEC no consulta ni verifica personas. La emisión UBL, firma y CDR permanecen fuera del alcance inicial. -## Probar sin credenciales +## Reproducir la integración -Requisitos: Go 1.26+ y Python 3.11+. Los scripts de generación necesitan ambos; cada SDK funciona independientemente. +Requisitos de desarrollo: Git, Go 1.26+ y Python 3.11+. ```bash -git clone https://github.com/DataScienceResearchPeru/peru-sdk.git -cd peru-sdk -python3 -m pip install ./sdk-python -python3 examples/offline.py -go -C sdk-go run ./examples/offline -``` - -Los ejemplos son locales y no realizan peticiones HTTP. - -### Go - -```go -service, err := reniec.NewMockIdentityService([]reniec.Identity{ - {DNI: "00000001", GivenNames: "PERSONA", FirstSurname: "PRUEBA", SecondSurname: "SINTETICA"}, -}) -if err != nil { - return err -} -person, err := service.Lookup(ctx, reniec.IdentityQuery{DNI: "00000001"}) -``` - -Imports: `github.com/DataScienceResearchPeru/peru-sdk/sdk-go/reniec` y `.../sunat`. Se puede consumir el módulo desde Git; todavía no hay versión estable. Fijar un commit para reproducibilidad. - -### Python - -```python -from peru_sdk.reniec import Identity, IdentityQuery, MockIdentityService - -service = MockIdentityService([ - Identity("00000001", "PERSONA", "PRUEBA", "SINTETICA"), -]) -person = service.lookup(IdentityQuery("00000001")) -``` - -## Cliente SUNAT - -El cliente implementa Consulta Integrada, no emisión de comprobantes. El RUC de la ruta es el de quien consulta; el RUC del cuerpo es el del emisor. La autenticación es `client_credentials`, no la Clave SOL ni el flujo de SIRE/GRE. - -Ejemplo Python para una integración que ya tenga sus datos y credenciales autorizadas: - -```python -from peru_sdk.sunat import OAuth2TokenProvider, ValidityClient, ValidityQuery - -# client_id, client_secret y requester_ruc los proporciona tu aplicación. -tokens = OAuth2TokenProvider(client_id, client_secret) -client = ValidityClient(requester_ruc, tokens) -query = ValidityQuery( - issuer_ruc=issuer_ruc, - document_type="01", - series="F001", - number=123, - issued_on="2026-11-02", - amount="118.00", -) -# Esta llamada sí utiliza la red y los endpoints SUNAT de producción. -result = client.check(query) -``` - -En Go se usan `sunat.NewOAuth2TokenSource`, `sunat.NewValidityClient` y `client.Check(ctx, query)`. También hay proveedores de token estático. No se leen secretos automáticamente del entorno ni se escriben logs. - -`document_status` conserva el código de SUNAT, incluso si es desconocido. El código `0` significa comprobante no informado según el manual: **no es un error de transporte ni una aceptación**. Un resultado tampoco determina deducibilidad tributaria. - -Los importes públicos son strings decimales. Se transmiten como números JSON exactos, sin conversión a `float`. Las fechas públicas usan `YYYY-MM-DD` y se convierten a `dd/mm/yyyy` para esta operación. - -Por defecto: timeout HTTP de 10 segundos, respuesta limitada a 1 MiB, TLS normal y sin redirecciones ni reintentos automáticos. HTTP sin TLS solo se permite en direcciones IP de loopback para pruebas. Go admite cancelación con `context.Context`; Python ofrece timeout por operación HTTP, sin cliente async en esta etapa. Se preservan códigos desconocidos y se ignoran campos adicionales de respuestas compatibles. - -## Contratos y generación - -```text -contracts/ Modelos públicos y procedencia de fuentes -conformance/ Casos sintéticos compartidos -tools/ Generador, comprobaciones y vigilancia de fuentes -sdk-go/ Implementación Go -sdk-python/ Implementación Python -examples/ Ejemplos locales -``` - -El generador soporta un subconjunto explícito de JSON Schema: objetos cerrados con campos obligatorios, strings, patrones compatibles con ambos lenguajes, fechas, enteros acotados y arrays de strings. Rechaza keywords no soportadas. No es un generador universal ni genera la lógica HTTP, fiscal o criptográfica. - -```bash -python3 tools/generate.py -python3 tools/generate.py --check -``` - -La segunda orden falla si un modelo difiere del contrato. Los archivos generados se versionan y no se editan manualmente. - -## Pruebas y CI/CD - -```bash -go -C sdk-go test -race ./... -python3 -m unittest discover -s sdk-python/tests -v python3 -m pip install -r tools/requirements.txt -python3 tools/check_contracts.py -python3 tools/check_packages.py +stage=$(mktemp -d /var/tmp/peru-ecosystem.XXXXXXXX) +python3 tools/check_ecosystem.py --output "$stage" ``` -La suite Python requiere instalar primero `./sdk-python`. Las únicas dependencias adicionales de validación son de desarrollo. `check_packages.py` construye e instala una wheel en un destino aislado, ejecuta sus pruebas fuera del repositorio y compila un consumidor Go externo. Los temporales se crean en subdirectorios únicos de `/var/tmp`. - -En cada push/PR, CI comprueba contratos, generación, ambos SDKs, condiciones de error y consumo del paquete instalado. Incluye Go mínimo/actual, race detector, Go sin cgo y Python 3.11/3.14 en Linux y Windows. Los tests HTTP solo acceden a loopback; las instalaciones de herramientas sí requieren acceso a sus registros. +El comando comprueba las referencias comunes, clona los SDKs por commit, crea artefactos aislados y ejecuta sus pruebas fuera de los checkouts. Además descarga el módulo Go desde GitHub, ejecuta un consumidor y su suite desde el módulo distribuido. Python se instala desde la wheel, usando los tests y el corpus contenidos en el sdist. No requiere repositorios hermanos ni credenciales SUNAT/RENIEC. -El workflow manual **Build preview distributions** produce wheel, sdist, código fuente y hashes como artefactos de GitHub. No publica automáticamente en PyPI ni crea versiones estables. Para Go, una futura versión del módulo ubicado en `sdk-go/` requiere una etiqueta como `sdk-go/v0.1.0`. +El resultado incluye report.json con versiones, commits, referencias y número de casos. Los artefactos se inspeccionan para evitar incluir implementaciones de otro SDK. La generación y sincronización son operaciones explícitas de desarrollo; no ocurren al instalar o usar los SDKs. -El workflow semanal **Official source watch** compara el hash del PDF oficial accesible: salida 0 sin cambios, 1 documento cambiado y 2 fuente inaccesible. Es una señal separada de las regresiones del SDK. No modifica reglas ni demuestra que el documento vigilado sea el más reciente. +## Actualizaciones y releases -## Fuentes, límites y próximos pasos +Cada SDK usa upstream.lock.json y conserva sus modelos y datos de prueba locales. Cambiar un contrato compartido no reescribe ni publica SDKs automáticamente. ecosystem.lock.json solo fija la combinación que verifica este hub. -La procedencia y las limitaciones están en [docs/compatibility.md](docs/compatibility.md); las decisiones en [docs/architecture.md](docs/architecture.md). +Go tiene un workflow Release manual que crea tags y releases después de comprobar los artefactos. Python tiene publicación PyPI mediante OIDC preparada, con validación de tag y versión. Para publicarlo falta configurar el Trusted Publisher en una cuenta PyPI del proyecto. No se ha publicado una versión estable ni un paquete PyPI en esta migración. -Próximos pasos: contrastar el cliente SUNAT con una consulta real autorizada; obtener el contrato y acceso correspondiente de RENIEC; ampliar pruebas de compatibilidad; incorporar una factura UBL acotada con verificación independiente de firma. La publicación estable y las credenciales de registros se configurarán después de comprobar esos hitos. +Orden de fusión: conformidad, modelos, SDKs y este hub. Los repositorios nuevos conservan merge commits para que los hashes fijados sigan siendo alcanzables. El módulo Go nuevo usa github.com/DataScienceResearchPeru/peru-sdk-go; las versiones previas del prototipo conservan su historia en Git. -Este proyecto no está afiliado a SUNAT ni a RENIEC. +Más detalles: [arquitectura](docs/architecture.md) y [publicación](docs/publishing.md). Los workflows usan runners de GitHub; no se ha modificado k3s ni añadido runners al host. diff --git a/conformance/cases.json b/conformance/cases.json deleted file mode 100644 index e9b2c09..0000000 --- a/conformance/cases.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "version": 1, - "provenance": "Casos sintéticos escritos para este proyecto a partir del manual oficial; ninguna respuesta proviene de una consulta real.", - "query": {"issuer_ruc": "20000000001", "document_type": "01", "series": "F001", "number": 123, "issued_on": "2026-11-02", "amount": "123456789012345.67"}, - "query_cases": [ - {"id": "valid", "patch": {}, "valid": true}, - {"id": "zero-amount", "patch": {"amount": "0.00"}, "valid": true}, - {"id": "unknown-document-code", "patch": {"document_type": "ZZ"}, "valid": true}, - {"id": "short-ruc", "patch": {"issuer_ruc": "123"}, "valid": false}, - {"id": "unicode-digits", "patch": {"issuer_ruc": "20000000001"}, "valid": false}, - {"id": "invalid-day", "patch": {"issued_on": "2026-02-30"}, "valid": false}, - {"id": "non-iso-date", "patch": {"issued_on": "02/11/2026"}, "valid": false}, - {"id": "compact-date", "patch": {"issued_on": "20261102"}, "valid": false}, - {"id": "negative-amount", "patch": {"amount": "-1.00"}, "valid": false}, - {"id": "too-many-decimals", "patch": {"amount": "1.001"}, "valid": false}, - {"id": "exponent", "patch": {"amount": "1e2"}, "valid": false}, - {"id": "float-input", "patch": {"amount": 0.1}, "valid": false}, - {"id": "zero-number", "patch": {"number": 0}, "valid": false}, - {"id": "boolean-number", "patch": {"number": true}, "valid": false}, - {"id": "overflow-number", "patch": {"number": 100000000}, "valid": false}, - {"id": "lowercase-series", "patch": {"series": "f001"}, "valid": false}, - {"id": "injection-amount", "patch": {"amount": "0,\"extra\":1"}, "valid": false} - ], - "response_cases": [ - {"id": "accepted-integer", "body": {"success": true, "data": {"estadoCp": 1, "estadoRuc": "00", "condDomiRuc": "00"}}, "expected": {"document_status": "1", "issuer_status": "00", "issuer_address_status": "00", "observations": []}}, - {"id": "not-found-is-successful-query", "body": {"success": true, "data": {"estadoCp": "0", "estadoRuc": "00", "condDomiRuc": "00", "observaciones": []}}, "expected": {"document_status": "0", "issuer_status": "00", "issuer_address_status": "00", "observations": []}}, - {"id": "unknown-codes-preserved", "body": {"success": true, "future": 42, "data": {"estadoCp": 99, "estadoRuc": "98", "condDomiRuc": "97", "observaciones": ["Observación sintética"]}}, "expected": {"document_status": "99", "issuer_status": "98", "issuer_address_status": "97", "observations": ["Observación sintética"]}}, - {"id": "manual-capitalization", "body": {"success": true, "data": {"estadoCp": 2, "estadoRuc": "00", "condDomiRuc": "12", "Observaciones": ["Prueba"]}}, "expected": {"document_status": "2", "issuer_status": "00", "issuer_address_status": "12", "observations": ["Prueba"]}}, - {"id": "null-observations", "body": {"success": true, "data": {"estadoCp": 1, "estadoRuc": "00", "condDomiRuc": "00", "observaciones": null}}, "expected": {"document_status": "1", "issuer_status": "00", "issuer_address_status": "00", "observations": []}}, - {"id": "remote-error", "body": {"success": false, "errorCode": "0100", "message": "No registrar este texto"}, "error": "remote"}, - {"id": "missing-success", "body": {"data": {}}, "error": "protocol"}, - {"id": "string-success", "body": {"success": "true", "data": {}}, "error": "protocol"}, - {"id": "missing-data", "body": {"success": true}, "error": "protocol"}, - {"id": "null-data", "body": {"success": true, "data": null}, "error": "protocol"}, - {"id": "missing-status", "body": {"success": true, "data": {"estadoRuc": "00", "condDomiRuc": "00"}}, "error": "protocol"}, - {"id": "boolean-status", "body": {"success": true, "data": {"estadoCp": true, "estadoRuc": "00", "condDomiRuc": "00"}}, "error": "protocol"}, - {"id": "fractional-status", "body": {"success": true, "data": {"estadoCp": 1.5, "estadoRuc": "00", "condDomiRuc": "00"}}, "error": "protocol"}, - {"id": "null-status", "body": {"success": true, "data": {"estadoCp": null, "estadoRuc": "00", "condDomiRuc": "00"}}, "error": "protocol"}, - {"id": "invalid-observation", "body": {"success": true, "data": {"estadoCp": 1, "estadoRuc": "00", "condDomiRuc": "00", "observaciones": [null]}}, "error": "protocol"}, - {"id": "html-instead-of-json", "raw_body": "Servicio no disponible", "error": "protocol"}, - {"id": "truncated-json", "raw_body": "{\"success\":true,", "error": "protocol"} - ], - "identity_records": [{"dni": "00000001", "given_names": "PERSONA", "first_surname": "PRUEBA", "second_surname": "SINTETICA"}], - "identity_cases": [ - {"id": "synthetic-match", "dni": "00000001", "found": true}, - {"id": "synthetic-missing", "dni": "00000002", "found": false}, - {"id": "invalid-dni", "dni": "123", "invalid": true} - ] -} diff --git a/contracts/reniec/identity.prototype.schema.json b/contracts/reniec/identity.prototype.schema.json deleted file mode 100644 index f3798ee..0000000 --- a/contracts/reniec/identity.prototype.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:peru-sdk:reniec:identity:prototype:1", - "description": "Prototipo local. No describe el protocolo oficial de RENIEC ni acredita acceso al servicio.", - "$defs": { - "IdentityQuery": { - "type": "object", "additionalProperties": false, - "required": ["dni"], - "properties": {"dni": {"type": "string", "pattern": "^[0-9]{8}$"}} - }, - "Identity": { - "type": "object", "additionalProperties": false, - "required": ["dni", "given_names", "first_surname", "second_surname"], - "properties": { - "dni": {"type": "string", "pattern": "^[0-9]{8}$"}, - "given_names": {"type": "string"}, - "first_surname": {"type": "string"}, - "second_surname": {"type": "string"} - } - } - } -} diff --git a/contracts/sources.lock.json b/contracts/sources.lock.json deleted file mode 100644 index 7fee418..0000000 --- a/contracts/sources.lock.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "retrieved_on": "2026-11-03", - "sources": [ - { - "id": "sunat-consulta-integrada-manual", - "title": "Manual de Consulta Integrada de Validez de CdP por Servicio WEB", - "url": "https://cdn.www.gob.pe/uploads/document/file/536289/Manual_de_Consulta_Integrada_de_Validez_de_CdP_por_Servicio_WEB.pdf", - "publication_page": "https://www.gob.pe/institucion/sunat/informes-publicaciones/454600-manual-de-consulta-integrada-de-validez-de-comprobantes-de-pago", - "sha256": "9a008cb9f31249bdbc30b4d8244cbe46b8389aa5902066634b6d81beefaf14f8", - "bytes": 1204474, - "sections": ["B: generación de token", "C: consulta integrada", "Anexo: códigos"], - "note": "El hash detecta cambios de bytes; no acredita vigencia normativa ni equivalencia con otras copias." - } - ], - "additional_references": [ - { - "url": "https://cpe.sunat.gob.pe/sites/default/files/inline-files/Manual-de-Consulta-Integrada-de-Comprobante-de-Pago-por-ServicioWEB_v2_0.pdf", - "note": "Referencia oficial de lectura. Descarga directa respondió HTTP 403 al establecer la línea base; no se inventó un hash." - }, - { - "url": "https://www.gob.pe/7311-consultar-la-validez-del-comprobante-de-pago", - "note": "Indica hasta 15 enteros y 2 decimales. El PDF usa n(8,2); conservamos la precisión exacta y documentamos la discrepancia." - }, - { - "url": "https://www.gob.pe/13543-solicitar-acceso-al-servicio-de-verificacion-de-identidad-de-personas-en-reniec", - "note": "Solo acredita la existencia y condiciones de acceso al servicio. El contrato RENIEC de este repositorio es provisional." - } - ] -} diff --git a/contracts/sunat/validity.schema.json b/contracts/sunat/validity.schema.json deleted file mode 100644 index 21ec960..0000000 --- a/contracts/sunat/validity.schema.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:peru-sdk:sunat:validity:1", - "description": "Contrato inicial para consulta de comprobantes electrónicos; no emite ni determina deducibilidad.", - "$defs": { - "ValidityQuery": { - "type": "object", - "additionalProperties": false, - "required": ["issuer_ruc", "document_type", "series", "number", "issued_on", "amount"], - "properties": { - "issuer_ruc": {"type": "string", "pattern": "^[0-9]{11}$", "description": "RUC del emisor. Solo se comprueba la estructura localmente."}, - "document_type": {"type": "string", "pattern": "^[A-Z0-9]{2}$", "description": "Código abierto; los códigos futuros no requieren un enum nuevo."}, - "series": {"type": "string", "pattern": "^[A-Z0-9]{4}$"}, - "number": {"type": "integer", "minimum": 1, "maximum": 99999999}, - "issued_on": {"type": "string", "format": "date", "description": "Fecha civil ISO YYYY-MM-DD; el adaptador la convierte al formato SUNAT."}, - "amount": {"type": "string", "pattern": "^(0|[1-9][0-9]{0,14})([.][0-9]{1,2})?$", "description": "Decimal exacto; se transmite como número JSON sin pasar por float."} - } - }, - "ValidityResult": { - "type": "object", - "additionalProperties": false, - "required": ["document_status", "issuer_status", "issuer_address_status", "observations"], - "properties": { - "document_status": {"type": "string", "pattern": "^[0-9]+$"}, - "issuer_status": {"type": "string"}, - "issuer_address_status": {"type": "string"}, - "observations": {"type": "array", "items": {"type": "string"}} - } - } - } -} diff --git a/docs/architecture.md b/docs/architecture.md index f2469e4..3ddf56a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,28 +1,18 @@ -# Decisiones iniciales +# SDKs independientes y contratos compartidos -## Implementación y contrato +peru-sdk coordina el ecosistema y comprueba integración entre commits concretos. Las implementaciones y credenciales de publicación pertenecen a los repositorios de cada SDK. -Go y Python son implementaciones nativas independientes. No se distribuye un motor compartido ni se ejecutan subprocesos para prestar las operaciones del SDK. Compartimos contratos de datos, procedencia y casos de conformidad, no implementación de negocio. +- peru-sdk-conformance mantiene los casos sintéticos y su manifiesto de integridad. +- peru-sdk-models mantiene esquemas, fuentes y generación por lenguaje. +- peru-sdk-go mantiene el módulo Go, su corpus local y sus releases. +- peru-sdk-python mantiene el paquete Python, su corpus local y publicación PyPI. -Los modelos se generan desde un subconjunto limitado de JSON Schema 2020-12. El generador es una herramienta de desarrollo escrita en Python y utiliza gofmt; no forma parte de los paquetes instalables. Los adaptadores HTTP, OAuth y normalización de respuesta se implementan en cada lenguaje y se contrastan con el mismo corpus. +Cada SDK fija commits completos y hashes de los insumos comunes en upstream.lock.json. Los modelos y el corpus se conservan dentro del SDK: instalar, usar o ejecutar sus pruebas nativas no requiere un checkout hermano. Regenerar es una operación explícita de desarrollo, comprobada en un job sin permisos de publicación. -La lógica de emisión fiscal no existe todavía. No creamos un sistema de plugins, un motor de reglas, una ABI o una abstracción universal de instituciones antes de tener casos que los necesiten. +ecosystem.lock.json fija los componentes comprobados juntos. Una corrección de un SDK puede publicarse independientemente; el lock del hub se actualiza para comprobar la nueva combinación. No es necesario que las versiones públicas coincidan. -## Fronteras +El verificador descarga cada SDK por commit, construye su distribución y la consume fuera del checkout. También descarga el módulo Go público y ejecuta sus pruebas desde un consumidor externo. Audita archivos para impedir incluir otro SDK en un paquete. Los informes registran commits y hashes. -- Cada cliente recibe su proveedor de token y opciones de transporte. No hay credenciales globales. -- OAuth de Consulta Integrada se separa del cliente de consulta. Su token se almacena en memoria con renovación sincronizada y margen de expiración. -- Los errores públicos no incluyen cuerpos remotos, tokens ni secretos. El código institucional, cuando es un string acotado seguro, queda disponible por separado. -- Los errores de consulta se separan del estado del comprobante. Un código de estado desconocido se conserva. -- RENIEC tiene una interfaz y un proveedor sintético; la interfaz es provisional hasta conocer el contrato autorizado. -- No hay reintentos automáticos ni promesa de exactamente una ejecución. +Los repositorios nuevos permiten merge commits y deshabilitan squash/rebase para conservar las referencias completas fijadas en locks. Si se decide reescribir esas referencias, deben actualizarse los locks y comprobarse la integración. -## Versiones y publicación - -El paquete Python empieza en 0.1.0.dev0. Los contratos tienen identificadores propios; su forma actual no es una promesa de estabilidad de producción. Antes de una release se revisarán cambios públicos y se etiquetará el módulo Go con el prefijo de su subdirectorio. - -CI verifica paquetes instalados antes de producir distribuciones de prueba. No se configuran tokens de publicación ni un pipeline de publicación a registros que todavía no administramos. La ejecución manual de distribución produce artefactos y hashes para revisión. - -## Trabajo pendiente que afecta a decisiones futuras - -La comprobación normativa automatizada comienza con un documento descargable. No puede detectar validaciones no publicadas por SUNAT. Las integraciones reales y la vigilancia de otras fuentes se incorporarán como señales separadas de las pruebas locales. Los nuevos lenguajes deberán ejecutar el mismo corpus y conservar su ergonomía propia. +Se conserva el cliente HTTP SUNAT, autenticación client_credentials, importes exactos y errores diferenciados. RENIEC sigue siendo exclusivamente sintético; emisión UBL y firma permanecen pendientes. La compatibilidad institucional real requiere una comprobación autorizada posterior. diff --git a/docs/compatibility.md b/docs/compatibility.md deleted file mode 100644 index cd49d0b..0000000 --- a/docs/compatibility.md +++ /dev/null @@ -1,30 +0,0 @@ -# Evidencia y límites de compatibilidad - -## Fuentes oficiales - -- [Manual de Consulta Integrada](https://www.gob.pe/institucion/sunat/informes-publicaciones/454600-manual-de-consulta-integrada-de-validez-de-comprobantes-de-pago): autenticación client_credentials, URL con RUC del consultante, formato del cuerpo y códigos. -- [Referencia CPE del manual v2.0](https://cpe.sunat.gob.pe/sites/default/files/inline-files/Manual-de-Consulta-Integrada-de-Comprobante-de-Pago-por-ServicioWEB_v2_0.pdf): lectura documental; su descarga HTTP directa estaba bloqueada al establecer la línea base. -- [Consulta de validez](https://www.gob.pe/7311-consultar-la-validez-del-comprobante-de-pago): requisitos de consulta y precisión de importes. -- [Acceso RENIEC](https://www.gob.pe/13543-solicitar-acceso-al-servicio-de-verificacion-de-identidad-de-personas-en-reniec): condiciones de acceso; no contiene un contrato de operación suficiente para implementar el cliente real. - -`contracts/sources.lock.json` registra la descarga verificable del PDF alojado en el dominio oficial gob.pe y su SHA-256. No redistribuimos ese documento ni importamos código, plantillas o pruebas de otros proyectos. - -## Decisiones de interpretación - -El manual llama al campo de observaciones `Observaciones`; el lector admite también `observaciones` como compatibilidad defensiva. Los casos son sintéticos: no se afirma haber observado ambas formas en producción. Las observaciones omitidas o null se normalizan a una colección vacía. - -`estadoCp` se documenta como entero y el anexo representa códigos entre comillas. Se admiten enteros no negativos o strings de dígitos; se conserva el código desconocido. No se admiten booleanos, decimales ni un éxito sin datos de estado. Los campos extra se ignoran; no se promete retener el cuerpo original completo en esta versión. - -El PDF señala `monto n(8,2)` y la página de consulta señala hasta 15 enteros y 2 decimales. El contrato admite el límite de la página y lo transporta exactamente; la aceptación de ese extremo por la API debe comprobarse con acceso autorizado. La validación RUC solo verifica once dígitos, no la existencia ni el dígito verificador. - -El contrato inicial exige monto y una serie alfanumérica de cuatro caracteres: es un alcance acotado para comprobantes electrónicos, no soporte completo de documentos físicos ni de todos los casos institucionales. - -## Qué prueban los tests - -Los casos de `conformance/cases.json` son originales y sintéticos. Go y Python verifican los mismos 17 casos de entrada, 17 respuestas y 3 consultas de identidad. Además comprueban en loopback la construcción real de solicitudes, precisión decimal, cache/expiración del token, concurrencia, errores HTTP, límites y ausencia de redirecciones y reintentos. - -Los tests no consultan SUNAT ni RENIEC. Un resultado verde acredita esos contratos y simulaciones, no disponibilidad ni homologación institucional. El proveedor de identidad no descubre ni verifica datos personales. - -## Señales externas - -`Official source watch` diferencia documento cambiado y fuente inaccesible. Un cambio de hash requiere revisión; no demuestra por sí solo una modificación normativa. Un hash igual tampoco garantiza que no exista una publicación más reciente en otra URL. No se modifican reglas ni se envían comprobantes reales desde CI. diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 0000000..a0cb3ff --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,31 @@ +# Publicación por SDK + +## Go + +El módulo está en la raíz de peru-sdk-go. VERSION determina la próxima versión, inicialmente 0.1.0-alpha.1. Release tiene publish=false por defecto: construye una distribución fuente aislada, ejecuta su suite y compila un consumidor externo. + +Después de fusionar, ejecutar Release desde main con publish=true. El job verifica hashes y commit, crea la etiqueta v y la release de GitHub. GITHUB_TOKEN tiene escritura solo en ese job. No sobrescribe etiquetas ni archivos con otro contenido; una reejecución completa archivos faltantes y conserva los ya comprobados. + +Go resuelve el módulo desde Git; no hay que subir un ejecutable ni configurar una cuenta de registro. Después de publicar: go get github.com/DataScienceResearchPeru/peru-sdk-go@v0.1.0-alpha.1. + +## Python + +Release construye wheel y sdist y ejecuta los tests del sdist contra la wheel instalada fuera del checkout. Solo dist/ se envía a PyPI; procedencia y hashes se conservan en manifest/. + +Configurar un Trusted Publisher pendiente o existente en una cuenta PyPI del proyecto: + +| Campo | Valor | +| --- | --- | +| Proyecto | peru-sdk, sujeto a disponibilidad | +| Owner | DataScienceResearchPeru | +| Repository | peru-sdk-python | +| Workflow | release.yml | +| Environment | pypi | + +El tag debe coincidir exactamente con pyproject.toml: inicialmente v0.1.0a1. Su push activa construcción, validación y publicación OIDC. También puede ejecutarse Release sobre esa etiqueta con publish=true. La ejecución manual predeterminada solo prepara artefactos. La publicación externa necesita esa configuración inicial de cuenta; no se ha completado una publicación PyPI en esta migración. + +## Otros ecosistemas e infraestructura + +Cada lenguaje nuevo tendrá empaquetado propio y consumirá la batería común por versión. Java/Maven y TypeScript/npm todavía no están implementados; no se crean paquetes vacíos en sus registros. + +Los jobs ordinarios no reciben credenciales de registros. La configuración k3s no cambia: estos workflows usan runners de GitHub. No se añaden runners al host. diff --git a/ecosystem.lock.json b/ecosystem.lock.json new file mode 100644 index 0000000..86c88e3 --- /dev/null +++ b/ecosystem.lock.json @@ -0,0 +1,24 @@ +{ + "format_version": 1, + "models": { + "repository": "peru-sdk-models", + "revision": "fbd07a48d26a9094014c94f0dfe2de2cdd0718d7" + }, + "conformance": { + "repository": "peru-sdk-conformance", + "revision": "96f6b934f2269865d9a524154537f0a579617083", + "sha256": "77d947fcb161b763413c85469d05e0a33d4162c4aebd8636c4890d490200d751" + }, + "sdks": { + "go": { + "repository": "peru-sdk-go", + "revision": "a2e43621d7b978b2c36572fdb996c2980437da21", + "upstream_lock_sha256": "db906596d6a52a36dd86441c873d42227af610ae1f0cff8d7d50fef25f640aa7" + }, + "python": { + "repository": "peru-sdk-python", + "revision": "9d93bd023bdc188e22a9616684dfc87cbc691607", + "upstream_lock_sha256": "241dd4e54f4d832134189f0fcd37ced39108529a11a69afa2e14aed22e765fad" + } + } +} diff --git a/examples/offline.py b/examples/offline.py deleted file mode 100644 index b86deed..0000000 --- a/examples/offline.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Run after installing sdk-python; uses synthetic data and makes no network calls.""" -from peru_sdk.reniec import Identity, IdentityQuery, MockIdentityService -from peru_sdk.sunat import parse_validity_response - -service = MockIdentityService([Identity("00000001", "PERSONA", "PRUEBA", "SINTETICA")]) -print("Identidad sintética:", service.lookup(IdentityQuery("00000001"))) -result = parse_validity_response( - b'{"success":true,"data":{"estadoCp":1,"estadoRuc":"00","condDomiRuc":"00"}}' -) -print("Estado de comprobante simulado:", result.document_status) diff --git a/sdk-go/LICENSE b/sdk-go/LICENSE deleted file mode 100644 index 71f63ee..0000000 --- a/sdk-go/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 DataScienceResearchPeru - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/sdk-go/examples/offline/main.go b/sdk-go/examples/offline/main.go deleted file mode 100644 index 362e442..0000000 --- a/sdk-go/examples/offline/main.go +++ /dev/null @@ -1,30 +0,0 @@ -// This example uses synthetic data and makes no network calls. -package main - -import ( - "context" - "fmt" - "log" - - "github.com/DataScienceResearchPeru/peru-sdk/sdk-go/reniec" - "github.com/DataScienceResearchPeru/peru-sdk/sdk-go/sunat" -) - -func main() { - service, err := reniec.NewMockIdentityService([]reniec.Identity{ - {DNI: "00000001", GivenNames: "PERSONA", FirstSurname: "PRUEBA", SecondSurname: "SINTETICA"}, - }) - if err != nil { - log.Fatal(err) - } - person, err := service.Lookup(context.Background(), reniec.IdentityQuery{DNI: "00000001"}) - if err != nil { - log.Fatal(err) - } - fmt.Println("Identidad sintética:", person.GivenNames) - result, err := sunat.ParseValidityResponse([]byte(`{"success":true,"data":{"estadoCp":1,"estadoRuc":"00","condDomiRuc":"00"}}`)) - if err != nil { - log.Fatal(err) - } - fmt.Println("Estado de comprobante simulado:", result.DocumentStatus) -} diff --git a/sdk-go/go.mod b/sdk-go/go.mod deleted file mode 100644 index 00b7cd3..0000000 --- a/sdk-go/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/DataScienceResearchPeru/peru-sdk/sdk-go - -go 1.26.0 diff --git a/sdk-go/reniec/mock.go b/sdk-go/reniec/mock.go deleted file mode 100644 index bd11d39..0000000 --- a/sdk-go/reniec/mock.go +++ /dev/null @@ -1,44 +0,0 @@ -// Package reniec defines a provisional identity interface with a synthetic provider only. -// It does not implement an official RENIEC endpoint or verify a person's identity. -package reniec - -import ( - "context" - "errors" -) - -var ErrNotFound = errors.New("synthetic identity not found") - -type IdentityService interface { - Lookup(context.Context, IdentityQuery) (Identity, error) -} - -type MockIdentityService struct{ records map[string]Identity } - -func NewMockIdentityService(records []Identity) (*MockIdentityService, error) { - s := &MockIdentityService{records: make(map[string]Identity, len(records))} - for _, record := range records { - if err := record.Validate(); err != nil { - return nil, err - } - if _, exists := s.records[record.DNI]; exists { - return nil, errors.New("duplicate synthetic identity") - } - s.records[record.DNI] = record - } - return s, nil -} - -func (s *MockIdentityService) Lookup(ctx context.Context, query IdentityQuery) (Identity, error) { - if err := ctx.Err(); err != nil { - return Identity{}, err - } - if err := query.Validate(); err != nil { - return Identity{}, err - } - record, exists := s.records[query.DNI] - if !exists { - return Identity{}, ErrNotFound - } - return record, nil -} diff --git a/sdk-go/reniec/mock_test.go b/sdk-go/reniec/mock_test.go deleted file mode 100644 index c57034b..0000000 --- a/sdk-go/reniec/mock_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package reniec - -import ( - "context" - "encoding/json" - "errors" - "os" - "testing" -) - -func TestSharedSyntheticIdentities(t *testing.T) { - var cases struct { - Records []Identity `json:"identity_records"` - Cases []struct { - ID, DNI string - Found, Invalid bool - } `json:"identity_cases"` - } - body, err := os.ReadFile("../../conformance/cases.json") - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(body, &cases); err != nil { - t.Fatal(err) - } - service, err := NewMockIdentityService(cases.Records) - if err != nil { - t.Fatal(err) - } - var _ IdentityService = service - for _, c := range cases.Cases { - t.Run(c.ID, func(t *testing.T) { - result, err := service.Lookup(context.Background(), IdentityQuery{DNI: c.DNI}) - if c.Invalid { - if err == nil { - t.Fatal("invalid DNI accepted") - } - return - } - if !c.Found { - if !errors.Is(err, ErrNotFound) { - t.Fatalf("got %v", err) - } - return - } - if err != nil || result != cases.Records[0] { - t.Fatalf("wrong synthetic identity: %#v, %v", result, err) - } - }) - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if _, err := service.Lookup(ctx, IdentityQuery{DNI: "00000001"}); !errors.Is(err, context.Canceled) { - t.Fatal("cancellation ignored") - } - if _, err := NewMockIdentityService(append(cases.Records, cases.Records[0])); err == nil { - t.Fatal("duplicate record accepted") - } -} diff --git a/sdk-go/reniec/models.gen.go b/sdk-go/reniec/models.gen.go deleted file mode 100644 index a03879f..0000000 --- a/sdk-go/reniec/models.gen.go +++ /dev/null @@ -1,36 +0,0 @@ -// Contract models. Regenerate with python3 tools/generate.py. -package reniec - -import ( - "fmt" - "regexp" -) - -type IdentityQuery struct { - DNI string `json:"dni"` -} - -func (m IdentityQuery) Validate() error { - if !identityQueryDNIPattern.MatchString(m.DNI) { - return fmt.Errorf("dni: invalid format") - } - return nil -} - -var identityQueryDNIPattern = regexp.MustCompile("^[0-9]{8}$") - -type Identity struct { - DNI string `json:"dni"` - GivenNames string `json:"given_names"` - FirstSurname string `json:"first_surname"` - SecondSurname string `json:"second_surname"` -} - -func (m Identity) Validate() error { - if !identityDNIPattern.MatchString(m.DNI) { - return fmt.Errorf("dni: invalid format") - } - return nil -} - -var identityDNIPattern = regexp.MustCompile("^[0-9]{8}$") diff --git a/sdk-go/sunat/auth.go b/sdk-go/sunat/auth.go deleted file mode 100644 index bcf9686..0000000 --- a/sdk-go/sunat/auth.go +++ /dev/null @@ -1,105 +0,0 @@ -package sunat - -import ( - "context" - "encoding/json" - "net/http" - "net/url" - "strings" - "sync" - "time" -) - -const validityScope = "https://api.sunat.gob.pe/v1/contribuyente/contribuyentes" - -type TokenSource interface { - Token(context.Context) (string, error) -} - -type staticToken struct{ value string } - -func (s *staticToken) String() string { return "StaticToken([redacted])" } -func (s *staticToken) GoString() string { return s.String() } -func (s *staticToken) Token(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - if !validToken(s.value) { - return "", &Error{Kind: "configuration", Operation: "token"} - } - return s.value, nil -} - -// StaticToken accepts an already obtained access token without network access. -func StaticToken(value string) TokenSource { return &staticToken{value: value} } - -type OAuth2Options struct { - ClientID string - ClientSecret string - // TokenURL overrides the complete token endpoint; HTTPS or literal loopback only. - TokenURL string - HTTPClient *http.Client -} - -func (o OAuth2Options) String() string { return "OAuth2Options([redacted])" } -func (o OAuth2Options) GoString() string { return o.String() } - -// OAuth2TokenSource implements the client_credentials flow for Consulta Integrada. -// It is not the SOL/password authentication flow used by other SUNAT services. -type OAuth2TokenSource struct { - mu sync.Mutex - clientID, secret, endpoint string - client *http.Client - token string - expires time.Time - now func() time.Time -} - -func (s *OAuth2TokenSource) String() string { return "OAuth2TokenSource([redacted])" } -func (s *OAuth2TokenSource) GoString() string { return s.String() } - -func NewOAuth2TokenSource(options OAuth2Options) (*OAuth2TokenSource, error) { - if strings.TrimSpace(options.ClientID) == "" || strings.TrimSpace(options.ClientSecret) == "" { - return nil, &Error{Kind: "configuration", Operation: "token"} - } - endpoint := options.TokenURL - if endpoint == "" { - endpoint = "https://api-seguridad.sunat.gob.pe/v1/clientesextranet/" + url.PathEscape(options.ClientID) + "/oauth2/token/" - } - if _, err := checkedEndpoint(endpoint); err != nil { - return nil, err - } - return &OAuth2TokenSource{clientID: options.ClientID, secret: options.ClientSecret, endpoint: endpoint, client: httpClient(options.HTTPClient), now: time.Now}, nil -} - -func (s *OAuth2TokenSource) Token(ctx context.Context) (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - if err := ctx.Err(); err != nil { - return "", err - } - if s.now().Before(s.expires) { - return s.token, nil - } - form := url.Values{"grant_type": {"client_credentials"}, "scope": {validityScope}, "client_id": {s.clientID}, "client_secret": {s.secret}} - started := s.now() - body, err := post(ctx, s.client, "token", s.endpoint, "application/x-www-form-urlencoded", "", []byte(form.Encode())) - if err != nil { - return "", err - } - var response struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` - } - if json.Unmarshal(body, &response) != nil || !validToken(response.AccessToken) || !strings.EqualFold(response.TokenType, "Bearer") || response.ExpiresIn <= 0 || response.ExpiresIn > 86400*365 { - return "", &Error{Kind: "protocol", Operation: "token"} - } - ttl := time.Duration(response.ExpiresIn) * time.Second - skew := ttl / 10 - if skew > 30*time.Second { - skew = 30 * time.Second - } - s.token, s.expires = response.AccessToken, started.Add(ttl-skew) - return s.token, nil -} diff --git a/sdk-go/sunat/errors.go b/sdk-go/sunat/errors.go deleted file mode 100644 index ac81b99..0000000 --- a/sdk-go/sunat/errors.go +++ /dev/null @@ -1,15 +0,0 @@ -package sunat - -import "fmt" - -// Error exposes stable categories without including credentials or remote bodies. -type Error struct { - Kind string - Operation string - HTTPStatus int - Code string -} - -func (e *Error) Error() string { - return fmt.Sprintf("%s: %s (HTTP %d)", e.Operation, e.Kind, e.HTTPStatus) -} diff --git a/sdk-go/sunat/http.go b/sdk-go/sunat/http.go deleted file mode 100644 index b737c34..0000000 --- a/sdk-go/sunat/http.go +++ /dev/null @@ -1,88 +0,0 @@ -package sunat - -import ( - "bytes" - "context" - "io" - "net" - "net/http" - "net/url" - "strings" - "time" -) - -const maxResponseBytes = 1 << 20 - -func checkedEndpoint(raw string) (string, error) { - u, err := url.Parse(raw) - if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { - return "", &Error{Kind: "configuration", Operation: "endpoint"} - } - ip := net.ParseIP(u.Hostname()) - if u.Scheme != "https" && !(u.Scheme == "http" && ip != nil && ip.IsLoopback()) { - return "", &Error{Kind: "configuration", Operation: "endpoint"} - } - return strings.TrimRight(raw, "/"), nil -} - -func httpClient(client *http.Client) *http.Client { - var copy http.Client - if client != nil { - copy = *client - } - if copy.Timeout == 0 { - copy.Timeout = 10 * time.Second - } - copy.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } - return © -} - -func post(ctx context.Context, client *http.Client, operation, endpoint, contentType, token string, body []byte) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return nil, &Error{Kind: "configuration", Operation: operation} - } - req.Header.Set("Content-Type", contentType) - req.Header.Set("Accept", "application/json") - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - res, err := client.Do(req) - if err != nil { - if ctx.Err() != nil { - return nil, ctx.Err() - } - return nil, &Error{Kind: "transport", Operation: operation} - } - defer res.Body.Close() - if res.StatusCode < 200 || res.StatusCode >= 300 { - kind := "http" - if res.StatusCode == 401 { - kind = "authentication" - } - if res.StatusCode == 403 { - kind = "authorization" - } - return nil, &Error{Kind: kind, Operation: operation, HTTPStatus: res.StatusCode} - } - data, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes+1)) - if err != nil { - return nil, &Error{Kind: "transport", Operation: operation} - } - if len(data) > maxResponseBytes { - return nil, &Error{Kind: "protocol", Operation: operation} - } - return data, nil -} - -func validToken(token string) bool { - if strings.TrimSpace(token) == "" { - return false - } - for _, c := range token { - if c <= 32 || c >= 127 { - return false - } - } - return true -} diff --git a/sdk-go/sunat/models.gen.go b/sdk-go/sunat/models.gen.go deleted file mode 100644 index 3d749bc..0000000 --- a/sdk-go/sunat/models.gen.go +++ /dev/null @@ -1,66 +0,0 @@ -// Contract models. Regenerate with python3 tools/generate.py. -package sunat - -import ( - "fmt" - "regexp" - "time" -) - -type ValidityQuery struct { - IssuerRUC string `json:"issuer_ruc"` - DocumentType string `json:"document_type"` - Series string `json:"series"` - Number int64 `json:"number"` - IssuedOn string `json:"issued_on"` - Amount string `json:"amount"` -} - -func (m ValidityQuery) Validate() error { - if !validityQueryIssuerRUCPattern.MatchString(m.IssuerRUC) { - return fmt.Errorf("issuer_ruc: invalid format") - } - if !validityQueryDocumentTypePattern.MatchString(m.DocumentType) { - return fmt.Errorf("document_type: invalid format") - } - if !validityQuerySeriesPattern.MatchString(m.Series) { - return fmt.Errorf("series: invalid format") - } - if m.Number < 1 { - return fmt.Errorf("number: out of range") - } - if m.Number > 99999999 { - return fmt.Errorf("number: out of range") - } - if t, err := time.Parse("2006-01-02", m.IssuedOn); err != nil || t.Format("2006-01-02") != m.IssuedOn { - return fmt.Errorf("issued_on: expected YYYY-MM-DD") - } - if !validityQueryAmountPattern.MatchString(m.Amount) { - return fmt.Errorf("amount: invalid format") - } - return nil -} - -var validityQueryIssuerRUCPattern = regexp.MustCompile("^[0-9]{11}$") -var validityQueryDocumentTypePattern = regexp.MustCompile("^[A-Z0-9]{2}$") -var validityQuerySeriesPattern = regexp.MustCompile("^[A-Z0-9]{4}$") -var validityQueryAmountPattern = regexp.MustCompile("^(0|[1-9][0-9]{0,14})([.][0-9]{1,2})?$") - -type ValidityResult struct { - DocumentStatus string `json:"document_status"` - IssuerStatus string `json:"issuer_status"` - IssuerAddressStatus string `json:"issuer_address_status"` - Observations []string `json:"observations"` -} - -func (m ValidityResult) Validate() error { - if !validityResultDocumentStatusPattern.MatchString(m.DocumentStatus) { - return fmt.Errorf("document_status: invalid format") - } - if m.Observations == nil { - return fmt.Errorf("observations: expected an array") - } - return nil -} - -var validityResultDocumentStatusPattern = regexp.MustCompile("^[0-9]+$") diff --git a/sdk-go/sunat/validity.go b/sdk-go/sunat/validity.go deleted file mode 100644 index 6fe9465..0000000 --- a/sdk-go/sunat/validity.go +++ /dev/null @@ -1,144 +0,0 @@ -// Package sunat provides a native Consulta Integrada client. Live compatibility is not yet certified. -package sunat - -import ( - "context" - "encoding/json" - "net/http" - "regexp" - "time" - "unicode/utf8" -) - -type ValidityOptions struct { - RequesterRUC string - TokenSource TokenSource - APIBaseURL string - HTTPClient *http.Client -} - -type ValidityClient struct { - requesterRUC string - tokens TokenSource - baseURL string - http *http.Client -} - -func NewValidityClient(options ValidityOptions) (*ValidityClient, error) { - if !regexp.MustCompile(`^[0-9]{11}$`).MatchString(options.RequesterRUC) || options.TokenSource == nil { - return nil, &Error{Kind: "configuration", Operation: "validity"} - } - base := options.APIBaseURL - if base == "" { - base = "https://api.sunat.gob.pe" - } - base, err := checkedEndpoint(base) - if err != nil { - return nil, err - } - return &ValidityClient{requesterRUC: options.RequesterRUC, tokens: options.TokenSource, baseURL: base, http: httpClient(options.HTTPClient)}, nil -} - -// Check queries the service. A successful HTTP exchange is not a positive document status. -// There are no implicit retries and no hidden calls other than token acquisition when required. -func (c *ValidityClient) Check(ctx context.Context, query ValidityQuery) (ValidityResult, error) { - if err := query.Validate(); err != nil { - return ValidityResult{}, err - } - date, _ := time.Parse("2006-01-02", query.IssuedOn) - request := struct { - Issuer string `json:"numRuc"` - Type string `json:"codComp"` - Series string `json:"numeroSerie"` - Number int64 `json:"numero"` - Date string `json:"fechaEmision"` - Amount json.Number `json:"monto"` - }{query.IssuerRUC, query.DocumentType, query.Series, query.Number, date.Format("02/01/2006"), json.Number(query.Amount)} - body, err := json.Marshal(request) - if err != nil { - return ValidityResult{}, &Error{Kind: "validation", Operation: "validity"} - } - token, err := c.tokens.Token(ctx) - if err != nil { - return ValidityResult{}, err - } - if !validToken(token) { - return ValidityResult{}, &Error{Kind: "configuration", Operation: "token"} - } - endpoint := c.baseURL + "/v1/contribuyente/contribuyentes/" + c.requesterRUC + "/validarcomprobante" - data, err := post(ctx, c.http, "validity", endpoint, "application/json", token, body) - if err != nil { - return ValidityResult{}, err - } - return ParseValidityResponse(data) -} - -// ParseValidityResponse accepts numeric or digit-string status codes and preserves unknown codes. -func ParseValidityResponse(body []byte) (ValidityResult, error) { - failure := func() (ValidityResult, error) { - return ValidityResult{}, &Error{Kind: "protocol", Operation: "validity"} - } - if len(body) > maxResponseBytes || !utf8.Valid(body) { - return failure() - } - var response map[string]json.RawMessage - if json.Unmarshal(body, &response) != nil || response == nil { - return failure() - } - var success bool - if string(response["success"]) != "true" && string(response["success"]) != "false" { - return failure() - } - _ = json.Unmarshal(response["success"], &success) - if !success { - var code string - _ = json.Unmarshal(response["errorCode"], &code) - if !regexp.MustCompile(`^[A-Za-z0-9_.-]{0,64}$`).MatchString(code) { - code = "" - } - return ValidityResult{}, &Error{Kind: "remote", Operation: "validity", Code: code} - } - var data map[string]json.RawMessage - if json.Unmarshal(response["data"], &data) != nil || data == nil { - return failure() - } - readCode := func(name string) (string, bool) { - raw := data[name] - var code string - if json.Unmarshal(raw, &code) == nil { - return code, regexp.MustCompile(`^[0-9]+$`).MatchString(code) - } - return string(raw), regexp.MustCompile(`^[0-9]+$`).Match(raw) - } - status, ok := readCode("estadoCp") - if !ok { - return failure() - } - ruc, ok := readCode("estadoRuc") - if !ok { - return failure() - } - address, ok := readCode("condDomiRuc") - if !ok { - return failure() - } - observations := []string{} - raw := data["observaciones"] - if len(raw) == 0 { - raw = data["Observaciones"] - } - if len(raw) != 0 && string(raw) != "null" { - var entries []json.RawMessage - if json.Unmarshal(raw, &entries) != nil { - return failure() - } - for _, entry := range entries { - var value string - if string(entry) == "null" || json.Unmarshal(entry, &value) != nil { - return failure() - } - observations = append(observations, value) - } - } - return ValidityResult{DocumentStatus: status, IssuerStatus: ruc, IssuerAddressStatus: address, Observations: observations}, nil -} diff --git a/sdk-go/sunat/validity_test.go b/sdk-go/sunat/validity_test.go deleted file mode 100644 index 77a6c80..0000000 --- a/sdk-go/sunat/validity_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package sunat - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "reflect" - "strings" - "sync" - "sync/atomic" - "testing" - "time" -) - -func TestSharedConformance(t *testing.T) { - var cases struct { - Query map[string]json.RawMessage `json:"query"` - Queries []struct { - ID string - Patch map[string]json.RawMessage - Valid bool - } `json:"query_cases"` - Responses []struct { - ID string - Body json.RawMessage - RawBody string `json:"raw_body"` - Expected ValidityResult - Error string - } `json:"response_cases"` - } - data, err := os.ReadFile("../../conformance/cases.json") - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(data, &cases); err != nil { - t.Fatal(err) - } - for _, c := range cases.Queries { - t.Run("query/"+c.ID, func(t *testing.T) { - merged := map[string]json.RawMessage{} - for k, v := range cases.Query { - merged[k] = v - } - for k, v := range c.Patch { - merged[k] = v - } - body, _ := json.Marshal(merged) - var query ValidityQuery - err := json.Unmarshal(body, &query) - if err == nil { - err = query.Validate() - } - if (err == nil) != c.Valid { - t.Fatalf("valid=%v, error=%v", c.Valid, err) - } - }) - } - for _, c := range cases.Responses { - t.Run("response/"+c.ID, func(t *testing.T) { - body := c.Body - if c.RawBody != "" { - body = []byte(c.RawBody) - } - result, err := ParseValidityResponse(body) - if c.Error != "" { - var sdkError *Error - if !errors.As(err, &sdkError) || sdkError.Kind != c.Error { - t.Fatalf("expected %s, got %v", c.Error, err) - } - return - } - if err != nil || !reflect.DeepEqual(result, c.Expected) { - t.Fatalf("got %#v, %v; want %#v", result, err, c.Expected) - } - }) - } -} - -func query() ValidityQuery { - return ValidityQuery{IssuerRUC: "20000000001", DocumentType: "01", Series: "F001", Number: 123, IssuedOn: "2026-11-02", Amount: "123456789012345.67"} -} - -const accepted = `{"success":true,"data":{"estadoCp":1,"estadoRuc":"00","condDomiRuc":"00"}}` - -func TestHTTPExchangeAndTokenCache(t *testing.T) { - var tokens atomic.Int32 - var queries atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Error("expected POST") - } - if r.URL.Path == "/token" { - tokens.Add(1) - if err := r.ParseForm(); err != nil { - t.Error(err) - } - if r.Form.Get("grant_type") != "client_credentials" || r.Form.Get("scope") != validityScope || r.Form.Get("client_id") != "synthetic-client" || r.Form.Get("client_secret") != "synthetic-secret" { - t.Error("incorrect token request") - } - fmt.Fprint(w, `{"access_token":"synthetic-token","token_type":"Bearer","expires_in":100}`) - return - } - queries.Add(1) - if r.URL.Path != "/v1/contribuyente/contribuyentes/20000000002/validarcomprobante" { - t.Error("path must use requester RUC") - } - if r.Header.Get("Authorization") != "Bearer synthetic-token" || r.Header.Get("Content-Type") != "application/json" { - t.Error("incorrect headers") - } - body, _ := io.ReadAll(r.Body) - var payload map[string]json.RawMessage - if err := json.Unmarshal(body, &payload); err != nil { - t.Error(err) - } - if string(payload["monto"]) != "123456789012345.67" || string(payload["fechaEmision"]) != `"02/11/2026"` || string(payload["numRuc"]) != `"20000000001"` || string(payload["numero"]) != "123" { - t.Errorf("wrong payload: %s", body) - } - fmt.Fprint(w, accepted) - })) - defer server.Close() - source, err := NewOAuth2TokenSource(OAuth2Options{ClientID: "synthetic-client", ClientSecret: "synthetic-secret", TokenURL: server.URL + "/token"}) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1000, 0) - source.now = func() time.Time { return now } - client, err := NewValidityClient(ValidityOptions{RequesterRUC: "20000000002", TokenSource: source, APIBaseURL: server.URL}) - if err != nil { - t.Fatal(err) - } - for range 2 { - if _, err := client.Check(context.Background(), query()); err != nil { - t.Fatal(err) - } - } - if tokens.Load() != 1 || queries.Load() != 2 { - t.Fatal("token not reused") - } - now = now.Add(91 * time.Second) - if _, err := client.Check(context.Background(), query()); err != nil { - t.Fatal(err) - } - if tokens.Load() != 2 { - t.Fatal("expired token not refreshed") - } -} - -func TestConcurrentTokenRefresh(t *testing.T) { - var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) - fmt.Fprint(w, `{"access_token":"test-token","token_type":"Bearer","expires_in":100}`) - })) - defer server.Close() - source, _ := NewOAuth2TokenSource(OAuth2Options{ClientID: "test", ClientSecret: "secret", TokenURL: server.URL}) - var wg sync.WaitGroup - for range 10 { - wg.Add(1) - go func() { - defer wg.Done() - if _, err := source.Token(context.Background()); err != nil { - t.Error(err) - } - }() - } - wg.Wait() - if calls.Load() != 1 { - t.Fatal("concurrent refresh made duplicate requests") - } -} - -func TestHTTPFailuresAndRedirects(t *testing.T) { - for _, status := range []int{302, 401, 403, 429, 500} { - t.Run(fmt.Sprint(status), func(t *testing.T) { - var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) - w.Header().Set("Location", "http://127.0.0.1:1/leak") - w.WriteHeader(status) - fmt.Fprint(w, "synthetic-secret") - })) - defer server.Close() - client, _ := NewValidityClient(ValidityOptions{RequesterRUC: "20000000002", TokenSource: StaticToken("test-token"), APIBaseURL: server.URL}) - _, err := client.Check(context.Background(), query()) - var e *Error - if !errors.As(err, &e) || e.HTTPStatus != status || calls.Load() != 1 { - t.Fatalf("wrong error or retry: %v", err) - } - if strings.Contains(err.Error(), "synthetic-secret") { - t.Fatal("remote body leaked") - } - }) - } -} - -func TestResponseBoundAndCancellation(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, strings.Repeat("x", maxResponseBytes+1)) })) - defer server.Close() - client, _ := NewValidityClient(ValidityOptions{RequesterRUC: "20000000002", TokenSource: StaticToken("token"), APIBaseURL: server.URL}) - _, err := client.Check(context.Background(), query()) - var e *Error - if !errors.As(err, &e) || e.Kind != "protocol" { - t.Fatalf("expected bounded response, got %v", err) - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if _, err := client.Check(ctx, query()); !errors.Is(err, context.Canceled) { - t.Fatalf("expected cancellation: %v", err) - } -} - -func TestConfigurationAndValidationBeforeNetwork(t *testing.T) { - for _, endpoint := range []string{"http://example.com", "https://user:password@example.com", "https://example.com?token=secret"} { - if _, err := NewValidityClient(ValidityOptions{RequesterRUC: "20000000002", TokenSource: StaticToken("token"), APIBaseURL: endpoint}); err == nil { - t.Fatal("insecure endpoint allowed") - } - } - client, _ := NewValidityClient(ValidityOptions{RequesterRUC: "20000000002", TokenSource: StaticToken("token"), APIBaseURL: "http://127.0.0.1:1"}) - bad := query() - bad.Amount = "1e2" - if _, err := client.Check(context.Background(), bad); err == nil || !strings.Contains(err.Error(), "amount") { - t.Fatalf("validation should happen before I/O: %v", err) - } - if strings.Contains(fmt.Sprintf("%#v", StaticToken("do-not-log")), "do-not-log") { - t.Fatal("token formatting leaked") - } -} - -func TestMalformedTokenResponses(t *testing.T) { - for _, body := range []string{`{}`, `null`, `{"access_token":"x","token_type":"Bearer","expires_in":0}`, `{"access_token":"x","token_type":"Bearer","expires_in":true}`, `{"access_token":"x","token_type":"Other","expires_in":10}`} { - t.Run(body, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) })) - defer server.Close() - source, _ := NewOAuth2TokenSource(OAuth2Options{ClientID: "id", ClientSecret: "secret", TokenURL: server.URL}) - _, err := source.Token(context.Background()) - var e *Error - if !errors.As(err, &e) || e.Kind != "protocol" { - t.Fatalf("bad token response accepted: %v", err) - } - }) - } -} diff --git a/sdk-python/LICENSE b/sdk-python/LICENSE deleted file mode 100644 index 71f63ee..0000000 --- a/sdk-python/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 DataScienceResearchPeru - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/sdk-python/README.md b/sdk-python/README.md deleted file mode 100644 index 9a042b4..0000000 --- a/sdk-python/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Peru SDK para Python - -Prueba mínima nativa, Python 3.11+, sin dependencias de runtime de terceros. Incluye Consulta Integrada SUNAT y un proveedor de identidad exclusivamente sintético. No implementa emisión, firma ni un cliente oficial RENIEC. - -Instalar desde el checkout con `python3 -m pip install ./sdk-python`. El nombre declarado del paquete no implica publicación en PyPI. La versión inicial es `0.1.0.dev0`. - -```python -from peru_sdk.reniec import Identity, IdentityQuery, MockIdentityService - -service = MockIdentityService([ - Identity("00000001", "PERSONA", "PRUEBA", "SINTETICA"), -]) -print(service.lookup(IdentityQuery("00000001"))) -``` - -Para SUNAT: `peru_sdk.sunat.ValidityClient`, `ValidityQuery`, `StaticToken` y `OAuth2TokenProvider`. La consulta usa `client_credentials`; los importes se expresan como strings decimales exactos. Se puede comprobar una respuesta sintética sin red con `parse_validity_response`. - -Una llamada a `ValidityClient.check` sí puede consultar SUNAT; el comportamiento real todavía no ha sido verificado con credenciales autorizadas. El proveedor simulado de identidad nunca accede a la red. - -Errores institucionales y de protocolo: `peru_sdk.errors.SDKError`, con `kind`, `operation`, `status` y `code`. Entrada inválida: `ValueError`. Las representaciones de proveedores de token ocultan los secretos; no se formatean cuerpos remotos en las excepciones públicas. - -La documentación, contratos y pruebas de conformidad están en el [repositorio](https://github.com/DataScienceResearchPeru/peru-sdk). Licencia MIT. diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml deleted file mode 100644 index ad2ff78..0000000 --- a/sdk-python/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -[build-system] -requires = ["setuptools>=77"] -build-backend = "setuptools.build_meta" - -[project] -name = "peru-sdk" -version = "0.1.0.dev0" -description = "Clientes nativos para servicios del Estado peruano" -readme = "README.md" -requires-python = ">=3.11" -license = "MIT" -license-files = ["LICENSE"] -dependencies = [] - -[project.urls] -Repository = "https://github.com/DataScienceResearchPeru/peru-sdk" - -[tool.setuptools.packages.find] -where = ["src"] diff --git a/sdk-python/src/peru_sdk/__init__.py b/sdk-python/src/peru_sdk/__init__.py deleted file mode 100644 index 456f065..0000000 --- a/sdk-python/src/peru_sdk/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Native clients for Peruvian government services.""" diff --git a/sdk-python/src/peru_sdk/_http.py b/sdk-python/src/peru_sdk/_http.py deleted file mode 100644 index cafbfb2..0000000 --- a/sdk-python/src/peru_sdk/_http.py +++ /dev/null @@ -1,60 +0,0 @@ -import ipaddress -from urllib.error import HTTPError, URLError -from urllib.parse import urlsplit -from urllib.request import HTTPRedirectHandler, Request, build_opener - -from .errors import SDKError - -MAX_RESPONSE_BYTES = 1 << 20 - - -def checked_endpoint(value: str) -> str: - try: - parsed = urlsplit(value) - loopback = False - try: - loopback = ipaddress.ip_address(parsed.hostname or "").is_loopback - except ValueError: - pass - valid = ( - parsed.hostname and parsed.username is None and parsed.password is None - and not parsed.query and not parsed.fragment - and (parsed.scheme == "https" or (parsed.scheme == "http" and loopback)) - ) - parsed.port # Reject malformed ports before a request is made. - except ValueError: - valid = False - if not valid: - raise SDKError("configuration", "endpoint") - return value.rstrip("/") - - -class _NoRedirect(HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - return None - - -class HTTPTransport: - """Bounded stdlib HTTP transport; redirects and automatic retries are disabled.""" - - def __init__(self, timeout: float = 10): - if timeout <= 0: - raise SDKError("configuration", "timeout") - self._timeout = timeout - self._opener = build_opener(_NoRedirect()) - - def post(self, url: str, headers: dict[str, str], body: bytes, operation: str) -> bytes: - request = Request(url, data=body, headers={"Accept": "application/json", **headers}, method="POST") - try: - with self._opener.open(request, timeout=self._timeout) as response: - result = response.read(MAX_RESPONSE_BYTES + 1) - if len(result) > MAX_RESPONSE_BYTES: - raise SDKError("protocol", operation) - return result - except HTTPError as exc: - status = exc.code - exc.close() - kind = {401: "authentication", 403: "authorization"}.get(status, "http") - raise SDKError(kind, operation, status=status) from None - except (URLError, OSError, ValueError): - raise SDKError("transport", operation) from None diff --git a/sdk-python/src/peru_sdk/errors.py b/sdk-python/src/peru_sdk/errors.py deleted file mode 100644 index f29ff2c..0000000 --- a/sdk-python/src/peru_sdk/errors.py +++ /dev/null @@ -1,9 +0,0 @@ -class SDKError(Exception): - """Stable category and operation; remote bodies and secrets are never formatted.""" - - def __init__(self, kind: str, operation: str, *, status: int = 0, code: str = ""): - self.kind = kind - self.operation = operation - self.status = status - self.code = code - super().__init__(f"{operation}: {kind} (HTTP {status})") diff --git a/sdk-python/src/peru_sdk/reniec/__init__.py b/sdk-python/src/peru_sdk/reniec/__init__.py deleted file mode 100644 index 3a13949..0000000 --- a/sdk-python/src/peru_sdk/reniec/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Provisional identity interface. Only synthetic lookups are implemented.""" - -from typing import Protocol - -from ..errors import SDKError -from .models import Identity, IdentityQuery - - -class IdentityService(Protocol): - def lookup(self, query: IdentityQuery) -> Identity: ... - - -class MockIdentityService: - """An in-memory test provider, with no network access or official RENIEC adapter.""" - - def __init__(self, records: list[Identity]): - self._records: dict[str, Identity] = {} - for record in records: - record.__post_init__() - if record.dni in self._records: - raise ValueError("duplicate synthetic identity") - self._records[record.dni] = record - - def lookup(self, query: IdentityQuery) -> Identity: - query.__post_init__() - try: - return self._records[query.dni] - except KeyError: - raise SDKError("not_found", "synthetic_identity") from None - - -__all__ = ["Identity", "IdentityQuery", "IdentityService", "MockIdentityService"] diff --git a/sdk-python/src/peru_sdk/reniec/models.py b/sdk-python/src/peru_sdk/reniec/models.py deleted file mode 100644 index 73e8cf6..0000000 --- a/sdk-python/src/peru_sdk/reniec/models.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Contract models. Regenerate with python3 tools/generate.py.""" -from dataclasses import dataclass -import re - -@dataclass(frozen=True) -class IdentityQuery: - dni: str - - def __post_init__(self) -> None: - if type(self.dni) is not str: - raise ValueError("dni: invalid type") - if re.fullmatch('^[0-9]{8}$', self.dni, flags=re.ASCII) is None: - raise ValueError("dni: invalid format") - -@dataclass(frozen=True) -class Identity: - dni: str - given_names: str - first_surname: str - second_surname: str - - def __post_init__(self) -> None: - if type(self.dni) is not str: - raise ValueError("dni: invalid type") - if re.fullmatch('^[0-9]{8}$', self.dni, flags=re.ASCII) is None: - raise ValueError("dni: invalid format") - if type(self.given_names) is not str: - raise ValueError("given_names: invalid type") - if type(self.first_surname) is not str: - raise ValueError("first_surname: invalid type") - if type(self.second_surname) is not str: - raise ValueError("second_surname: invalid type") diff --git a/sdk-python/src/peru_sdk/sunat/__init__.py b/sdk-python/src/peru_sdk/sunat/__init__.py deleted file mode 100644 index eaeb99a..0000000 --- a/sdk-python/src/peru_sdk/sunat/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .auth import OAuth2TokenProvider, StaticToken, TokenProvider -from .models import ValidityQuery, ValidityResult -from .validity import ValidityClient, parse_validity_response - -__all__ = ["OAuth2TokenProvider", "StaticToken", "TokenProvider", "ValidityQuery", - "ValidityResult", "ValidityClient", "parse_validity_response"] diff --git a/sdk-python/src/peru_sdk/sunat/auth.py b/sdk-python/src/peru_sdk/sunat/auth.py deleted file mode 100644 index fbb9419..0000000 --- a/sdk-python/src/peru_sdk/sunat/auth.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -from threading import Lock -from time import monotonic -from typing import Protocol -from urllib.parse import quote, urlencode - -from .._http import HTTPTransport, checked_endpoint -from ..errors import SDKError - -SCOPE = "https://api.sunat.gob.pe/v1/contribuyente/contribuyentes" - - -def valid_token(value: str) -> bool: - return type(value) is str and bool(value) and all(32 < ord(c) < 127 for c in value) - - -class TokenProvider(Protocol): - def token(self) -> str: ... - - -class StaticToken: - def __init__(self, token: str): - if not valid_token(token): - raise SDKError("configuration", "token") - self._value = token - - def __repr__(self) -> str: - return "StaticToken([redacted])" - - def token(self) -> str: - return self._value - - -class OAuth2TokenProvider: - """Consulta Integrada client_credentials, with expiry-aware in-memory caching.""" - - def __init__(self, client_id: str, client_secret: str, *, token_url: str | None = None, - transport: HTTPTransport | None = None): - if not client_id.strip() or not client_secret.strip(): - raise SDKError("configuration", "token") - self._client_id = client_id - self._secret = client_secret - self._url = token_url or ( - "https://api-seguridad.sunat.gob.pe/v1/clientesextranet/" - + quote(client_id, safe="") + "/oauth2/token/" - ) - checked_endpoint(self._url) - self._transport = transport or HTTPTransport() - self._lock = Lock() - self._access_token = "" - self._expires = 0.0 - self._clock = monotonic - - def __repr__(self) -> str: - return "OAuth2TokenProvider([redacted])" - - def token(self) -> str: - with self._lock: - if self._clock() < self._expires: - return self._access_token - started = self._clock() - form = urlencode({"grant_type": "client_credentials", "scope": SCOPE, - "client_id": self._client_id, "client_secret": self._secret}) - body = self._transport.post(self._url, {"Content-Type": "application/x-www-form-urlencoded"}, - form.encode("ascii"), "token") - try: - result = json.loads(body) - token, ttl, kind = result["access_token"], result["expires_in"], result["token_type"] - if not valid_token(token) or type(ttl) is not int or not 0 < ttl <= 86400 * 365: - raise ValueError - if type(kind) is not str or kind.lower() != "bearer": - raise ValueError - except (ValueError, KeyError, TypeError, UnicodeError): - raise SDKError("protocol", "token") from None - self._access_token = token - self._expires = started + ttl - min(30, ttl / 10) - return token diff --git a/sdk-python/src/peru_sdk/sunat/models.py b/sdk-python/src/peru_sdk/sunat/models.py deleted file mode 100644 index d8c13d0..0000000 --- a/sdk-python/src/peru_sdk/sunat/models.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Contract models. Regenerate with python3 tools/generate.py.""" -from dataclasses import dataclass -from datetime import date -import re - -@dataclass(frozen=True) -class ValidityQuery: - issuer_ruc: str - document_type: str - series: str - number: int - issued_on: str - amount: str - - def __post_init__(self) -> None: - if type(self.issuer_ruc) is not str: - raise ValueError("issuer_ruc: invalid type") - if re.fullmatch('^[0-9]{11}$', self.issuer_ruc, flags=re.ASCII) is None: - raise ValueError("issuer_ruc: invalid format") - if type(self.document_type) is not str: - raise ValueError("document_type: invalid type") - if re.fullmatch('^[A-Z0-9]{2}$', self.document_type, flags=re.ASCII) is None: - raise ValueError("document_type: invalid format") - if type(self.series) is not str: - raise ValueError("series: invalid type") - if re.fullmatch('^[A-Z0-9]{4}$', self.series, flags=re.ASCII) is None: - raise ValueError("series: invalid format") - if type(self.number) is not int: - raise ValueError("number: invalid type") - if self.number < 1: - raise ValueError("number: out of range") - if self.number > 99999999: - raise ValueError("number: out of range") - if type(self.issued_on) is not str: - raise ValueError("issued_on: invalid type") - try: - parsed = date.fromisoformat(self.issued_on) - except ValueError: - raise ValueError("issued_on: invalid date") from None - if parsed.isoformat() != self.issued_on: - raise ValueError("issued_on: expected YYYY-MM-DD") - if type(self.amount) is not str: - raise ValueError("amount: invalid type") - if re.fullmatch('^(0|[1-9][0-9]{0,14})([.][0-9]{1,2})?$', self.amount, flags=re.ASCII) is None: - raise ValueError("amount: invalid format") - -@dataclass(frozen=True) -class ValidityResult: - document_status: str - issuer_status: str - issuer_address_status: str - observations: tuple[str, ...] - - def __post_init__(self) -> None: - if type(self.document_status) is not str: - raise ValueError("document_status: invalid type") - if re.fullmatch('^[0-9]+$', self.document_status, flags=re.ASCII) is None: - raise ValueError("document_status: invalid format") - if type(self.issuer_status) is not str: - raise ValueError("issuer_status: invalid type") - if type(self.issuer_address_status) is not str: - raise ValueError("issuer_address_status: invalid type") - if type(self.observations) is not tuple or any(type(v) is not str for v in self.observations): - raise ValueError("observations: invalid type") diff --git a/sdk-python/src/peru_sdk/sunat/validity.py b/sdk-python/src/peru_sdk/sunat/validity.py deleted file mode 100644 index e46b611..0000000 --- a/sdk-python/src/peru_sdk/sunat/validity.py +++ /dev/null @@ -1,75 +0,0 @@ -from datetime import date -import json -import re - -from .._http import HTTPTransport, MAX_RESPONSE_BYTES, checked_endpoint -from ..errors import SDKError -from .auth import TokenProvider, valid_token -from .models import ValidityQuery, ValidityResult - - -def parse_validity_response(body: bytes) -> ValidityResult: - """Normalize status codes without treating query success as document acceptance.""" - try: - if len(body) > MAX_RESPONSE_BYTES: - raise ValueError - def reject_constant(value): - raise ValueError("non-JSON constant") - response = json.loads(body.decode("utf-8"), parse_constant=reject_constant) - if type(response) is not dict or type(response.get("success")) is not bool: - raise ValueError - if not response["success"]: - code = response.get("errorCode", "") - if type(code) is not str or re.fullmatch(r"[A-Za-z0-9_.-]{0,64}", code) is None: - code = "" - raise SDKError("remote", "validity", code=code) - data = response["data"] - if type(data) is not dict: - raise ValueError - - def read_code(name: str) -> str: - value = data[name] - if type(value) is int and value >= 0: - return str(value) - if type(value) is str and re.fullmatch(r"[0-9]+", value) is not None: - return value - raise ValueError - - observations = data.get("observaciones", data.get("Observaciones")) - if observations is None: - observations = [] - if type(observations) is not list or any(type(v) is not str for v in observations): - raise ValueError - return ValidityResult(read_code("estadoCp"), read_code("estadoRuc"), - read_code("condDomiRuc"), tuple(observations)) - except (ValueError, KeyError, TypeError, UnicodeError, OverflowError): - raise SDKError("protocol", "validity") from None - - -class ValidityClient: - """Read-only Consulta Integrada client. No implicit retries or global credentials.""" - - def __init__(self, requester_ruc: str, token_provider: TokenProvider, *, - base_url: str = "https://api.sunat.gob.pe", transport: HTTPTransport | None = None): - if type(requester_ruc) is not str or re.fullmatch(r"[0-9]{11}", requester_ruc) is None or token_provider is None: - raise SDKError("configuration", "validity") - self._url = (checked_endpoint(base_url) + "/v1/contribuyente/contribuyentes/" - + requester_ruc + "/validarcomprobante") - self._tokens = token_provider - self._transport = transport or HTTPTransport() - - def check(self, query: ValidityQuery) -> ValidityResult: - # Revalidate even when passed an instance created outside the normal constructor. - query.__post_init__() - data = {"numRuc": query.issuer_ruc, "codComp": query.document_type, - "numeroSerie": query.series, "numero": query.number, - "fechaEmision": date.fromisoformat(query.issued_on).strftime("%d/%m/%Y")} - # The contract permits only JSON decimal literals; never round through binary float. - body = (json.dumps(data, separators=(",", ":")).encode("utf-8")[:-1] - + b',"monto":' + query.amount.encode("ascii") + b"}") - token = self._tokens.token() - if not valid_token(token): - raise SDKError("configuration", "token") - response = self._transport.post(self._url, {"Content-Type": "application/json", - "Authorization": "Bearer " + token}, body, "validity") - return parse_validity_response(response) diff --git a/sdk-python/tests/test_sdk.py b/sdk-python/tests/test_sdk.py deleted file mode 100644 index 3d81630..0000000 --- a/sdk-python/tests/test_sdk.py +++ /dev/null @@ -1,210 +0,0 @@ -from concurrent.futures import ThreadPoolExecutor -from dataclasses import asdict -from decimal import Decimal -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -import json -from pathlib import Path -import threading -import unittest -from urllib.parse import parse_qs - -from peru_sdk._http import HTTPTransport, MAX_RESPONSE_BYTES -from peru_sdk.errors import SDKError -from peru_sdk.reniec import Identity, IdentityQuery, MockIdentityService -from peru_sdk.sunat import OAuth2TokenProvider, StaticToken, ValidityClient, ValidityQuery, parse_validity_response - -CASES = json.loads((Path(__file__).resolve().parents[2] / "conformance/cases.json").read_text(encoding="utf-8")) -ACCEPTED = b'{"success":true,"data":{"estadoCp":1,"estadoRuc":"00","condDomiRuc":"00"}}' - - -class Server: - def __init__(self, callback): - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - # Consume the request before replying so close cannot reset an unread body. - self.request_body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) - callback(self) - - def log_message(self, *args): - pass - - self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - - def __enter__(self): - self.thread.start() - self.url = f"http://127.0.0.1:{self.server.server_port}" - return self - - def __exit__(self, *args): - self.server.shutdown() - self.server.server_close() - self.thread.join() - - -def respond(handler, body=ACCEPTED, status=200): - handler.send_response(status) - handler.send_header("Content-Type", "application/json") - handler.send_header("Content-Length", str(len(body))) - handler.end_headers() - handler.wfile.write(body) - - -class SharedConformance(unittest.TestCase): - def test_queries(self): - for case in CASES["query_cases"]: - with self.subTest(case=case["id"]): - data = {**CASES["query"], **case["patch"]} - if case["valid"]: - ValidityQuery(**data) - else: - with self.assertRaises(ValueError): - ValidityQuery(**data) - - def test_responses(self): - for case in CASES["response_cases"]: - with self.subTest(case=case["id"]): - body = case["raw_body"].encode() if "raw_body" in case else json.dumps(case["body"]).encode() - if "error" in case: - with self.assertRaises(SDKError) as caught: - parse_validity_response(body) - self.assertEqual(caught.exception.kind, case["error"]) - else: - result = json.loads(json.dumps(asdict(parse_validity_response(body)))) - self.assertEqual(result, case["expected"]) - - def test_synthetic_identities(self): - records = [Identity(**r) for r in CASES["identity_records"]] - service = MockIdentityService(records) - for case in CASES["identity_cases"]: - with self.subTest(case=case["id"]): - if case.get("invalid"): - with self.assertRaises(ValueError): - IdentityQuery(case["dni"]) - elif case["found"]: - self.assertEqual(service.lookup(IdentityQuery(case["dni"])), records[0]) - else: - with self.assertRaises(SDKError) as caught: - service.lookup(IdentityQuery(case["dni"])) - self.assertEqual(caught.exception.kind, "not_found") - with self.assertRaises(ValueError): - MockIdentityService(records + records) - - -class HTTPTests(unittest.TestCase): - def test_exchange_precision_and_expiry(self): - requests = [] - - def handle(handler): - body = handler.request_body - requests.append((handler.path, dict(handler.headers), body)) - if handler.path == "/token": - respond(handler, b'{"access_token":"synthetic-token","token_type":"Bearer","expires_in":100}') - else: - respond(handler) - - with Server(handle) as server: - tokens = OAuth2TokenProvider("synthetic-client", "synthetic-secret", token_url=server.url + "/token") - clock = [1000.0] - tokens._clock = lambda: clock[0] - client = ValidityClient("20000000002", tokens, base_url=server.url) - for _ in range(2): - self.assertEqual(client.check(ValidityQuery(**CASES["query"])).document_status, "1") - self.assertEqual(sum(path == "/token" for path, _, _ in requests), 1) - clock[0] += 91 - client.check(ValidityQuery(**CASES["query"])) - self.assertEqual(sum(path == "/token" for path, _, _ in requests), 2) - - for path, headers, body in requests: - if path == "/token": - form = parse_qs(body.decode()) - self.assertEqual(form["grant_type"], ["client_credentials"]) - self.assertEqual(form["scope"], ["https://api.sunat.gob.pe/v1/contribuyente/contribuyentes"]) - self.assertEqual(form["client_id"], ["synthetic-client"]) - self.assertEqual(form["client_secret"], ["synthetic-secret"]) - else: - self.assertEqual(path, "/v1/contribuyente/contribuyentes/20000000002/validarcomprobante") - self.assertEqual(headers["Authorization"], "Bearer synthetic-token") - payload = json.loads(body, parse_float=Decimal) - self.assertEqual(payload["numRuc"], "20000000001") - self.assertEqual(payload["fechaEmision"], "02/11/2026") - self.assertEqual(payload["monto"], Decimal("123456789012345.67")) - self.assertEqual(payload["numero"], 123) - - def test_concurrent_token_refresh(self): - requests = [] - - def handle(handler): - requests.append(handler.path) - respond(handler, b'{"access_token":"synthetic-token","token_type":"Bearer","expires_in":100}') - - with Server(handle) as server: - provider = OAuth2TokenProvider("id", "secret", token_url=server.url) - with ThreadPoolExecutor(max_workers=10) as executor: - self.assertEqual(list(executor.map(lambda _: provider.token(), range(10))), ["synthetic-token"] * 10) - self.assertEqual(len(requests), 1) - - def test_statuses_no_redirect_or_retry(self): - for status in (302, 401, 403, 429, 500): - with self.subTest(status=status): - requests = [] - - def handle(handler): - requests.append(handler.path) - handler.send_response(status) - handler.send_header("Location", "http://127.0.0.1:1/leak") - handler.end_headers() - handler.wfile.write(b"synthetic-secret") - - with Server(handle) as server: - client = ValidityClient("20000000002", StaticToken("token"), base_url=server.url) - with self.assertRaises(SDKError) as caught: - client.check(ValidityQuery(**CASES["query"])) - self.assertEqual(caught.exception.status, status) - self.assertNotIn("synthetic-secret", str(caught.exception)) - self.assertEqual(len(requests), 1) - - def test_response_limit(self): - with Server(lambda h: respond(h, b"x" * (MAX_RESPONSE_BYTES + 1))) as server: - client = ValidityClient("20000000002", StaticToken("token"), base_url=server.url) - with self.assertRaises(SDKError) as caught: - client.check(ValidityQuery(**CASES["query"])) - self.assertEqual(caught.exception.kind, "protocol") - - def test_invalid_endpoints_and_safe_repr(self): - for endpoint in ("http://example.com", "https://user:password@example.com", "https://example.com?token=secret"): - with self.assertRaises(SDKError): - ValidityClient("20000000002", StaticToken("token"), base_url=endpoint) - self.assertNotIn("do-not-log", repr(StaticToken("do-not-log"))) - self.assertNotIn("do-not-log", repr(OAuth2TokenProvider("id", "do-not-log"))) - with self.assertRaises(SDKError): - StaticToken("header\ninjection") - - def test_malformed_token_responses(self): - for body in ({}, None, {"access_token": "x", "token_type": "Bearer", "expires_in": 0}, - {"access_token": "x", "token_type": "Bearer", "expires_in": True}, - {"access_token": "x", "token_type": "Other", "expires_in": 10}): - with self.subTest(body=body): - with Server(lambda h: respond(h, json.dumps(body).encode())) as server: - provider = OAuth2TokenProvider("id", "secret", token_url=server.url) - with self.assertRaises(SDKError) as caught: - provider.token() - self.assertEqual(caught.exception.kind, "protocol") - - def test_validation_precedes_token_or_network(self): - class UnexpectedToken: - def token(self): - raise AssertionError("token requested before validation") - - query = ValidityQuery(**CASES["query"]) - object.__setattr__(query, "amount", "1e2") - client = ValidityClient("20000000002", UnexpectedToken()) - with self.assertRaises(ValueError): - client.check(query) - - def test_invalid_timeout(self): - self.assertRaises(SDKError, HTTPTransport, 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/check_contracts.py b/tools/check_contracts.py deleted file mode 100644 index 2b0d162..0000000 --- a/tools/check_contracts.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Verify contract syntax and check synthetic input/output vectors against the schemas.""" -import json -from pathlib import Path - -from jsonschema import Draft202012Validator, FormatChecker - -ROOT = Path(__file__).resolve().parents[1] - - -def main(): - cases = json.loads((ROOT / "conformance/cases.json").read_text(encoding="utf-8")) - sunat = json.loads((ROOT / "contracts/sunat/validity.schema.json").read_text()) - reniec = json.loads((ROOT / "contracts/reniec/identity.prototype.schema.json").read_text()) - for schema in (sunat, reniec): - Draft202012Validator.check_schema(schema) - - def validator(schema, name): - return Draft202012Validator(schema["$defs"][name], format_checker=FormatChecker()) - - query = validator(sunat, "ValidityQuery") - result = validator(sunat, "ValidityResult") - for case in cases["query_cases"]: - value = {**cases["query"], **case["patch"]} - assert query.is_valid(value) == case["valid"], case["id"] - for case in cases["response_cases"]: - if "expected" in case: - result.validate(case["expected"]) - identity = validator(reniec, "Identity") - for record in cases["identity_records"]: - identity.validate(record) - query = validator(reniec, "IdentityQuery") - for case in cases["identity_cases"]: - assert query.is_valid({"dni": case["dni"]}) != case.get("invalid", False), case["id"] - print("Contracts and shared vectors are valid.") - - -if __name__ == "__main__": - main() diff --git a/tools/check_ecosystem.py b/tools/check_ecosystem.py new file mode 100644 index 0000000..ee34e6d --- /dev/null +++ b/tools/check_ecosystem.py @@ -0,0 +1,93 @@ +"""Consume pinned SDK repositories and verify their isolated distributions.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +from urllib.request import urlopen +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +OWNER = "DataScienceResearchPeru" + + +def read_file(repository, revision, path): + if not re.fullmatch(r"peru-sdk-(models|conformance|go|python)", repository) or not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ValueError("expected a component repository and a full immutable commit") + with urlopen(f"https://raw.githubusercontent.com/{OWNER}/{repository}/{revision}/{path}", timeout=30) as response: + body = response.read(2 * 1024 * 1024 + 1) + if len(body) > 2 * 1024 * 1024: + raise ValueError("upstream file exceeds its limit") + return body + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + output = parser.parse_args().output.resolve() + if output.exists() and any(output.iterdir()): + raise SystemExit("verification destination must be empty") + output.mkdir(parents=True, exist_ok=True) + lock = json.loads((ROOT / "ecosystem.lock.json").read_text()) + if lock["format_version"] != 1: + raise SystemExit("unsupported ecosystem lock") + corpus = lock["conformance"] + body = read_file(corpus["repository"], corpus["revision"], "cases.json") + if hashlib.sha256(body).hexdigest() != corpus["sha256"]: + raise SystemExit("shared corpus integrity mismatch") + report = {"models": lock["models"], "conformance": corpus, "sdks": {}} + Path("/var/tmp").mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="peru-ecosystem-", dir="/var/tmp") as directory: + task = Path(directory) + env = {**os.environ, "TMPDIR": directory, "TEMP": directory, "TMP": directory, + "PYTHONDONTWRITEBYTECODE": "1", "PIP_DISABLE_PIP_VERSION_CHECK": "1"} + + def run(*command, cwd=task): + subprocess.run(command, cwd=cwd, env=env, check=True) + + for language, sdk in lock["sdks"].items(): + raw = read_file(sdk["repository"], sdk["revision"], "upstream.lock.json") + if hashlib.sha256(raw).hexdigest() != sdk["upstream_lock_sha256"]: + raise SystemExit("SDK upstream lock integrity mismatch") + upstream = json.loads(raw) + if upstream["language"] != language or upstream["generator"]["revision"] != lock["models"]["revision"]: + raise SystemExit("SDK language or model revision mismatch") + if upstream["generator"]["repository"] != OWNER + "/" + lock["models"]["repository"]: + raise SystemExit("SDK uses another model repository") + if upstream["conformance"]["revision"] != corpus["revision"] or upstream["conformance"]["sha256"] != corpus["sha256"]: + raise SystemExit("SDK uses another conformance corpus") + source = task / language + run("git", "init", "--quiet", str(source)) + run("git", "remote", "add", "origin", f"https://github.com/{OWNER}/{sdk['repository']}.git", cwd=source) + run("git", "fetch", "--quiet", "--depth=1", "origin", sdk["revision"], cwd=source) + run("git", "checkout", "--quiet", "--detach", "FETCH_HEAD", cwd=source) + actual = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=source, text=True).strip() + if actual != sdk["revision"]: + raise SystemExit("checkout did not resolve the expected commit") + run(sys.executable, ".ci/check_package.py", "--output", str(output / language), cwd=source) + report["sdks"][language] = json.loads((output / language / "manifest/release.json").read_text()) + if language == "go": + consumer = task / "public-go-consumer" + consumer.mkdir() + shutil.copyfile(source / "examples/offline/main.go", consumer / "main.go") + run("go", "mod", "init", "consumer.example/published", cwd=consumer) + module = "github.com/" + OWNER + "/" + sdk["repository"] + run("go", "get", module + "@" + sdk["revision"], cwd=consumer) + run("go", "run", ".", cwd=consumer) + run("go", "test", module + "/reniec", module + "/sunat", cwd=consumer) + download = json.loads(subprocess.check_output(["go", "mod", "download", "-json", module], cwd=consumer, env=env, text=True)) + with zipfile.ZipFile(download["Zip"]) as archive: + if any("/src/peru_sdk/" in name or "/sdk-python/" in name or name.endswith((".whl", ".jar")) for name in archive.namelist()): + raise SystemExit("foreign SDK in published Go module") + report["shared_cases"] = sum(len(json.loads(body)[key]) for key in ("query_cases", "response_cases", "identity_cases")) + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n") + print("Verified independent SDKs and public Go module. Report:", output / "report.json") + + +if __name__ == "__main__": + main() diff --git a/tools/check_packages.py b/tools/check_packages.py deleted file mode 100644 index 728173f..0000000 --- a/tools/check_packages.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Build and consume both SDKs outside the repository using task-local temporary files.""" -import os -from pathlib import Path -import subprocess -import sys -import tempfile - -ROOT = Path(__file__).resolve().parents[1] -MODULE = "github.com/DataScienceResearchPeru/peru-sdk/sdk-go" - - -def main(): - Path("/var/tmp").mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix="peru-sdk-packages-", dir="/var/tmp") as task: - stage = Path(task) - env = {**os.environ, "TMPDIR": task, "TEMP": task, "TMP": task, "PIP_DISABLE_PIP_VERSION_CHECK": "1"} - - def run(*args, cwd=stage, extra=None): - subprocess.run(args, cwd=cwd, env={**env, **(extra or {})}, check=True) - - run(sys.executable, "-m", "pip", "wheel", "--no-deps", "--no-cache-dir", "--wheel-dir", str(stage / "wheels"), str(ROOT / "sdk-python")) - wheel, = (stage / "wheels").glob("*.whl") - run(sys.executable, "-m", "pip", "install", "--no-deps", "--no-cache-dir", "--only-binary=:all:", "--target", str(stage / "site"), str(wheel)) - extra = {"PYTHONPATH": str(stage / "site"), "PYTHONDONTWRITEBYTECODE": "1"} - run(sys.executable, "-c", "import peru_sdk; assert 'site/peru_sdk' in peru_sdk.__file__.replace('\\\\', '/')", extra=extra) - run(sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "sdk-python/tests"), "-v", extra=extra) - - consumer = stage / "go-consumer" - consumer.mkdir() - (consumer / "main.go").write_text('''package main -import ( - "context" - "fmt" - "github.com/DataScienceResearchPeru/peru-sdk/sdk-go/reniec" - "github.com/DataScienceResearchPeru/peru-sdk/sdk-go/sunat" -) -func main() { - _, err := sunat.NewValidityClient(sunat.ValidityOptions{RequesterRUC: "20000000002", TokenSource: sunat.StaticToken("synthetic-token")}) - if err != nil { panic(err) } - service, err := reniec.NewMockIdentityService([]reniec.Identity{{DNI:"00000001", GivenNames:"PERSONA", FirstSurname:"PRUEBA", SecondSurname:"SINTETICA"}}) - if err != nil { panic(err) } - person, err := service.Lookup(context.Background(), reniec.IdentityQuery{DNI:"00000001"}) - if err != nil { panic(err) } - fmt.Println(person.GivenNames) -} -''') - run("go", "mod", "init", "consumer.example/smoke", cwd=consumer) - run("go", "mod", "edit", "-require=" + MODULE + "@v0.0.0", "-replace=" + MODULE + "=" + str(ROOT / "sdk-go"), cwd=consumer) - run("go", "run", ".", cwd=consumer) - print("Both SDKs consumed successfully outside the repository.") - - -if __name__ == "__main__": - main() diff --git a/tools/check_sources.py b/tools/check_sources.py deleted file mode 100644 index 1e3780f..0000000 --- a/tools/check_sources.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Report changed official documents separately from unavailable sources; never update rules.""" -import hashlib -import json -from pathlib import Path -from urllib.error import URLError -from urllib.request import urlopen - -ROOT = Path(__file__).resolve().parents[1] - - -def main() -> int: - manifest = json.loads((ROOT / "contracts/sources.lock.json").read_text(encoding="utf-8")) - changed = unavailable = False - for source in manifest["sources"]: - try: - with urlopen(source["url"], timeout=30) as response: - body = response.read(16 * 1024 * 1024 + 1) - if len(body) > 16 * 1024 * 1024 or not body.startswith(b"%PDF-"): - raise ValueError("response is not a bounded PDF") - digest = hashlib.sha256(body).hexdigest() - if digest != source["sha256"]: - changed = True - print(f"CHANGED {source['id']}: {source['sha256']} -> {digest}; review required") - else: - print(f"UNCHANGED {source['id']}") - except (URLError, OSError, ValueError) as error: - unavailable = True - print(f"UNAVAILABLE {source['id']}: {type(error).__name__}; compatibility is not evaluated") - return 2 if unavailable else 1 if changed else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/generate.py b/tools/generate.py deleted file mode 100644 index e3e5dca..0000000 --- a/tools/generate.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Generate native models from the deliberately small contract subset.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path -import subprocess -import sys - -ROOT = Path(__file__).resolve().parents[1] -CONTRACTS = ( - ("sunat", ROOT / "contracts/sunat/validity.schema.json"), - ("reniec", ROOT / "contracts/reniec/identity.prototype.schema.json"), -) - - -def go_name(value: str) -> str: - return "".join({"ruc": "RUC", "dni": "DNI"}.get(p, p.title()) for p in value.split("_")) - - -def check_shape(shape: dict) -> None: - if set(shape) - {"type", "additionalProperties", "required", "properties"}: - raise ValueError("unsupported object keyword") - if shape["type"] != "object" or shape["additionalProperties"] is not False: - raise ValueError("only closed objects are supported") - if set(shape["required"]) != set(shape["properties"]): - raise ValueError("this generator supports required fields only") - for spec in shape["properties"].values(): - allowed = {"type", "description"} - if spec["type"] == "string": - allowed |= {"pattern", "format"} - if "format" in spec and spec["format"] != "date": - raise ValueError("unsupported format") - elif spec["type"] == "integer": - allowed |= {"minimum", "maximum"} - elif spec["type"] == "array" and spec.get("items") == {"type": "string"}: - allowed |= {"items"} - else: - raise ValueError("unsupported field type") - if set(spec) - allowed: - raise ValueError("unsupported field keyword") - - -def render_python(shapes: dict) -> str: - has_dates = any(s.get("format") == "date" for shape in shapes.values() for s in shape["properties"].values()) - has_patterns = any("pattern" in s for shape in shapes.values() for s in shape["properties"].values()) - out = [ - '"""Contract models. Regenerate with python3 tools/generate.py."""', - "from dataclasses import dataclass", - ] - if has_dates: - out.append("from datetime import date") - if has_patterns: - out.append("import re") - types = {"string": "str", "integer": "int", "array": "tuple[str, ...]"} - for name, shape in shapes.items(): - out += ["", "@dataclass(frozen=True)", f"class {name}:"] - for field, spec in shape["properties"].items(): - out.append(f" {field}: {types[spec['type']]}") - out += ["", " def __post_init__(self) -> None:"] - for field, spec in shape["properties"].items(): - attr = f"self.{field}" - if spec["type"] == "array": - test = f"type({attr}) is not tuple or any(type(v) is not str for v in {attr})" - else: - test = f"type({attr}) is not {types[spec['type']]}" - out += [f" if {test}:", f' raise ValueError("{field}: invalid type")'] - if "pattern" in spec: - out += [f" if re.fullmatch({spec['pattern']!r}, {attr}, flags=re.ASCII) is None:", - f' raise ValueError("{field}: invalid format")'] - for key, op in (("minimum", "<"), ("maximum", ">")): - if key in spec: - out += [f" if {attr} {op} {spec[key]}:", f' raise ValueError("{field}: out of range")'] - if spec.get("format") == "date": - out += [" try:", f" parsed = date.fromisoformat({attr})", " except ValueError:", - f' raise ValueError("{field}: invalid date") from None', - f" if parsed.isoformat() != {attr}:", f' raise ValueError("{field}: expected YYYY-MM-DD")'] - return "\n".join(out) + "\n" - - -def render_go(package: str, shapes: dict) -> str: - out = ["// Contract models. Regenerate with python3 tools/generate.py.", f"package {package}", - 'import ("fmt"; "regexp"; "time")'] - # Imports are inferred, so new packages do not need dummy references. - patterns = any("pattern" in s for shape in shapes.values() for s in shape["properties"].values()) - dates = any(s.get("format") == "date" for shape in shapes.values() for s in shape["properties"].values()) - imports = ['"fmt"'] + (['"regexp"'] if patterns else []) + (['"time"'] if dates else []) - out[2] = "import (" + ";".join(imports) + ")" - types = {"string": "string", "integer": "int64", "array": "[]string"} - for name, shape in shapes.items(): - out.append(f"type {name} struct {{") - for field, spec in shape["properties"].items(): - out.append(f'{go_name(field)} {types[spec["type"]]} `json:"{field}"`') - out += ["}", f"func (m {name}) Validate() error {{"] - for field, spec in shape["properties"].items(): - attr = "m." + go_name(field) - if spec["type"] == "array": - out.append(f'if {attr} == nil {{ return fmt.Errorf("{field}: expected an array") }}') - if "pattern" in spec: - var = name[0].lower() + name[1:] + go_name(field) + "Pattern" - out.append(f'if !{var}.MatchString({attr}) {{ return fmt.Errorf("{field}: invalid format") }}') - for key, op in (("minimum", "<"), ("maximum", ">")): - if key in spec: - out.append(f'if {attr} {op} {spec[key]} {{ return fmt.Errorf("{field}: out of range") }}') - if spec.get("format") == "date": - out.append(f'if t, err := time.Parse("2006-01-02", {attr}); err != nil || t.Format("2006-01-02") != {attr} {{ return fmt.Errorf("{field}: expected YYYY-MM-DD") }}') - out += ["return nil", "}"] - for field, spec in shape["properties"].items(): - if "pattern" in spec: - var = name[0].lower() + name[1:] + go_name(field) + "Pattern" - out.append(f"var {var} = regexp.MustCompile({json.dumps(spec['pattern'])})") - return subprocess.run(["gofmt"], input="\n".join(out), text=True, capture_output=True, check=True).stdout - - -def outputs() -> dict[Path, str]: - result = {} - for package, path in CONTRACTS: - schema = json.loads(path.read_text()) - if set(schema) - {"$schema", "$id", "description", "$defs"}: - raise ValueError("unsupported root keyword") - shapes = schema["$defs"] - for shape in shapes.values(): - check_shape(shape) - result[ROOT / f"sdk-go/{package}/models.gen.go"] = render_go(package, shapes) - result[ROOT / f"sdk-python/src/peru_sdk/{package}/models.py"] = render_python(shapes) - return result - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--check", action="store_true") - args = parser.parse_args() - dirty = [] - for path, content in outputs().items(): - if args.check: - if not path.exists() or path.read_text() != content: - dirty.append(str(path.relative_to(ROOT))) - else: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - if dirty: - print("Models need regeneration: " + ", ".join(dirty), file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/requirements.txt b/tools/requirements.txt index d1cfec3..e8f6590 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -1 +1,2 @@ -jsonschema>=4.23,<5 +build>=1.2,<2 +twine>=6,<8