mirotaract 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mirotaract-0.1.0/.gitignore +18 -0
- mirotaract-0.1.0/LICENSE +21 -0
- mirotaract-0.1.0/PKG-INFO +248 -0
- mirotaract-0.1.0/README.md +228 -0
- mirotaract-0.1.0/pyproject.toml +35 -0
- mirotaract-0.1.0/src/mirotaract/__init__.py +75 -0
- mirotaract-0.1.0/src/mirotaract/_http.py +238 -0
- mirotaract-0.1.0/src/mirotaract/auth.py +501 -0
- mirotaract-0.1.0/src/mirotaract/client.py +533 -0
- mirotaract-0.1.0/src/mirotaract/errors.py +56 -0
- mirotaract-0.1.0/src/mirotaract/fastapi.py +93 -0
- mirotaract-0.1.0/src/mirotaract/pagination.py +119 -0
- mirotaract-0.1.0/src/mirotaract/py.typed +0 -0
- mirotaract-0.1.0/src/mirotaract/types.py +159 -0
- mirotaract-0.1.0/src/mirotaract/webhooks.py +150 -0
- mirotaract-0.1.0/tests/conftest.py +167 -0
- mirotaract-0.1.0/tests/test_auth.py +219 -0
- mirotaract-0.1.0/tests/test_client.py +285 -0
- mirotaract-0.1.0/tests/test_fastapi.py +70 -0
- mirotaract-0.1.0/tests/test_http.py +139 -0
- mirotaract-0.1.0/tests/test_webhooks.py +76 -0
mirotaract-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Distrito 4845 de Rotaract
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mirotaract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK oficial de Mi Rotaract: API de datos institucionales e "Ingresar con Mi Rotaract" (OAuth 2.0 + OpenID Connect).
|
|
5
|
+
Author: Mi Rotaract
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx<1,>=0.27
|
|
13
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.8
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: fastapi>=0.110; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Provides-Extra: fastapi
|
|
18
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# mirotaract (Python)
|
|
22
|
+
|
|
23
|
+
SDK oficial de **Mi Rotaract** para Python ≥ 3.10:
|
|
24
|
+
|
|
25
|
+
- **API de datos institucionales** (clubes, socios, autoridades, períodos,
|
|
26
|
+
permisos) con `client_credentials`: `MiRotaract` (síncrono) y
|
|
27
|
+
`AsyncMiRotaract` (asíncrono).
|
|
28
|
+
- **Ingresar con Mi Rotaract** (OAuth 2.0 + PKCE + OpenID Connect):
|
|
29
|
+
`MiRotaractAuth` y `AsyncMiRotaractAuth`, con verificación del `id_token`
|
|
30
|
+
contra el JWKS publicado.
|
|
31
|
+
- Dependencia para **FastAPI**: `mirotaract.fastapi.require_user`.
|
|
32
|
+
- **Webhooks**: `verify_webhook` verifica en una línea los avisos firmados
|
|
33
|
+
que Mi Rotaract le manda a tu servidor (solo biblioteca estándar).
|
|
34
|
+
|
|
35
|
+
Dependencias: `httpx` y `PyJWT[crypto]`.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install mirotaract # o "mirotaract[fastapi]"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
La URL base es la de la API del kernel, que además es el _issuer_ OIDC:
|
|
42
|
+
`https://api.rotaract4845.com/api/kernel/v1`. El resto se descubre desde
|
|
43
|
+
`/.well-known/openid-configuration`.
|
|
44
|
+
|
|
45
|
+
## 1. Cliente de servidor (API de datos)
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import os
|
|
49
|
+
from mirotaract import MiRotaract
|
|
50
|
+
|
|
51
|
+
mr = MiRotaract(
|
|
52
|
+
os.environ["MIROTARACT_BASE_URL"],
|
|
53
|
+
os.environ["MIROTARACT_CLIENT_ID"], # mra_…
|
|
54
|
+
os.environ["MIROTARACT_CLIENT_SECRET"], # mrs_…
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
for club in mr.clubs.list(status="ACTIVE", type="CLUB"): # sigue nextCursor solo
|
|
58
|
+
print(club["name"])
|
|
59
|
+
|
|
60
|
+
socios = mr.members.list(club_id, status="ACTIVE").all()
|
|
61
|
+
club = mr.clubs.get(club_id)
|
|
62
|
+
personas = mr.persons.batch(ids) # de a 100, automático
|
|
63
|
+
membresias = mr.persons.memberships(person_id)
|
|
64
|
+
autoridades = mr.authorities.list(distrito_id, include_descendants=True)
|
|
65
|
+
periodos = mr.periods.list(club_id, status="ACTIVE")
|
|
66
|
+
decision = mr.permissions.check(
|
|
67
|
+
person_id=person_id, permission="meetings.meeting.create", organization_id=club_id
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Asíncrono, misma superficie:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from mirotaract import AsyncMiRotaract
|
|
75
|
+
|
|
76
|
+
async with AsyncMiRotaract(base_url, client_id, client_secret) as mr:
|
|
77
|
+
async for socio in mr.members.list(club_id, limit=100):
|
|
78
|
+
...
|
|
79
|
+
club = await mr.clubs.get(club_id)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Recurso | Métodos |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `clubs` (alias `organizations`) | `list(type=, status=, parent_id=, updated_since=, limit=, if_none_match=)`, `get(id)` |
|
|
85
|
+
| `members` | `list(organization_id, status=, updated_since=, limit=, if_none_match=)` |
|
|
86
|
+
| `persons` | `get(id)`, `batch(ids)`, `memberships(person_id)` |
|
|
87
|
+
| `authorities` | `list(organization_id, include_descendants=)` |
|
|
88
|
+
| `periods` | `list(organization_id, status=)` |
|
|
89
|
+
| `permissions` | `check(person_id=, permission=, organization_id=, scope_type=, period_id=, resource=)`, `check_many([...])` |
|
|
90
|
+
|
|
91
|
+
Otros: `get_access_token()`, `granted_scopes`, `clear_token()`, `discovery()`
|
|
92
|
+
y `request(method, path, params=, json=)`.
|
|
93
|
+
|
|
94
|
+
Las vistas son `TypedDict` con las claves tal como las devuelve la API
|
|
95
|
+
(`displayName`, `updatedAt`…): `OrganizationView`, `MemberView`,
|
|
96
|
+
`PersonView`, `AuthorityView`, `PeriodView`, `PersonMembershipView`,
|
|
97
|
+
`AuthorizationDecision`.
|
|
98
|
+
|
|
99
|
+
### Comportamiento
|
|
100
|
+
|
|
101
|
+
- Token de servicio cacheado hasta 60 s antes de vencer (con lock, una sola
|
|
102
|
+
renovación aunque haya concurrencia); ante un 401 se renueva una vez.
|
|
103
|
+
- Reintentos (2 por defecto) con backoff exponencial y jitter ante
|
|
104
|
+
429/502/503/504 y errores de red, respetando `Retry-After`. Nunca reintenta
|
|
105
|
+
un `POST` sin `Idempotency-Key`; las lecturas por POST (`persons.batch`,
|
|
106
|
+
`permissions.check`) mandan una clave propia.
|
|
107
|
+
- Paginadores: iteración, `.all(max=)`, `.pages()` y `.page(cursor)`.
|
|
108
|
+
- Podés pasar tu propio `httpx.Client` / `httpx.AsyncClient` (`http_client=`)
|
|
109
|
+
para proxies, timeouts o pruebas con `httpx.MockTransport`.
|
|
110
|
+
|
|
111
|
+
### ETag (sincronización incremental)
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
page = mr.members.list(club_id).page()
|
|
115
|
+
guardar(page.items, page.etag)
|
|
116
|
+
|
|
117
|
+
again = mr.members.list(club_id, if_none_match=etag_guardado).page()
|
|
118
|
+
if again.not_modified: # 304: nada cambió
|
|
119
|
+
return
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`if_none_match` aplica a la primera página; con 304, `.page()` devuelve
|
|
123
|
+
`NotModified(etag=…)`, la iteración no produce elementos y
|
|
124
|
+
`paginator.not_modified` queda en `True`.
|
|
125
|
+
|
|
126
|
+
### Errores
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from mirotaract import MiRotaractApiError, MiRotaractOAuthError
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
mr.members.list(otro_club).all()
|
|
133
|
+
except MiRotaractApiError as e: # Problem Details
|
|
134
|
+
e.status, e.code, e.title, e.detail, e.trace_id # 403, "KERNEL_HTTP_403", …
|
|
135
|
+
except MiRotaractOAuthError as e: # RFC 6749
|
|
136
|
+
e.error, e.error_description # "invalid_client", …
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## 2. Ingresar con Mi Rotaract
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from mirotaract import MiRotaractAuth
|
|
143
|
+
|
|
144
|
+
auth = MiRotaractAuth(
|
|
145
|
+
"https://api.rotaract4845.com/api/kernel/v1",
|
|
146
|
+
client_id="mra_…",
|
|
147
|
+
redirect_uri="https://mi-app.org/auth/callback",
|
|
148
|
+
client_secret="mrs_…", # None para apps PUBLIC
|
|
149
|
+
scope="openid profile email memberships",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# 1) Login: guardá code_verifier, state y nonce en la sesión del servidor
|
|
153
|
+
req = auth.authorization_url()
|
|
154
|
+
session["mr"] = {"verifier": req.code_verifier, "state": req.state, "nonce": req.nonce}
|
|
155
|
+
return redirect(req.url)
|
|
156
|
+
|
|
157
|
+
# 2) Callback
|
|
158
|
+
code = auth.parse_callback(str(request.url), session["mr"]["state"])
|
|
159
|
+
tokens = auth.exchange_code(code, session["mr"]["verifier"], nonce=session["mr"]["nonce"])
|
|
160
|
+
tokens.claims # id_token verificado: sub, name, email, memberships…
|
|
161
|
+
tokens.refresh_token
|
|
162
|
+
|
|
163
|
+
# 3) Después
|
|
164
|
+
auth.user_info(tokens.access_token)
|
|
165
|
+
nuevos = auth.refresh(tokens.refresh_token) # rota: guardá el nuevo
|
|
166
|
+
auth.revoke(nuevos.refresh_token)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Métodos: `discovery()`, `authorization_url(scope=, state=, nonce=, redirect_uri=, extra_params=)`,
|
|
170
|
+
`parse_callback(url, state)`, `exchange_code(code, code_verifier, nonce=)`,
|
|
171
|
+
`refresh(refresh_token, scope=)`, `verify_id_token(id_token, nonce=, max_age=)`,
|
|
172
|
+
`verify_access_token(token)`, `user_info(access_token)`, `revoke(token)`.
|
|
173
|
+
`AsyncMiRotaractAuth` tiene los mismos métodos con `await`.
|
|
174
|
+
|
|
175
|
+
El JWKS se descarga con el mismo cliente `httpx` (no con `urllib`), se
|
|
176
|
+
construye con `jwt.PyJWKSet`, se cachea 10 minutos y se vuelve a pedir ante un
|
|
177
|
+
`kid` desconocido. Se verifica ES256, `iss`, `aud` (= `client_id`), `exp`,
|
|
178
|
+
`azp` y `nonce`.
|
|
179
|
+
|
|
180
|
+
## 3. FastAPI
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from fastapi import Depends, FastAPI
|
|
184
|
+
from mirotaract import AsyncMiRotaractAuth
|
|
185
|
+
from mirotaract.fastapi import require_user
|
|
186
|
+
|
|
187
|
+
auth = AsyncMiRotaractAuth(ISSUER, CLIENT_ID, REDIRECT_URI)
|
|
188
|
+
current_user = require_user(auth) # verify="userinfo" (por defecto) o "jwt"
|
|
189
|
+
|
|
190
|
+
app = FastAPI()
|
|
191
|
+
|
|
192
|
+
@app.get("/api/yo")
|
|
193
|
+
async def yo(user=Depends(current_user)):
|
|
194
|
+
return user
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- `verify="userinfo"`: consulta `/oauth/userinfo` (ve revocaciones al
|
|
198
|
+
instante); cachea `cache_ttl` segundos (60).
|
|
199
|
+
- `verify="jwt"`: verificación local de la firma, sin red por pedido.
|
|
200
|
+
- `authorize=lambda user: …` devuelve 403 si da `False`.
|
|
201
|
+
- Sin token o inválido: `401` con `WWW-Authenticate: Bearer error="invalid_token"`.
|
|
202
|
+
|
|
203
|
+
Para apps web con páginas del servidor conviene una sesión del lado del
|
|
204
|
+
servidor: hacé el login con `authorization_url` / `exchange_code` y guardá
|
|
205
|
+
`tokens.claims` en tu sesión.
|
|
206
|
+
|
|
207
|
+
## 4. Webhooks
|
|
208
|
+
|
|
209
|
+
Mi Rotaract le avisa a tu servidor cuando pasa algo (socio activado, baja,
|
|
210
|
+
cargo asumido...) con un `POST` firmado. Guía completa:
|
|
211
|
+
[docs/developers/webhooks.md](../../docs/developers/webhooks.md); tipos:
|
|
212
|
+
[catálogo de eventos](../../docs/developers/catalogo-de-eventos.md).
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import os
|
|
216
|
+
from mirotaract import MiRotaractWebhookError, verify_webhook
|
|
217
|
+
|
|
218
|
+
event = verify_webhook(
|
|
219
|
+
raw_body, # bytes crudos: await request.body() (FastAPI) / request.get_data() (Flask)
|
|
220
|
+
request.headers, # dict, Headers de Starlette o pares (bytes, bytes)
|
|
221
|
+
os.environ["MIROTARACT_WEBHOOK_SECRET"], # o [nuevo, viejo]
|
|
222
|
+
tolerance=300,
|
|
223
|
+
)
|
|
224
|
+
if event["type"] == "membership.activated.v1":
|
|
225
|
+
print(event["data"]["membership"]["person"]["displayName"])
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Devuelve el evento como `dict`. Si no es válido lanza
|
|
229
|
+
`MiRotaractWebhookError` con `code`: `missing_header`, `invalid_timestamp`,
|
|
230
|
+
`timestamp_out_of_tolerance`, `invalid_signature` o `invalid_payload`.
|
|
231
|
+
|
|
232
|
+
## Conformidad
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
python3 -m venv .venv && .venv/bin/pip install -e 'sdks/python[dev]'
|
|
236
|
+
KERNEL_DATABASE_URL=… MR_BASE_URL=http://127.0.0.1:3911/api/kernel/v1 node sdks/conformance/seed.mjs > /tmp/mr.env
|
|
237
|
+
.venv/bin/python sdks/conformance/run_python.py --env-file /tmp/mr.env
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`webhooks.signature_vectors` corre sin kernel (vectores compartidos con el SDK
|
|
241
|
+
de JavaScript en `sdks/conformance/webhook-vectors.json`).
|
|
242
|
+
|
|
243
|
+
## Desarrollo
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
.venv/bin/pip install -e 'sdks/python[dev]'
|
|
247
|
+
.venv/bin/python -m pytest sdks/python
|
|
248
|
+
```
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# mirotaract (Python)
|
|
2
|
+
|
|
3
|
+
SDK oficial de **Mi Rotaract** para Python ≥ 3.10:
|
|
4
|
+
|
|
5
|
+
- **API de datos institucionales** (clubes, socios, autoridades, períodos,
|
|
6
|
+
permisos) con `client_credentials`: `MiRotaract` (síncrono) y
|
|
7
|
+
`AsyncMiRotaract` (asíncrono).
|
|
8
|
+
- **Ingresar con Mi Rotaract** (OAuth 2.0 + PKCE + OpenID Connect):
|
|
9
|
+
`MiRotaractAuth` y `AsyncMiRotaractAuth`, con verificación del `id_token`
|
|
10
|
+
contra el JWKS publicado.
|
|
11
|
+
- Dependencia para **FastAPI**: `mirotaract.fastapi.require_user`.
|
|
12
|
+
- **Webhooks**: `verify_webhook` verifica en una línea los avisos firmados
|
|
13
|
+
que Mi Rotaract le manda a tu servidor (solo biblioteca estándar).
|
|
14
|
+
|
|
15
|
+
Dependencias: `httpx` y `PyJWT[crypto]`.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install mirotaract # o "mirotaract[fastapi]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
La URL base es la de la API del kernel, que además es el _issuer_ OIDC:
|
|
22
|
+
`https://api.rotaract4845.com/api/kernel/v1`. El resto se descubre desde
|
|
23
|
+
`/.well-known/openid-configuration`.
|
|
24
|
+
|
|
25
|
+
## 1. Cliente de servidor (API de datos)
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import os
|
|
29
|
+
from mirotaract import MiRotaract
|
|
30
|
+
|
|
31
|
+
mr = MiRotaract(
|
|
32
|
+
os.environ["MIROTARACT_BASE_URL"],
|
|
33
|
+
os.environ["MIROTARACT_CLIENT_ID"], # mra_…
|
|
34
|
+
os.environ["MIROTARACT_CLIENT_SECRET"], # mrs_…
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
for club in mr.clubs.list(status="ACTIVE", type="CLUB"): # sigue nextCursor solo
|
|
38
|
+
print(club["name"])
|
|
39
|
+
|
|
40
|
+
socios = mr.members.list(club_id, status="ACTIVE").all()
|
|
41
|
+
club = mr.clubs.get(club_id)
|
|
42
|
+
personas = mr.persons.batch(ids) # de a 100, automático
|
|
43
|
+
membresias = mr.persons.memberships(person_id)
|
|
44
|
+
autoridades = mr.authorities.list(distrito_id, include_descendants=True)
|
|
45
|
+
periodos = mr.periods.list(club_id, status="ACTIVE")
|
|
46
|
+
decision = mr.permissions.check(
|
|
47
|
+
person_id=person_id, permission="meetings.meeting.create", organization_id=club_id
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Asíncrono, misma superficie:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from mirotaract import AsyncMiRotaract
|
|
55
|
+
|
|
56
|
+
async with AsyncMiRotaract(base_url, client_id, client_secret) as mr:
|
|
57
|
+
async for socio in mr.members.list(club_id, limit=100):
|
|
58
|
+
...
|
|
59
|
+
club = await mr.clubs.get(club_id)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Recurso | Métodos |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `clubs` (alias `organizations`) | `list(type=, status=, parent_id=, updated_since=, limit=, if_none_match=)`, `get(id)` |
|
|
65
|
+
| `members` | `list(organization_id, status=, updated_since=, limit=, if_none_match=)` |
|
|
66
|
+
| `persons` | `get(id)`, `batch(ids)`, `memberships(person_id)` |
|
|
67
|
+
| `authorities` | `list(organization_id, include_descendants=)` |
|
|
68
|
+
| `periods` | `list(organization_id, status=)` |
|
|
69
|
+
| `permissions` | `check(person_id=, permission=, organization_id=, scope_type=, period_id=, resource=)`, `check_many([...])` |
|
|
70
|
+
|
|
71
|
+
Otros: `get_access_token()`, `granted_scopes`, `clear_token()`, `discovery()`
|
|
72
|
+
y `request(method, path, params=, json=)`.
|
|
73
|
+
|
|
74
|
+
Las vistas son `TypedDict` con las claves tal como las devuelve la API
|
|
75
|
+
(`displayName`, `updatedAt`…): `OrganizationView`, `MemberView`,
|
|
76
|
+
`PersonView`, `AuthorityView`, `PeriodView`, `PersonMembershipView`,
|
|
77
|
+
`AuthorizationDecision`.
|
|
78
|
+
|
|
79
|
+
### Comportamiento
|
|
80
|
+
|
|
81
|
+
- Token de servicio cacheado hasta 60 s antes de vencer (con lock, una sola
|
|
82
|
+
renovación aunque haya concurrencia); ante un 401 se renueva una vez.
|
|
83
|
+
- Reintentos (2 por defecto) con backoff exponencial y jitter ante
|
|
84
|
+
429/502/503/504 y errores de red, respetando `Retry-After`. Nunca reintenta
|
|
85
|
+
un `POST` sin `Idempotency-Key`; las lecturas por POST (`persons.batch`,
|
|
86
|
+
`permissions.check`) mandan una clave propia.
|
|
87
|
+
- Paginadores: iteración, `.all(max=)`, `.pages()` y `.page(cursor)`.
|
|
88
|
+
- Podés pasar tu propio `httpx.Client` / `httpx.AsyncClient` (`http_client=`)
|
|
89
|
+
para proxies, timeouts o pruebas con `httpx.MockTransport`.
|
|
90
|
+
|
|
91
|
+
### ETag (sincronización incremental)
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
page = mr.members.list(club_id).page()
|
|
95
|
+
guardar(page.items, page.etag)
|
|
96
|
+
|
|
97
|
+
again = mr.members.list(club_id, if_none_match=etag_guardado).page()
|
|
98
|
+
if again.not_modified: # 304: nada cambió
|
|
99
|
+
return
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`if_none_match` aplica a la primera página; con 304, `.page()` devuelve
|
|
103
|
+
`NotModified(etag=…)`, la iteración no produce elementos y
|
|
104
|
+
`paginator.not_modified` queda en `True`.
|
|
105
|
+
|
|
106
|
+
### Errores
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from mirotaract import MiRotaractApiError, MiRotaractOAuthError
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
mr.members.list(otro_club).all()
|
|
113
|
+
except MiRotaractApiError as e: # Problem Details
|
|
114
|
+
e.status, e.code, e.title, e.detail, e.trace_id # 403, "KERNEL_HTTP_403", …
|
|
115
|
+
except MiRotaractOAuthError as e: # RFC 6749
|
|
116
|
+
e.error, e.error_description # "invalid_client", …
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## 2. Ingresar con Mi Rotaract
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from mirotaract import MiRotaractAuth
|
|
123
|
+
|
|
124
|
+
auth = MiRotaractAuth(
|
|
125
|
+
"https://api.rotaract4845.com/api/kernel/v1",
|
|
126
|
+
client_id="mra_…",
|
|
127
|
+
redirect_uri="https://mi-app.org/auth/callback",
|
|
128
|
+
client_secret="mrs_…", # None para apps PUBLIC
|
|
129
|
+
scope="openid profile email memberships",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# 1) Login: guardá code_verifier, state y nonce en la sesión del servidor
|
|
133
|
+
req = auth.authorization_url()
|
|
134
|
+
session["mr"] = {"verifier": req.code_verifier, "state": req.state, "nonce": req.nonce}
|
|
135
|
+
return redirect(req.url)
|
|
136
|
+
|
|
137
|
+
# 2) Callback
|
|
138
|
+
code = auth.parse_callback(str(request.url), session["mr"]["state"])
|
|
139
|
+
tokens = auth.exchange_code(code, session["mr"]["verifier"], nonce=session["mr"]["nonce"])
|
|
140
|
+
tokens.claims # id_token verificado: sub, name, email, memberships…
|
|
141
|
+
tokens.refresh_token
|
|
142
|
+
|
|
143
|
+
# 3) Después
|
|
144
|
+
auth.user_info(tokens.access_token)
|
|
145
|
+
nuevos = auth.refresh(tokens.refresh_token) # rota: guardá el nuevo
|
|
146
|
+
auth.revoke(nuevos.refresh_token)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Métodos: `discovery()`, `authorization_url(scope=, state=, nonce=, redirect_uri=, extra_params=)`,
|
|
150
|
+
`parse_callback(url, state)`, `exchange_code(code, code_verifier, nonce=)`,
|
|
151
|
+
`refresh(refresh_token, scope=)`, `verify_id_token(id_token, nonce=, max_age=)`,
|
|
152
|
+
`verify_access_token(token)`, `user_info(access_token)`, `revoke(token)`.
|
|
153
|
+
`AsyncMiRotaractAuth` tiene los mismos métodos con `await`.
|
|
154
|
+
|
|
155
|
+
El JWKS se descarga con el mismo cliente `httpx` (no con `urllib`), se
|
|
156
|
+
construye con `jwt.PyJWKSet`, se cachea 10 minutos y se vuelve a pedir ante un
|
|
157
|
+
`kid` desconocido. Se verifica ES256, `iss`, `aud` (= `client_id`), `exp`,
|
|
158
|
+
`azp` y `nonce`.
|
|
159
|
+
|
|
160
|
+
## 3. FastAPI
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from fastapi import Depends, FastAPI
|
|
164
|
+
from mirotaract import AsyncMiRotaractAuth
|
|
165
|
+
from mirotaract.fastapi import require_user
|
|
166
|
+
|
|
167
|
+
auth = AsyncMiRotaractAuth(ISSUER, CLIENT_ID, REDIRECT_URI)
|
|
168
|
+
current_user = require_user(auth) # verify="userinfo" (por defecto) o "jwt"
|
|
169
|
+
|
|
170
|
+
app = FastAPI()
|
|
171
|
+
|
|
172
|
+
@app.get("/api/yo")
|
|
173
|
+
async def yo(user=Depends(current_user)):
|
|
174
|
+
return user
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- `verify="userinfo"`: consulta `/oauth/userinfo` (ve revocaciones al
|
|
178
|
+
instante); cachea `cache_ttl` segundos (60).
|
|
179
|
+
- `verify="jwt"`: verificación local de la firma, sin red por pedido.
|
|
180
|
+
- `authorize=lambda user: …` devuelve 403 si da `False`.
|
|
181
|
+
- Sin token o inválido: `401` con `WWW-Authenticate: Bearer error="invalid_token"`.
|
|
182
|
+
|
|
183
|
+
Para apps web con páginas del servidor conviene una sesión del lado del
|
|
184
|
+
servidor: hacé el login con `authorization_url` / `exchange_code` y guardá
|
|
185
|
+
`tokens.claims` en tu sesión.
|
|
186
|
+
|
|
187
|
+
## 4. Webhooks
|
|
188
|
+
|
|
189
|
+
Mi Rotaract le avisa a tu servidor cuando pasa algo (socio activado, baja,
|
|
190
|
+
cargo asumido...) con un `POST` firmado. Guía completa:
|
|
191
|
+
[docs/developers/webhooks.md](../../docs/developers/webhooks.md); tipos:
|
|
192
|
+
[catálogo de eventos](../../docs/developers/catalogo-de-eventos.md).
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
import os
|
|
196
|
+
from mirotaract import MiRotaractWebhookError, verify_webhook
|
|
197
|
+
|
|
198
|
+
event = verify_webhook(
|
|
199
|
+
raw_body, # bytes crudos: await request.body() (FastAPI) / request.get_data() (Flask)
|
|
200
|
+
request.headers, # dict, Headers de Starlette o pares (bytes, bytes)
|
|
201
|
+
os.environ["MIROTARACT_WEBHOOK_SECRET"], # o [nuevo, viejo]
|
|
202
|
+
tolerance=300,
|
|
203
|
+
)
|
|
204
|
+
if event["type"] == "membership.activated.v1":
|
|
205
|
+
print(event["data"]["membership"]["person"]["displayName"])
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Devuelve el evento como `dict`. Si no es válido lanza
|
|
209
|
+
`MiRotaractWebhookError` con `code`: `missing_header`, `invalid_timestamp`,
|
|
210
|
+
`timestamp_out_of_tolerance`, `invalid_signature` o `invalid_payload`.
|
|
211
|
+
|
|
212
|
+
## Conformidad
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
python3 -m venv .venv && .venv/bin/pip install -e 'sdks/python[dev]'
|
|
216
|
+
KERNEL_DATABASE_URL=… MR_BASE_URL=http://127.0.0.1:3911/api/kernel/v1 node sdks/conformance/seed.mjs > /tmp/mr.env
|
|
217
|
+
.venv/bin/python sdks/conformance/run_python.py --env-file /tmp/mr.env
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`webhooks.signature_vectors` corre sin kernel (vectores compartidos con el SDK
|
|
221
|
+
de JavaScript en `sdks/conformance/webhook-vectors.json`).
|
|
222
|
+
|
|
223
|
+
## Desarrollo
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
.venv/bin/pip install -e 'sdks/python[dev]'
|
|
227
|
+
.venv/bin/python -m pytest sdks/python
|
|
228
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mirotaract"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SDK oficial de Mi Rotaract: API de datos institucionales e \"Ingresar con Mi Rotaract\" (OAuth 2.0 + OpenID Connect)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Mi Rotaract" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27,<1",
|
|
15
|
+
"PyJWT[crypto]>=2.8,<3",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
fastapi = ["fastapi>=0.110"]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=8",
|
|
27
|
+
"fastapi>=0.110",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/mirotaract"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
addopts = "-q"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""SDK oficial de Mi Rotaract.
|
|
2
|
+
|
|
3
|
+
- ``MiRotaract`` / ``AsyncMiRotaract``: API de datos institucionales
|
|
4
|
+
(client_credentials, solo servidor).
|
|
5
|
+
- ``MiRotaractAuth`` / ``AsyncMiRotaractAuth``: "Ingresar con Mi Rotaract"
|
|
6
|
+
(OAuth 2.0 + PKCE + OpenID Connect).
|
|
7
|
+
- ``mirotaract.fastapi.require_user``: dependencia para FastAPI.
|
|
8
|
+
- ``verify_webhook``: verifica la firma de un webhook de Mi Rotaract.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._http import SDK_VERSION
|
|
12
|
+
from .auth import (
|
|
13
|
+
KERNEL_AUDIENCE,
|
|
14
|
+
AsyncMiRotaractAuth,
|
|
15
|
+
MiRotaractAuth,
|
|
16
|
+
pkce_challenge,
|
|
17
|
+
random_token,
|
|
18
|
+
)
|
|
19
|
+
from .client import AsyncMiRotaract, MiRotaract
|
|
20
|
+
from .errors import (
|
|
21
|
+
MiRotaractApiError,
|
|
22
|
+
MiRotaractConfigError,
|
|
23
|
+
MiRotaractError,
|
|
24
|
+
MiRotaractOAuthError,
|
|
25
|
+
)
|
|
26
|
+
from .webhooks import (
|
|
27
|
+
MiRotaractWebhookError,
|
|
28
|
+
verify_webhook,
|
|
29
|
+
)
|
|
30
|
+
from .pagination import AsyncPaginator, NotModified, Page, SyncPaginator
|
|
31
|
+
from .types import (
|
|
32
|
+
AuthorityView,
|
|
33
|
+
AuthorizationDecision,
|
|
34
|
+
AuthorizationRequest,
|
|
35
|
+
MemberView,
|
|
36
|
+
OrganizationView,
|
|
37
|
+
PeriodView,
|
|
38
|
+
PersonMembershipView,
|
|
39
|
+
PersonView,
|
|
40
|
+
TokenSet,
|
|
41
|
+
UserInfo,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__version__ = SDK_VERSION
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"SDK_VERSION",
|
|
48
|
+
"KERNEL_AUDIENCE",
|
|
49
|
+
"MiRotaract",
|
|
50
|
+
"AsyncMiRotaract",
|
|
51
|
+
"MiRotaractAuth",
|
|
52
|
+
"AsyncMiRotaractAuth",
|
|
53
|
+
"MiRotaractError",
|
|
54
|
+
"MiRotaractApiError",
|
|
55
|
+
"MiRotaractOAuthError",
|
|
56
|
+
"MiRotaractConfigError",
|
|
57
|
+
"MiRotaractWebhookError",
|
|
58
|
+
"verify_webhook",
|
|
59
|
+
"Page",
|
|
60
|
+
"NotModified",
|
|
61
|
+
"SyncPaginator",
|
|
62
|
+
"AsyncPaginator",
|
|
63
|
+
"AuthorizationRequest",
|
|
64
|
+
"TokenSet",
|
|
65
|
+
"OrganizationView",
|
|
66
|
+
"PersonView",
|
|
67
|
+
"MemberView",
|
|
68
|
+
"AuthorityView",
|
|
69
|
+
"PeriodView",
|
|
70
|
+
"PersonMembershipView",
|
|
71
|
+
"AuthorizationDecision",
|
|
72
|
+
"UserInfo",
|
|
73
|
+
"pkce_challenge",
|
|
74
|
+
"random_token",
|
|
75
|
+
]
|