diff --git a/.github/workflows/clients-ruby.yml b/.github/workflows/clients-ruby.yml new file mode 100644 index 0000000000..04bc0ed8dd --- /dev/null +++ b/.github/workflows/clients-ruby.yml @@ -0,0 +1,103 @@ +name: "Clients Ruby" + +on: + push: + branches: ['**'] + paths: + - 'clients/ruby/**' + - 'commons/swagger/**' + - '.github/workflows/clients-ruby.yml' + tags: + - 'ruby-api-*-v*' + +jobs: + rspec: + name: RSpec (Ruby ${{ matrix.ruby }}) + if: github.ref_type != 'tag' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ['3.2', '3.3', '4.0'] + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - name: commons + working-directory: clients/ruby/commons + run: bundle install && bundle exec rspec + - name: api_entreprise + working-directory: clients/ruby/api_entreprise + run: bundle install && bundle exec rspec + - name: api_particulier + working-directory: clients/ruby/api_particulier + run: bundle install && bundle exec rspec + + freshness: + name: sync_commons & scaffold_resources freshness + if: github.ref_type != 'tag' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + - name: Verify vendored commons is up to date + run: clients/ruby/bin/sync_commons --check + - name: Verify scaffolded resources are up to date + run: clients/ruby/bin/scaffold_resources --api all --check + + release: + name: Build & push to rubygems.org + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + environment: rubygems + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v6 + + - name: Resolve gem from tag + id: gem + run: | + case "$GITHUB_REF_NAME" in + ruby-api-entreprise-v*) + echo "name=api_entreprise" >>"$GITHUB_OUTPUT" + echo "dir=clients/ruby/api_entreprise" >>"$GITHUB_OUTPUT" + ;; + ruby-api-particulier-v*) + echo "name=api_particulier" >>"$GITHUB_OUTPUT" + echo "dir=clients/ruby/api_particulier" >>"$GITHUB_OUTPUT" + ;; + *) + echo "Unsupported tag: $GITHUB_REF_NAME" >&2 + exit 1 + ;; + esac + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: ${{ steps.gem.outputs.dir }} + + - name: Verify tag version matches gemspec version + working-directory: ${{ steps.gem.outputs.dir }} + run: | + tag_version="${GITHUB_REF_NAME##*-v}" + gem_version=$(ruby -e 'puts Gem::Specification.load("${{ steps.gem.outputs.name }}.gemspec").version') + if [ "$tag_version" != "$gem_version" ]; then + echo "Tag version '$tag_version' does not match gemspec version '$gem_version'" >&2 + exit 1 + fi + echo "Releasing ${{ steps.gem.outputs.name }} $gem_version" + + - name: Run rspec + working-directory: ${{ steps.gem.outputs.dir }} + run: bundle exec rspec + + - uses: rubygems/release-gem@v1 + with: + working-directory: ${{ steps.gem.outputs.dir }} diff --git a/clients/README.md b/clients/README.md new file mode 100644 index 0000000000..0d3af21eb5 --- /dev/null +++ b/clients/README.md @@ -0,0 +1,95 @@ +# clients/ + +Familles de SDKs officiels pour [API Entreprise v3](https://entreprise.api.gouv.fr) +et [API Particulier v3](https://particulier.api.gouv.fr), construites au-dessus +des specs OpenAPI versionnées dans [`commons/swagger/`](../commons/swagger). + +## Ce qui est ici + +| Fichier / dossier | Rôle | +|---|---| +| [`SPECS.md`](./SPECS.md) | Contrat normatif (langage-agnostique) que tout client doit respecter : environnements, auth, enveloppe, erreurs, rate-limit, testing, packaging, checklist de conformité. | +| `ruby/` | Implémentation de référence en Ruby. | +| `node/`, `python/`, `php/`, `java/` | *(à venir)* — ports à produire en suivant `SPECS.md` et en s'inspirant de `ruby/`. | + +## L'implémentation de référence : `ruby/` + +``` +ruby/ + commons/ # source de vérité partagée (Configuration, Response, + # RateLimit, hiérarchie d'erreurs JSON:API, SIRET/SIREN + # validators, Faraday middlewares, ClientBase, …) + api_entreprise/ # gem publié — 23 resources scaffoldées par provider + api_particulier/ # gem publié — 9 resources scaffoldées par provider + bin/sync_commons # vendorise commons/ dans chaque gem en réécrivant le + # namespace (ApiGouvCommons → ApiEntreprise::Commons …) + bin/scaffold_resources # (re)génère les lib/*/resources/*.rb depuis les specs + # OpenAPI de commons/swagger/ +``` + +Le dossier `commons/` **n'est pas publié** comme gem. Chaque gem embarque sa +propre copie vendorisée — pas de couplage au moment du release. `bin/sync_commons` +garde les copies en phase ; la CI vérifie la fraîcheur avec `--check`. + +### Lancer les tests localement + +```sh +cd clients/ruby/commons && bundle && bundle exec rspec # 65 / 65 +cd clients/ruby/api_entreprise && bundle && bundle exec rspec # 32 / 32 +cd clients/ruby/api_particulier && bundle && bundle exec rspec # 18 / 18 +``` + +### Exemples (lancés sans réseau grâce à WebMock, sauf les `basic.rb`) + +```sh +cd clients/ruby/api_entreprise +bundle exec ruby examples/error_handling.rb # matrice d'exceptions complète +bundle exec ruby examples/retry.rb # retry opt-in sur 429 / 502 / 503 + +cd ../api_particulier +bundle exec ruby examples/error_handling.rb +bundle exec ruby examples/retry.rb +``` + +Les `examples/basic.rb` de chaque gem tapent sur le bac à sable staging et +requièrent un jeton : + +```sh +TOKEN=$(curl -s https://raw.githubusercontent.com/datagouv/apistration/develop/mocks/tokens/default) +API_ENTREPRISE_TOKEN=$TOKEN bundle exec ruby clients/ruby/api_entreprise/examples/basic.rb +API_PARTICULIER_TOKEN=$TOKEN bundle exec ruby clients/ruby/api_particulier/examples/basic.rb +``` + +Pour un run de conformité complet contre staging avant release, suivre +[`TESTING.md`](TESTING.md) — c'est la playbook qui remplace les anciens +`bin/smoke` (trop superficiels pour catcher autre chose qu'une panne infra). + +### Régénérer après un changement de spec OpenAPI + +```sh +clients/ruby/bin/sync_commons +clients/ruby/bin/scaffold_resources --api all +``` + +## Porter SPECS.md dans une autre langue + +1. Lire `SPECS.md` du début à la fin — il est normatif et langage-agnostique. +2. Calquer la structure ruby/ : un sous-dossier `commons/` pour le code + partagé, un dossier par gem publié, des scripts de build qui vendorisent + `commons/` dans chaque artefact. +3. Couvrir la matrice de tests unitaires §12.1 (validateurs SIRET/SIREN, + configuration immuable, auth strategy, enveloppe, mapping d'erreurs, + rate-limit, retry, redaction des logs PII, signatures des resources). +4. Couvrir les 4 cas bout-en-bout §12.2 (200, 422, 429 avec `retry_after`, + 502 avec `meta.retry_in`) contre un stub HTTP. +5. Publier un README avec un exemple de stub, un `CHANGELOG.md`. +6. Cocher la checklist §20 avant merge. + +## CI + +[`.github/workflows/clients-ruby.yml`](../.github/workflows/clients-ruby.yml) +lance sur chaque push : + +- `rspec` pour les 3 projets Ruby sur la matrice Ruby 3.2 / 3.3 / 4.0 +- `bin/sync_commons --check` (échoue si commons vendorisé pas en phase) +- `bin/scaffold_resources --api all --check` (échoue si resources obsolètes) diff --git a/clients/SPECS.md b/clients/SPECS.md new file mode 100644 index 0000000000..b42d5675d1 --- /dev/null +++ b/clients/SPECS.md @@ -0,0 +1,676 @@ +# Client SDK Specification — API Entreprise & API Particulier v3 + +> Normative specification for every official client library of API Entreprise v3 +> and API Particulier v3 (Ruby, Node, Python, PHP, Java). Language-agnostic. +> +> Source of truth for the HTTP contract: +> - `commons/swagger/openapi-entreprise.yaml` +> - `commons/swagger/openapi-particulier.yaml` +> - `commons/swagger/authorizations.yml` (scope reference) +> +> Reference implementation: `clients/ruby/` — any deviation between this spec and +> the Ruby client must be resolved in favour of this document. + +--- + +## 1. Scope & non-goals + +**In scope** + +- API Entreprise, all endpoints with an URL-embedded version ≥ v3 (endpoints + are versioned independently: the same logical endpoint can exist in multiple + versions, e.g. `/v3/...` and `/v4/...`). +- API Particulier, same convention (≥ v3). +- `https://entreprise.api.gouv.fr` / `https://particulier.api.gouv.fr` +- Token-based bearer authentication with an extensibility seam for future auth + strategies (OAuth2, mTLS, rotating providers). + +**Out of scope** + +- API Particulier v2 (legacy, `X-Api-Key` header — not covered here). +- Server-side code generation from OpenAPI: clients MAY use codegen internally, + but the public surface, naming, and ergonomics described here are normative + and must not be exposed as raw codegen output. +- Credential provisioning / DataPass enrolment workflows. +- Long-running background jobs: every v3 endpoint is synchronous GET. + +Each client publishes **two separate packages**, one per API (e.g. +`api_entreprise` and `api_particulier`). Shared code lives in a per-language +`commons/` folder, vendored into each package at build time. + +--- + +## 2. Environments + +Every client MUST ship two named environments and accept a custom base URL +override. + +| Environment | API Entreprise | API Particulier | +|--------------|--------------------------------------------------|--------------------------------------------------| +| `production` | `https://entreprise.api.gouv.fr` | `https://particulier.api.gouv.fr` | +| `staging` | `https://staging.entreprise.api.gouv.fr` | `https://staging.particulier.api.gouv.fr` | + +- Default is `production`. Switching to `staging` MUST be a single parameter + (`environment: :staging` or equivalent). +- Staging is backed by the deterministic fixtures in this repo's `mocks/` + folder. A test JWT is available at + `https://raw.githubusercontent.com/datagouv/apistration/develop/mocks/tokens/default` + and returns fictional data only. +- No embedded mock/bouchon mode is shipped with the client. Consumers stub the + HTTP layer at test time — see §12. + +A `base_url` override MUST be accepted for CI proxies, enterprise gateways, or +local mock servers. + +--- + +## 3. Authentication + +Both APIs use the OpenAPI security scheme `jwt_bearer_token`: + +``` +Authorization: Bearer +``` + +Tokens are long-lived (18 months) and issued via DataPass. Refresh is out of +scope for the client. + +### 3.1 Strategy seam (mandatory) + +The transport layer MUST NOT read a raw token string. It delegates to an +**auth strategy** interface called on every request. One concrete strategy +ships today: `BearerToken` (static JWT). Future strategies +(`OAuth2ClientCredentials`, `MTLS`, rotating providers, etc.) MUST be addable +without changing the transport or any resource method. The `token: "..."` +constructor argument is sugar that builds a `BearerToken`. + +### 3.2 Scopes + +Tokens carry scopes assigned at enrolment time. Scopes act as **data masks**, +not access gates: the same endpoint may return fewer fields for a narrower +token. Clients do not validate scopes; they surface whatever the API returns. + +--- + +## 4. Cross-cutting request parameters (API Entreprise) + +Nearly every API Entreprise endpoint requires three query parameters used for +audit and access control: + +| Parameter | Meaning | +|-------------|-------------------------------------------------------------| +| `recipient` | SIRET of the administration receiving the data. | +| `context` | Human-readable justification for the request. | +| `object` | Identifier of the case/file/user folder the request serves. | + +Clients MUST: + +1. Accept these three parameters as **client-level defaults**. +2. Allow **per-call override** as keyword arguments. +3. Raise a **local validation error** before any HTTP call if any required + parameter is missing — never silently send an incomplete request. +4. Validate `recipient` locally as a well-formed SIRET (§4.1) before any HTTP + call. + +API Particulier does not require `context` and `object`; it only uses +`recipient`. The same defaulting/override mechanism MUST exist but with a +smaller required set. The SIRET validation rule applies identically. + +### 4.1 SIRET validation (normative algorithm) + +A valid SIRET is either a 14-digit string passing the Luhn checksum, or a +"La Poste" SIRET whose checksum rule differs. + +``` +def valid_siret?(value): + if value is nil or not matching /\A\d{14}\z/: + return false + if value matches /^356000000\d{5}/: # La Poste + return true + return luhn_checksum(value) mod 10 == 0 + +def luhn_checksum(value): + accum = 0 + for index, digit in enumerate(reversed(value.digits)): + t = digit if index is even else digit * 2 + if t >= 10: t -= 9 + accum += t + return accum +``` + +Clients MUST raise a native validation error (`ArgumentError` or equivalent) +named `InvalidSiretError` (subclass of the language's argument error) when the +check fails, with a message identifying the offending parameter (`recipient` +vs other SIRET fields such as an endpoint's path parameter). The check runs +**before** any HTTP call; it is never skipped when a SIRET default is set. + +Reference implementation: `siade/app/validators/siret_format_validator.rb`. + +--- + +## 5. Success response envelope + +Every 2xx response is a JSON object with exactly these keys: + +```json +{ + "data": { /* or array */ }, + "links": { /* navigation / document URLs */ }, + "meta": { /* technical info exploitable by the caller — e.g. date_derniere_mise_a_jour, redirect_from_siren, redirect_from_siret. May be empty. */ } +} +``` + +Clients MUST surface `meta` verbatim even when empty; callers rely on the +stable shape. + +The client's `Response` object MUST expose, at minimum: + +- `data` — parsed body of the `data` key. +- `links` — parsed `links`. +- `meta` — parsed `meta`. +- `raw` — full deserialised body. +- `http_status` — integer. +- `headers` — case-insensitive map. +- `rate_limit` — parsed `RateLimit-*` (§7). + +`Response` is a value object: no side effects, stable field order. + +--- + +## 6. Error handling — JSON:API + +All 4xx/5xx responses follow the JSON:API error envelope: + +```json +{ + "errors": [ + { + "code": "00101", + "title": "Privilèges insuffisants", + "detail": "Votre token est valide mais vos privilèges sont insuffisants.", + "source": { "parameter": "recipient" }, + "meta": { "provider": "INSEE", "retry_in": 10 } + } + ] +} +``` + +`source` and `meta` are optional. `meta` carries provider-scoped diagnostics: + +- `meta.provider` — upstream data provider name (e.g. `"INSEE"`, `"DGFIP"`, + `"Douanes"`, `"URSSAF"`). Set whenever the error originates from (or is + attributed to) a specific upstream; absent on generic platform errors + (auth, rate-limit, input validation). Clients MUST surface it verbatim on + the exception (e.g. `error.provider` / `error.errors.first['meta']['provider']`). +- `meta.retry_in` — when present, expressed in **seconds** (seen on 502 + provider errors); the client MUST preserve the unit and surface it as-is. + +Provider-scoped errors come from `AbstractGenericProviderError` / +`AbstractSpecificProviderError` in the reference Rails app (siade) — see +`siade/app/errors/abstract_generic_provider_error.rb` and +`siade/app/serializers/errors_serializer.rb`. + +### 6.1 Exception hierarchy (normative) + +Every language maps HTTP status + first error code to one of these exceptions. +Names are idiomatic per language; semantics are fixed. + +``` +Error (base) +├── ClientError (4xx) +│ ├── AuthenticationError 401 (codes 00101, 00103, 00105) +│ ├── AuthorizationError 403 (code 00100) +│ ├── NotFoundError 404 +│ ├── ConflictError 409 (code 00015) +│ ├── ValidationError 422 (codes 002xx, 003xx) +│ └── RateLimitError 429 (code 00429) +├── ServerError (5xx) +│ ├── ProviderError 502 (codes 04xxx) +│ └── ProviderUnavailableError 503, 504 +└── TransportError (timeout, DNS, TLS, connection reset) +``` + +Every exception carries: + +| Field | Description | +|---------------|---------------------------------------------------------| +| `http_status` | Integer status code (nil for `TransportError`). | +| `errors` | Raw JSON:API `errors` array (empty for `TransportError`). | +| `method` | HTTP verb sent. | +| `url` | Fully-resolved URL sent. | + +Convenience accessors `first_error_code`, `first_error_title`, +`first_error_detail`, `first_error_source` MUST be provided for ergonomics. + +### 6.2 Mapping rule + +``` +if http_status == 401 → AuthenticationError +elif http_status == 403 → AuthorizationError +elif http_status == 404 → NotFoundError +elif http_status == 409 → ConflictError +elif http_status == 422 → ValidationError +elif http_status == 429 → RateLimitError +elif 400 ≤ http_status < 500 → ClientError +elif http_status == 502 → ProviderError +elif http_status in (503, 504) → ProviderUnavailableError +elif 500 ≤ http_status < 600 → ServerError +else (network / transport failure) → TransportError +``` + +The first-error `code` is used for documentation and logging only; the status +code is the primary key. + +--- + +## 7. Rate limiting + +The API exposes three response headers on every request: + +| Header | Meaning | +|-----------------------|-----------------------------------------------------------| +| `RateLimit-Limit` | Quota for the endpoint, in requests per minute. | +| `RateLimit-Remaining` | Remaining calls in the current one-minute window. | +| `RateLimit-Reset` | Unix timestamp marking the end of the current window. | + +### 7.1 Parsing + +Every `Response` MUST parse these headers into a `RateLimit` value object with +integer fields `limit`, `remaining`, and a timestamp `reset_at`. Missing or +unparseable headers yield `nil`. + +### 7.2 429 handling + +- `RateLimitError.retry_after` (seconds) is computed from `RateLimit-Reset - + now`, falling back to `meta.retry_in` when present. It MUST never be + negative; clamp at zero. +- When no source is available, `retry_after` MUST be the idiomatic + "unknown" value (`nil` / `None` / `null`) — **never `0`**, which would + incorrectly suggest "retry immediately". +- The client MUST NOT retry automatically by default. + +### 7.3 Optional retry middleware + +A retry layer MAY be configured by the caller, opt-in: + +``` +retry: { max: 2, on_status: [429, 502, 503], backoff: :exponential } +``` + +When enabled: + +- Respect `retry_after` on 429. +- Use exponential backoff with jitter on 502/503. +- Never retry 4xx other than 429. +- GETs are idempotent — all v3 endpoints are GETs — so retries are always safe. + +--- + +## 8. Timeouts + +Default connect timeout: **5 s**. Default read timeout: **30 s**. Both MUST +be overridable per client instance (via `Configuration`). Per-call overrides +are OPTIONAL — a language MAY expose them if idiomatic, but the default is +client-wide only. + +On timeout, raise `TransportError` with `method`, `url`, and the underlying +cause chained where the language supports it. + +--- + +## 9. Public API surface + +### 9.1 Shape + +Endpoints are grouped into **resource modules** named after the **provider** +(the second URL segment under `/v3/`, e.g. `insee`, `urssaf`, `dgfip`, +`inpi_rne`, `ademe`, `dss`, `mesri`…). OpenAPI `tags` are business-area +labels, not provider IDs, so they are ignored for grouping. + +``` +client..(, ) → Response +``` + +- Path parameters are positional. +- Query parameters are keyword / named arguments. +- `recipient` / `context` / `object` fall back to client defaults when omitted. +- Method name: last non-templated path segment(s) in snake_case. When that + produces a collision within the same provider (e.g. two endpoints ending + in `identite`), prefix with the preceding non-templated segment. +- **Versioning** (§1): endpoints are versioned independently via their URL + prefix (`/v3/`, `/v4/`, `/v5/`…). When the same logical endpoint exists in + multiple versions, the client exposes **one** canonical method whose + default version is the **latest available** (highest vN, regardless of its + `deprecated` flag — the upstream provider is presumed to promote the + newest contract). A `version:` kwarg lets callers pin a specific version + (e.g. `client.insee.unites_legales(siren, version: 3)`). Requesting a + version that does not exist for the endpoint MUST raise the + language-native argument error. Calling a version marked + `deprecated: true` in the OpenAPI spec MUST emit a deprecation warning + through the language-native channel. + +Example (Ruby, normative for other languages' idiomatic translation): + +```ruby +client.insee.unites_legales("418166096") +client.urssaf.attestation_vigilance("418166096", context: "Aide X") +client.dgfip.chiffres_affaires("418166096", annee: 2023) +``` + +### 9.2 Low-level escape hatch + +Clients MUST expose a generic HTTP method for endpoints that are not yet +wrapped or for experimentation: + +``` +client.get(path, params: {...}, headers: {...}) → Response +``` + +It applies auth, default cross-cutting params, envelope parsing, rate-limit +parsing, and error mapping — identical to high-level methods. + +### 9.3 Validation + +Each generated method MUST: + +1. Reject missing required parameters **before** any network call, with a + native language error (`ArgumentError`, `TypeError`, etc.) — not an API + exception. +2. Drop `nil`/`None` optional parameters from the query string. +3. Not coerce types beyond what the language natively does. + +### 9.4 Array-valued parameters + +OpenAPI declares array query parameters with a trailing `[]` in the name +(e.g. `prenoms[]`). On the wire, each element is emitted as its own +`key[]=value` pair: + +``` +?prenoms[]=Jean&prenoms[]=Paul +``` + +The client MUST produce exactly that encoding — **one** pair of brackets per +element, never `prenoms[][]=Jean`. When the HTTP library being used appends +`[]` automatically for array values (e.g. Ruby's Faraday), the scaffolder +MUST strip the trailing `[]` from the OpenAPI name before handing the value +to the library. When it does not, the `[]` stays on the key. + +Method signatures expose the language-idiomatic kwarg name **without** the +brackets: + +```ruby +client.dss.allocation_adulte_handicape_identite(prenoms: ['Jean', 'Paul'], …) +``` + +--- + +## 10. Logging & observability + +Clients MUST provide a logging hook/middleware that emits one structured event +per request with, at minimum: + +- `method`, `url` (path + query, **without** user-identifying params by default) +- `http_status` +- `duration_ms` +- `rate_limit_remaining` +- `request_id` if available + +**PII handling.** `recipient` is a SIRET (public). Other params on API +Particulier often contain personal data (names, dates of birth, INE). The +default formatter MUST redact the query string on API Particulier requests. +Opt-in verbose logging is acceptable. + +Every request MUST set a `User-Agent` of the form: + +``` +api-{entreprise|particulier}-/ (+https://github.com/datagouv/apistration) +``` + +--- + +## 11. Configuration + +Each client exposes an immutable `Configuration` object built from: + +1. Explicit constructor arguments (highest precedence). +2. Environment variables: + - `API_ENTREPRISE_TOKEN`, `API_ENTREPRISE_ENV`, `API_ENTREPRISE_BASE_URL` + - `API_PARTICULIER_TOKEN`, `API_PARTICULIER_ENV`, `API_PARTICULIER_BASE_URL` +3. Built-in defaults. + +`Configuration` supports a `with(...)` / `copy(...)` operation returning a new +instance — no in-place mutation. All I/O state (HTTP connection, middlewares) +is rebuilt from it. + +Required fields: `token` (or `auth_strategy`), `environment` or `base_url`. +Optional: `default_params` (`recipient`, `context`, `object`), +`open_timeout`, `read_timeout`, `retry`, `logger`, `user_agent`. + +--- + +## 12. Testing + +No bouchon/mock mode is embedded in the client. Testing is split into three +layers, all of which MUST exist. + +### 12.1 Unit tests (internal, mandatory) + +Each client's test suite MUST include unit tests covering at least the +following surfaces. These are contract tests for the reference implementation +and mirror tests for every port. + +- **SIRET validator** — positive cases (random Luhn-valid SIRETs, La Poste + pattern `356000000XXXXX`), negative cases (wrong length, non-digit + characters, Luhn mismatch, empty, `nil`/`None`). +- **Configuration precedence** — explicit arg > ENV > default, per field. +- **Configuration immutability** — `with()` / `copy()` returns a new instance; + the original is unchanged; no writer on any public field. +- **Auth strategy** — `BearerToken` emits the expected header; a strategy + that raises surfaces as `AuthenticationError` without any HTTP call. +- **Cross-cutting param defaulting** — per-call override wins over client + default; missing required param raises the local validation error before + any HTTP call (stubbed HTTP layer MUST record zero calls). +- **Envelope parser** — well-formed `{data, links, meta}` → all three + populated; missing `links` or `meta` → empty object exposed, never nil; + non-object body on 2xx → explicit parser error surfaced as `TransportError`. +- **Error mapper matrix** — one test per row: + + | HTTP | First `code` | Expected exception | + |------|--------------|------------------------------| + | 401 | `00101` | `AuthenticationError` | + | 401 | `00103` | `AuthenticationError` | + | 401 | `00105` | `AuthenticationError` | + | 403 | `00100` | `AuthorizationError` | + | 404 | *(any)* | `NotFoundError` | + | 409 | `00015` | `ConflictError` | + | 422 | `00201` | `ValidationError` | + | 422 | `00301` | `ValidationError` | + | 429 | `00429` | `RateLimitError` | + | 502 | `04001` | `ProviderError` | + | 503 | *(any)* | `ProviderUnavailableError` | + | 418 | *(any)* | `ClientError` (fallback) | + | 599 | *(any)* | `ServerError` (fallback) | + | n/a | *(network)* | `TransportError` | + + Each test asserts `http_status`, `errors[0].code`, `first_error_detail`, and + that `method` / `url` are populated. +- **Rate limit parser** — integer headers parsed; missing headers yield + `nil`; `reset_at` coerced to the language's timestamp type; malformed + values do not raise, they return `nil`. +- **`RateLimitError.retry_after`** — derived from `RateLimit-Reset` when + present; falls back to `meta.retry_in`; clamps at zero for past timestamps. +- **Retry middleware (when enabled)** — retries on 429/502/503 only; respects + `retry_after`; never retries on 401/403/404/422; stops at `max`. +- **User-Agent** — set on every request; matches the §10 format. +- **Particulier logging redaction** — query string redacted by default; + opt-in verbose mode exposes it. +- **Resource method signatures** — smoke test: every generated resource + method can be invoked with valid dummy params without raising locally + (HTTP stubbed). Guards against scaffold regressions. + +### 12.2 Integration tests (against stubs) + +Each client MUST include integration tests that wire the full middleware +stack and exercise, at minimum: + +- A 200 response with `data/links/meta` envelope and a non-empty `RateLimit-*` + header set. +- A 422 raising `ValidationError`. +- A 429 raising `RateLimitError` with `retry_after` computed from + `RateLimit-Reset`. +- A 502 raising `ProviderError` whose `meta.retry_in` is surfaced. +- One happy path on API Entreprise and one on API Particulier. + +Fixtures SHOULD be sourced from `mocks/payloads//` where available +(inlined or vendored) to stay aligned with staging. Inline JSON is acceptable +for the edge cases (429, 502 metadata) that are not covered by `mocks/`. + +### 12.3 Stubbing for consumers (README contract) + +Every client's README MUST include a **Testing** section with a runnable stub +example using the language-idiomatic tool: + +| Language | Recommended stubbing tool | +|----------|---------------------------------------| +| Ruby | [WebMock](https://github.com/bblimke/webmock) | +| Node | [nock](https://github.com/nock/nock) | +| Python | [responses](https://github.com/getsentry/responses) or `respx` | +| PHP | `Http::fake` (Laravel) or `GuzzleHttp\Handler\MockHandler` | +| Java | [WireMock](https://wiremock.org/) | + +At least one stub example MUST show a 200 and one MUST show a 429 with +`RateLimit-Reset` populated. + +See [`TESTING.md`](TESTING.md) for the full staging conformance playbook — +which fixtures to drive, what to assert, and which sections are unit-only. +Every new language client MUST pass that playbook before being declared +SPECS-conformant. + +### 12.4 Manual & CI + +- Manual staging conformance runs through [`TESTING.md`](TESTING.md) — a + one-shot "tap one endpoint and exit 0" smoke script is more cargo-cult + than proof, since the same happy path is already covered by stubbed + unit tests. Don't ship one. +- CI MUST run unit + integration tests on the two newest supported runtimes; + minimum supported version is declared in the package metadata. + +--- + +## 13. Thread safety + +`Client` instances are immutable after construction and MUST be safe to share +across threads / fibers / async tasks. `config.with(...)` yields an +independent instance. No process-global mutable state (loggers, counters, +auth state). + +--- + +## 14. Transport + +- **TLS verification** is mandatory and MUST NOT be disableable from public + configuration. Custom CAs via the language's standard mechanism + (`SSL_CERT_FILE`, truststore) are fine. +- `nil`/`None` query parameters are **dropped** (not sent as empty). +- `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` env vars are honoured (HTTP-lib + default in every target language). +- The client MUST NOT cache responses. + +--- + +## 15. Packaging & release + +- Runtime dependencies stay minimal: a single well-maintained HTTP library + plus an optional retry helper. No framework runtime dependency. +- SemVer (`MAJOR.MINOR.PATCH`). MAJOR = breaking change to public surface, + configuration, or exception hierarchy. MINOR = new resource methods or + optional parameters. PATCH = fixes. +- Public methods slated for removal emit a language-native deprecation + warning for at least one MINOR cycle before the MAJOR that removes them. +- `CHANGELOG.md` present and updated per release. + +--- + +## 16. Documentation + +- Every public class and method carries an inline doc comment in the + language's native format (YARD, JSDoc, docstrings, PHPDoc, Javadoc). +- README sections: *Installation*, *Configuration*, *Quickstart*, + *Error handling*, *Testing* (with a runnable stub example). + +--- + +## 17. Repository layout + +``` +clients/ + SPECS.md # this document + ruby/ + commons/ # shared source of truth (Ruby) + api_entreprise/ # published gem + api_particulier/ # published gem + bin/sync_commons # vendors commons/ into each gem + node/ + python/ + php/ + java/ +``` + +Each language subfolder SHOULD mirror this layout: a `commons/` source of +truth, one package per API, and a build-time vendoring step (not a runtime +dependency) to avoid cross-package release coupling. + +### 17.1 Isolation between sibling packages + +Consumers frequently load **both** packages (`api_entreprise` + `api_particulier`) +in the same process. Nothing in either package may rely on process-global +registries keyed by symbol — that includes Faraday middleware registration +(`Faraday.register_middleware`), Guzzle handler stacks, Axios global +interceptors, etc. The last package loaded would silently overwrite the +other's handlers, causing the wrong exception classes (or envelopes, or auth) +to be applied. + +Middlewares, interceptors, and plugins MUST be attached to the client's own +connection/stack by explicit reference (class / instance), not by +globally-registered name. The vendored commons MAY expose helpers, but the +wiring must be package-scoped. + +--- + +## 18. Conformance checklist + +A reviewer certifying a new client ticks each item. + +- [ ] `production` / `staging` envs and `base_url` override. +- [ ] Pluggable auth strategy; `BearerToken` ships. +- [ ] `recipient` / `context` / `object` defaultable + overridable; missing + required params raise locally before any HTTP call; `recipient` (and + path SIRETs) pass the Luhn + La Poste validator (§4.1). +- [ ] `Response` exposes `data`, `links`, `meta`, `raw`, `http_status`, + `headers`, `rate_limit`. +- [ ] Exception hierarchy (§6.1) and mapping rule (§6.2) implemented; + `first_error_*` accessors present. +- [ ] `RateLimit-*` headers parsed on every response; `RateLimitError. + retry_after` ≥ 0 when known, `nil` when unknown (never `0` as a + fallback); retry middleware opt-in and never retries non-429 4xx. +- [ ] Array-valued query params emitted as `?key[]=v1&key[]=v2`, never + `?key[][]=…` (§9.4). +- [ ] No process-global middleware / interceptor registration; wiring is + package-scoped so loading sibling clients together does not collide + (§17.1). +- [ ] 5 s connect / 30 s read timeouts, overridable. +- [ ] Resources grouped by provider (2nd path segment under `/v3/`); + snake-cased method names from the last meaningful path segments; + low-level `client.get(path, params:)` escape hatch. +- [ ] Logging hook with §10 fields; Particulier query string redacted; + `User-Agent` set. +- [ ] Immutable `Configuration` with `with()` / `copy()`; ENV vars honoured. +- [ ] Unit tests cover every surface listed in §12.1; integration tests + cover 200 / 422 / 429 / 502 on both APIs; staging conformance run + from TESTING.md passes; README has a stub example. +- [ ] `Client` shareable across threads; no process-global mutable state. +- [ ] TLS verification non-disableable; `nil` params dropped; proxy env + vars honoured; no response caching. +- [ ] Minimal runtime deps, no framework dependency. +- [ ] Every public method documented; README sections from §16 present. +- [ ] SemVer, `CHANGELOG.md`, one-MINOR deprecation cycle before removal. diff --git a/clients/TESTING.md b/clients/TESTING.md new file mode 100644 index 0000000000..778e544c30 --- /dev/null +++ b/clients/TESTING.md @@ -0,0 +1,231 @@ +# Testing against staging + +Staging (`staging.entreprise.api.gouv.fr`, `staging.particulier.api.gouv.fr`) +is the canonical target for manual conformance testing. It's a **deterministic +mock backend** — responses are driven by fixtures under `mocks/payloads/` +keyed by request parameters. **Before accepting a new client as +SPECS-conformant, exercise it against staging** following the playbook below. + +Unit tests (`spec/`, `test/`, …) cover the fast loop; staging is where you +observe what actually crosses the wire. + +--- + +## 1. Setup + +Get a token with broad scopes: + +```sh +TOKEN=$(curl -s https://raw.githubusercontent.com/datagouv/apistration/develop/mocks/tokens/default) +export API_ENTREPRISE_TOKEN=$TOKEN +export API_PARTICULIER_TOKEN=$TOKEN +``` + +The default token is a JWT (`sub=staging development`, far-future `exp`) that +covers every scope shipped on staging. Requests made with it are billed to +`siret=13002526500013` (DINUM) by convention — use it as the default +`recipient`. + +--- + +## 2. What to exercise + +The playbook is organised by SPECS.md section. Every section lists the +fixture(s) that trigger the behaviour at the time of writing. **Fixtures +change**; if a SIREN/SIRET no longer produces the documented status, look for +the current one in `mocks/payloads//summary.csv` or in the +matching `*.yaml` (each file has a `status:` and `params:` block at the top). +Don't hardcode SIRENs into CI — read `summary.csv` at test time. + +### 2.1 Happy path — every provider + +Tap at least one 200 endpoint on every provider the gem exposes. For +Entreprise, `SIREN=418166096` / `SIRET=41816609600051` exercise most +providers. For Particulier with a Bearer, prefer non-FranceConnect routes +(the FC variants need a FranceConnect session; §2.6 below). + +Check on each call: + +- `response.http_status == 200` +- `response.data` parsed (Hash / Array depending on endpoint) +- `response.rate_limit.{limit, remaining, reset_at}` populated +- `response.meta` surfaced verbatim (often `{}` — that's fine, see SPECS §5) + +Exercising the 23 Entreprise providers + 9 Particulier providers in one pass +catches scaffolder regressions (missing resource, typo'd path, etc.). + +### 2.2 Error mapping matrix (SPECS §6) + +Drive each HTTP status via a known fixture and assert the exception class + +`first_error_code` / `first_error_title` / `first_error_meta`. + +| HTTP | Exception | Fixture hint (verify current value) | +|------|----------------------------|--------------------------------------------------------------| +| 401 | `AuthenticationError` | any call with an invalid or absent token | +| 403 | `AuthorizationError` | `insee.successions(<403-SIRET>)` — code `00100` | +| 404 | `NotFoundError` | `france_travail.indemnites(identifiant: '')` — 24003| +| 409 | `ConflictError` | look for `409.yaml` under `mocks/payloads/` (code `00015`) | +| 422 | `ValidationError` | any malformed SIREN in the request body | +| 429 | `RateLimitError` | `mocks/payloads/*_cnav_*/429.yaml` | +| 502 | `ProviderError` | `insee.successions(<502-SIRET>)` + any CNAV/INSEE 502 fixture| +| 503 | `ProviderUnavailableError` | look for `503.yaml` fixtures | +| 504 | `ProviderUnavailableError` | `insee.successions(<504-SIRET>)` — same class as 503 | +| — | `TransportError` | not reachable against staging; cover in unit tests | + +For every provider-scoped 4xx/5xx (`AbstractGenericProviderError` / +`AbstractSpecificProviderError` family in siade), assert that +`first_error_meta['provider']` carries the upstream name +(e.g. `"INSEE"`, `"CNAV"`, `"DGFIP"`, `"Douanes"`). The mock backend is +slowly being audited to ensure every 5xx fixture sets this field — **fail +loudly** if a new fixture forgets it and open an issue against `mocks/`. + +`RateLimitError.retry_after`: must be an integer (≥ 0) when the server sends +a `Retry-After` header or a `meta.retry_in` field, and the idiomatic +"unknown" value (`nil` / `None` / `null`) otherwise. **Never `0`.** + +### 2.3 Local validation (SPECS §4.1, §9.3) + +- Luhn-invalid SIREN / SIRET → native argument error, **no** HTTP call. +- Luhn-invalid `recipient` default param → same, from the first resource call. +- `context` / `object` absent on Entreprise → native missing-parameter error. +- `recipient` absent on Particulier → same. + +Confirm the client fails *before* any request by checking no log line is +emitted (or by stubbing the HTTP layer in a unit test variant). + +### 2.4 Versioning (SPECS §9.1) + +- Default version = highest `vN` available for the endpoint. +- `version: ` pins to the requested version and, when the target is + flagged `deprecated` in OpenAPI, emits a native-idiomatic deprecation + warning (Ruby: `Kernel#warn`, Python: `warnings.warn`, Node: + `process.emitWarning`, etc.). +- `version: ` raises the native argument error synchronously. + +Suggested probes: `insee.unites_legales(siren, version: 3)` (deprecated +warning), `insee.unites_legales(siren, version: 99)` (raises). + +### 2.5 Envelope, rate-limit headers, low-level GET + +- `response.data`, `response.links`, `response.meta`, `response.raw`, + `response.http_status`, `response.headers`, `response.rate_limit` all + populated on 200. +- `response.headers` is case-insensitive: look up `RateLimit-Limit`, + `ratelimit-limit`, `RATELIMIT-LIMIT` — same value. +- `client.get(path, params:)` escape hatch reaches the same pipeline + (auth + defaults + envelope + error mapping). + +### 2.6 FranceConnect-flow endpoints + +`/…/france_connect` paths on Particulier require an actual FranceConnect +session — a static Bearer yields 401 even with all the right scopes. Don't +smoke-test them against staging with the default token. Cover them via unit +tests with stubbed responses, or via an end-to-end harness that provisions a +FC session. + +The identity-flow twin endpoints (`*_identite`) accept a Bearer and are the +right target for staging smoke tests. + +### 2.7 Array-valued query parameters (SPECS §9.4) + +Endpoints like `dss.allocation_*_identite(prenoms: […])` take arrays. The URL +on the wire must be `?prenoms[]=Jean&prenoms[]=Paul` — **one** pair of +brackets. If staging returns a 422 "prénoms manquants" on what should be a +valid call, suspect the client is encoding `prenoms[][]=…`. + +### 2.8 Logging & PII (SPECS §10) + +With a logger configured: + +- Entreprise: the query string (recipient/context/object) is fine to log. +- Particulier: the query string MUST be redacted by default (personal data). + Inject a `StringIO` logger, make a call with a recognisable PII value + (e.g. `nom_naissance: 'CANARY-ABC123'`), and assert the value does **not** + appear in the log output. + +### 2.9 Configuration & env vars (SPECS §11) + +- `API__TOKEN`, `API__ENV`, `API__BASE_URL` each take effect. +- `BASE_URL` override lets you point a "production" client at staging (useful + when debugging a ticket without mutating the consumer's code). + +### 2.10 Multi-SDK isolation (SPECS §17.1) + +Load **both** `api_entreprise` and `api_particulier` in the same process, +make an Entreprise call that raises (e.g. a known 502 fixture), assert the +exception is `ApiEntreprise::Commons::ProviderError` — **not** +`ApiParticulier::*`. Same check the other way. Any collision here is a +packaging bug (process-global registration, shared middleware symbol, etc.). + +--- + +## 3. What *not* to test against staging + +These behaviours are observable but not reliably reproducible on a mock: + +- **Retry middleware** (`faraday-retry` and friends): staging will not emit a + transient 502 followed by a 200 for the same request. Cover with stubbed + unit tests. +- **Timeouts**: staging is too fast to hit `open_timeout` / `read_timeout`. + Cover with `to_timeout`-style stubs. +- **Transport errors** (DNS, TLS, reset): same — unit tests only. +- **Thread-safety smoke** (§13): parallelism against staging tells you + nothing. Cover with concurrent unit tests against stubs. + +--- + +## 4. Fixtures change — keep the playbook honest + +The SIRENs / identités quoted in §2.2 above are **examples** captured at a +point in time. They're referenced into this file only because they were +accurate at the time of writing — the `mocks/` folder is the source of +truth: + +- `mocks/payloads//*.yaml` — one file per scenario, each with + `status:`, `params:`, and `payload:` at the top. +- `mocks/payloads//summary.csv` — machine-readable index. + +When writing a test runner: + +1. Walk `mocks/payloads/`. +2. Read `summary.csv` (or parse each `*.yaml` header). +3. Group by `(api, endpoint, status)`; pick one set of `params` for each + status you want to exercise. +4. Drive the SDK with those params; assert the classes / fields listed in + §2.2. + +This way the playbook stays accurate even as fixtures evolve. + +--- + +## 5. Reference implementation + +The Ruby gems ship a `bin/smoke` that executes the core happy path for each +API. It is intended as a **release-time** check, not a replacement for this +playbook. It should: + +- Exit 0 when staging is reachable and the auth + envelope + rate-limit + wiring is correct. +- Never target a FranceConnect-gated endpoint (§2.6). + +New language clients SHOULD ship an equivalent, in the idiomatic runner +(`npm run smoke`, `pytest -m smoke`, `mvn -Psmoke`, etc.). + +--- + +## 6. Reproducing the Ruby reference test run + +Scripts that exercised §§2.1–2.10 in one pass during the Ruby bootstrap live +in `sandbox/client-test-report/` (not committed as official tests — they're +transcripts of what was run). They're a starting point for porting the +playbook to a new language. + +```sh +R=sandbox/client-test-report +cd clients/ruby/api_entreprise && bundle exec ruby ../../../$R/test_entreprise.rb +cd clients/ruby/api_entreprise && bundle exec ruby ../../../$R/test_errors_entreprise.rb +cd clients/ruby/api_entreprise && bundle exec ruby ../../../$R/test_cover_entreprise.rb +cd clients/ruby/api_particulier && bundle exec ruby ../../../$R/test_particulier.rb +cd clients/ruby/api_particulier && bundle exec ruby ../../../$R/test_errors_particulier.rb +cd clients/ruby/api_particulier && bundle exec ruby ../../../$R/test_cover_particulier.rb +``` diff --git a/clients/ruby/README.md b/clients/ruby/README.md new file mode 100644 index 0000000000..bfdeb13f03 --- /dev/null +++ b/clients/ruby/README.md @@ -0,0 +1,207 @@ +# Ruby — implémentation de référence + +SDKs officiels en Ruby pour API Entreprise v3 et API Particulier v3. Sert de +*reference implementation* pour les autres langages (Node, Python, PHP, Java) +qui doivent se conformer à [`../SPECS.md`](../SPECS.md). + +## Structure + +``` +ruby/ + commons/ # source de vérité partagée + lib/api_gouv_commons/ # Configuration, Response, RateLimit, + auth/ # hiérarchie d'erreurs JSON:API, + middleware/ # validators SIRET/SIREN, 5 middlewares + # Faraday, ClientBase, UserAgent + spec/ # 65 specs unitaires — matrice §12.1 + Gemfile # faraday + faraday-retry + rspec + webmock + + api_entreprise/ # gem publié (23 providers, 52 endpoints) + api_entreprise.gemspec + lib/api_entreprise.rb + lib/api_entreprise/ + client.rb # façade : token/env/default_params + délégation + commons.rb # entry-point du commons vendorisé + commons/ # (généré) copie + namespace réécrit + resources/ # (généré) 1 fichier par provider + spec/ # 40 specs : envs, matrice 200/422/429/502, + # validation locale, smoke 23 providers + examples/{basic,error_handling,retry}.rb + README.md + + api_particulier/ # gem publié (9 providers, 36 endpoints) + … structure symétrique, 18 specs, examples … + + bin/ + sync_commons # vendorise commons/ dans chaque gem + # (copie + rewrite ApiGouvCommons → + # ApiEntreprise::Commons / ApiParticulier::Commons) + scaffold_resources # (re)génère lib/*/resources/*.rb depuis + # commons/swagger/openapi-*.yaml +``` + +## Conventions clés + +- **2 gems publiés, pas de dépendance croisée**. `commons/` est vendorisé à la + build via `bin/sync_commons`, jamais un runtime-dep partagé — ça évite le + couplage de releases. +- **Resources groupées par provider** (2ᵉ segment de l'URL `/v3/…`), pas par + tag OpenAPI (les tags sont descriptifs business, pas provider-oriented). +- **Méthode nommée sur le dernier segment non-templaté** du path (ex : + `/v3/urssaf/unites_legales/{siren}/attestation_vigilance` → + `client.urssaf.attestation_vigilance(siren)`). +- **Versions des endpoints indépendantes** : le même chemin logique peut + exister en v3, v4, v5… La méthode générée utilise par défaut la **plus + récente** version disponible (vN le plus grand) ; `version:` kwarg pour + pinner explicitement + (`client.insee.unites_legales(siren, version: 3)`). Version inconnue → + `ArgumentError`. Version deprecated → warning Ruby à l'appel. +- **Toutes les validations locales avant l'appel HTTP** : SIRET (Luhn + La + Poste), SIREN (Luhn), `recipient` / `context` / `object` requis sur + Entreprise, `recipient` requis sur Particulier. +- **Faraday 2** + middlewares maison (`Authentication`, `Logging` avec + redaction query-string pour Particulier, `RateLimitParser`, `ErrorHandler`, + `Envelope`), attachés par référence de classe (pas de `register_middleware` + global, pour garantir l'isolation quand les deux gems sont chargés dans le + même processus — voir SPECS §17.1), + `faraday-retry` optionnel opt-in + (exceptions = `RateLimitError`, `ProviderError`, `ProviderUnavailableError`, + `TransportError`). +- **RSpec + WebMock**, pas de VCR (aligné avec la consigne du repo). + +## Workflows + +### Lancer les tests + +```sh +cd clients/ruby/commons && bundle && bundle exec rspec # 65 / 65 +cd clients/ruby/api_entreprise && bundle && bundle exec rspec # 32 / 32 +cd clients/ruby/api_particulier && bundle && bundle exec rspec # 18 / 18 +``` + +### Lancer les exemples + +Ne nécessitent pas de réseau (sauf `basic.rb`) : + +```sh +cd clients/ruby/api_entreprise +bundle exec ruby examples/error_handling.rb # matrice d'exceptions +bundle exec ruby examples/retry.rb # retry opt-in +``` + +Avec un jeton de staging : + +```sh +TOKEN=$(curl -s https://raw.githubusercontent.com/datagouv/apistration/develop/mocks/tokens/default) + +API_ENTREPRISE_TOKEN=$TOKEN bundle exec ruby clients/ruby/api_entreprise/examples/basic.rb +API_PARTICULIER_TOKEN=$TOKEN bundle exec ruby clients/ruby/api_particulier/examples/basic.rb +``` + +Pour une validation pré-release complète contre staging, dérouler +[`../TESTING.md`](../TESTING.md). + +### Régénérer après un changement + +```sh +# 1. Modifier clients/ruby/commons/lib/** +bin/sync_commons # vendorise dans les 2 gems +cd commons && bundle exec rspec # valide la source +cd ../api_entreprise && bundle exec rspec # valide via le vendored +cd ../api_particulier && bundle exec rspec + +# 2. Changement de spec OpenAPI dans commons/swagger/ +bin/scaffold_resources --api all # régénère les resources +``` + +### Vérifier qu'on n'a rien oublié + +```sh +bin/sync_commons --check # sort en erreur si vendored périmé +bin/scaffold_resources --api all --check # idem pour les resources +``` + +La CI ([`.github/workflows/clients-ruby.yml`](../../.github/workflows/clients-ruby.yml)) +exécute les 3 suites rspec sur Ruby 3.2 + 3.3 + 4.0 et ces deux `--check`. + +## Quickstart consommateur + +```ruby +# Gemfile +gem 'api_entreprise' +gem 'api_particulier' + +# app code +client = ApiEntreprise::Client.new( + token: ENV['API_ENTREPRISE_TOKEN'], + environment: :staging, + default_params: { recipient: '13002526500013', context: 'Aide X', object: 'Dossier 42' } +) + +response = client.insee.unites_legales('418166096') +response.data # => { "siren" => "...", ... } +response.rate_limit.remaining +``` + +Gestion des erreurs et stubs dans les READMEs de chaque gem : +[`api_entreprise/README.md`](./api_entreprise/README.md), +[`api_particulier/README.md`](./api_particulier/README.md). + +## Publier une version sur rubygems.org + +### Prérequis (one-shot) + +1. Compte rubygems.org avec MFA actif (les gemspecs déclarent + `rubygems_mfa_required = true`). +2. **Premier release manuel** pour réserver les noms (avant que le trusted + publishing prenne le relais). Depuis un poste connecté à rubygems + (`gem signin`) : + ```sh + cd clients/ruby/api_entreprise && gem build api_entreprise.gemspec && gem push api_entreprise-0.1.0.gem + cd ../api_particulier && gem build api_particulier.gemspec && gem push api_particulier-0.1.0.gem + ``` +3. **Configurer le trusted publisher OIDC** sur rubygems + (`https://rubygems.org/profile/oidc/trusted_publishers/new`) pour chaque + gem : + + | Champ | Valeur | + |---|---| + | Repository owner | `datagouv` | + | Repository name | `apistration` | + | Workflow filename | `clients-ruby.yml` | + | Environment | `rubygems` | + +4. Côté GitHub : créer l'environment `rubygems` (Settings → Environments) + avec un protection rule "required reviewers" si on veut une approbation + manuelle avant push. + +### Cycle de release + +```sh +# 1. bump +$EDITOR clients/ruby/api_entreprise/lib/api_entreprise/version.rb # 0.1.0 → 0.2.0 +$EDITOR clients/ruby/api_entreprise/CHANGELOG.md # add entry +git add -A && git commit -m "Release api_entreprise 0.2.0" + +# 2. tag (le préfixe identifie la gem ; le suffixe doit matcher la version) +git tag ruby-api-entreprise-v0.2.0 +git push origin main --tags + +# → .github/workflows/clients-ruby.yml : +# - vérifie que le tag matche la version dans le gemspec +# - lance rspec +# - build + push via rubygems/release-gem (OIDC, pas de secret en clair) +# - crée la release GitHub associée au tag +``` + +Tags reconnus : + +- `ruby-api-entreprise-v` → publie `api_entreprise` +- `ruby-api-particulier-v` → publie `api_particulier` + +## Ajouter / corriger une resource à la main + +Les fichiers sous `lib/*/resources/*.rb` portent un header `DO NOT EDIT`. Si +une méthode générée est insatisfaisante (mauvais nom, paramètre requis mal +détecté), **ne pas patcher le fichier généré** : ajuster +`bin/scaffold_resources` puis régénérer. C'est la seule façon de garder les +autres ports (Node/Python/PHP/Java) alignés sur le même contrat. diff --git a/clients/ruby/api_entreprise/.rspec b/clients/ruby/api_entreprise/.rspec new file mode 100644 index 0000000000..7a2cc1a6e0 --- /dev/null +++ b/clients/ruby/api_entreprise/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--color diff --git a/clients/ruby/api_entreprise/CHANGELOG.md b/clients/ruby/api_entreprise/CHANGELOG.md new file mode 100644 index 0000000000..50545c5aae --- /dev/null +++ b/clients/ruby/api_entreprise/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to `api_entreprise` (Ruby) are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/) and the project +adheres to [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added +- Initial release — conforms to `clients/SPECS.md` §1–§20. +- `production` / `staging` environments with `base_url` override. +- `BearerToken` auth strategy with a pluggable `Auth::Strategy` seam. +- Client-level `default_params` with per-call override for `recipient` / + `context` / `object`. +- Local SIRET (Luhn + La Poste) and SIREN validation before any HTTP call. +- `Response` value object (`data`, `links`, `meta`, `raw`, `http_status`, + `headers`, `rate_limit`). +- Full JSON:API exception hierarchy (`AuthenticationError`, + `AuthorizationError`, `NotFoundError`, `ConflictError`, `ValidationError`, + `RateLimitError`, `ProviderError`, `ProviderUnavailableError`, + `TransportError`) with `first_error_*` accessors. +- `RateLimit-*` header parsing, `RateLimit` value object, `retry_after` + derivation from `Reset` or `meta.retry_in`. +- Opt-in retry middleware (429 / 502 / 503) via `faraday-retry`. +- 23 resource modules scaffolded from the OpenAPI spec, grouped by provider. +- Versioned endpoints: each method accepts a `version:` kwarg; default is + the latest available version; unknown version raises `ArgumentError`; + deprecated versions emit a language-native `warn` on call. +- `examples/{basic,error_handling,retry}.rb`. diff --git a/clients/ruby/api_entreprise/Gemfile b/clients/ruby/api_entreprise/Gemfile new file mode 100644 index 0000000000..500903f4b6 --- /dev/null +++ b/clients/ruby/api_entreprise/Gemfile @@ -0,0 +1,8 @@ +source 'https://rubygems.org' + +gemspec + +group :test do + gem 'rspec', '~> 3.12' + gem 'webmock', '~> 3.19' +end diff --git a/clients/ruby/api_entreprise/Gemfile.lock b/clients/ruby/api_entreprise/Gemfile.lock new file mode 100644 index 0000000000..0e68c6b6cd --- /dev/null +++ b/clients/ruby/api_entreprise/Gemfile.lock @@ -0,0 +1,85 @@ +PATH + remote: . + specs: + api_entreprise (0.1.0) + faraday (~> 2.0) + faraday-retry (~> 2.0) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + bigdecimal (4.1.1) + crack (1.0.1) + bigdecimal + rexml + diff-lcs (1.6.2) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) + hashdiff (1.2.1) + json (2.19.3) + logger (1.7.0) + net-http (0.9.1) + uri (>= 0.11.1) + public_suffix (7.0.5) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + uri (1.1.1) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + api_entreprise! + rspec (~> 3.12) + webmock (~> 3.19) + +CHECKSUMS + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + api_entreprise (0.1.0) + bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c + faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 + json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90 + +BUNDLED WITH + 4.0.3 diff --git a/clients/ruby/api_entreprise/LICENSE b/clients/ruby/api_entreprise/LICENSE new file mode 100644 index 0000000000..677e601ebe --- /dev/null +++ b/clients/ruby/api_entreprise/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DINUM (Direction Interministérielle du Numérique) + +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/clients/ruby/api_entreprise/README.md b/clients/ruby/api_entreprise/README.md new file mode 100644 index 0000000000..53937b0c76 --- /dev/null +++ b/clients/ruby/api_entreprise/README.md @@ -0,0 +1,109 @@ +# api_entreprise + +Ruby client for [API Entreprise v3](https://entreprise.api.gouv.fr). Conforms +to [`clients/SPECS.md`](../../SPECS.md). + +## Installation + +```ruby +# Gemfile +gem 'api_entreprise' +``` + +## Configuration + +```ruby +client = ApiEntreprise::Client.new( + token: ENV['API_ENTREPRISE_TOKEN'], + environment: :staging, # or :production (default) + default_params: { + recipient: '13002526500013', + context: 'Calcul aide', + object: 'Dossier 42' + } +) +``` + +Environment variables honoured: `API_ENTREPRISE_TOKEN`, `API_ENTREPRISE_ENV`, +`API_ENTREPRISE_BASE_URL`. + +## Quickstart + +```ruby +response = client.insee.unites_legales('418166096') +response.data # => { "siren" => "418166096", ... } +response.meta # => { "provider" => "INSEE", ... } +response.rate_limit # => # +``` + +Endpoints are versioned independently (v3, v4, v5, …). By default the client +calls the **latest available** version. Pin a specific version via the +`version:` kwarg: + +```ruby +client.insee.unites_legales('418166096') # → v4 (latest) +client.insee.unites_legales('418166096', version: 3) # → v3 (emits deprecation warning) +client.insee.unites_legales('418166096', version: 99) # → ArgumentError +``` + +Low-level escape hatch (full path, including version): + +```ruby +client.get('/v3/urssaf/unites_legales/418166096/attestation_vigilance') +``` + +## Error handling + +All errors inherit from `ApiEntreprise::Commons::Error`: + +```ruby +begin + client.insee.unites_legales('418166096') +rescue ApiEntreprise::Commons::ValidationError => e + e.first_error_code # => "00301" + e.first_error_detail # => "Le numéro de siren n'est pas correctement formatté" +rescue ApiEntreprise::Commons::RateLimitError => e + sleep e.retry_after + retry +rescue ApiEntreprise::Commons::ProviderError => e + Rails.logger.warn("provider down, retry_in=#{e.retry_after}s") +end +``` + +Hierarchy: `Error` → `ClientError` {`AuthenticationError`, +`AuthorizationError`, `NotFoundError`, `ConflictError`, `ValidationError`, +`RateLimitError`}, `ServerError` {`ProviderError`, `ProviderUnavailableError`}, +`TransportError`. + +## Testing + +The client makes no attempt to embed a mock mode. Stub the HTTP layer with +[WebMock](https://github.com/bblimke/webmock): + +```ruby +require 'webmock/rspec' + +stub_request(:get, %r{https://staging\.entreprise\.api\.gouv\.fr/v3/insee/sirene/unites_legales/418166096}) + .with(headers: { 'Authorization' => 'Bearer test' }) + .to_return( + status: 200, + headers: { 'Content-Type' => 'application/json', 'RateLimit-Remaining' => '49' }, + body: { data: { siren: '418166096' }, links: {}, meta: {} }.to_json + ) + +stub_request(:get, %r{.+}) + .to_return(status: 429, + headers: { 'RateLimit-Reset' => (Time.now.to_i + 30).to_s }, + body: { errors: [{ code: '00429', title: 'Trop de requêtes', detail: '...' }] }.to_json) +``` + +## Development + +This gem vendors shared commons code from `clients/ruby/commons/`. After any +change in `commons/`, regenerate the vendored copies: + +```sh +clients/ruby/bin/sync_commons +``` + +CI checks freshness via `clients/ruby/bin/sync_commons --check`. diff --git a/clients/ruby/api_entreprise/api_entreprise.gemspec b/clients/ruby/api_entreprise/api_entreprise.gemspec new file mode 100644 index 0000000000..4f42037a8a --- /dev/null +++ b/clients/ruby/api_entreprise/api_entreprise.gemspec @@ -0,0 +1,29 @@ +require_relative 'lib/api_entreprise/version' + +Gem::Specification.new do |spec| + spec.name = 'api_entreprise' + spec.version = ApiEntreprise::VERSION + spec.authors = ['DINUM'] + spec.email = ['api-entreprise@api.gouv.fr'] + spec.summary = 'Official Ruby client for API Entreprise v3' + spec.description = 'Idiomatic Ruby client for https://entreprise.api.gouv.fr — auth, envelope, error normalisation, rate limit.' + spec.homepage = 'https://github.com/datagouv/apistration' + spec.license = 'MIT' + + spec.required_ruby_version = '>= 3.1' + + spec.metadata = { + 'homepage_uri' => 'https://github.com/datagouv/apistration', + 'source_code_uri' => 'https://github.com/datagouv/apistration/tree/main/clients/ruby/api_entreprise', + 'changelog_uri' => 'https://github.com/datagouv/apistration/blob/main/clients/ruby/api_entreprise/CHANGELOG.md', + 'bug_tracker_uri' => 'https://github.com/datagouv/apistration/issues', + 'documentation_uri' => 'https://entreprise.api.gouv.fr/v3/', + 'rubygems_mfa_required' => 'true' + } + + spec.files = Dir['lib/**/*.rb', 'README.md', 'CHANGELOG.md', 'LICENSE'] + spec.require_paths = ['lib'] + + spec.add_dependency 'faraday', '~> 2.0' + spec.add_dependency 'faraday-retry', '~> 2.0' +end diff --git a/clients/ruby/api_entreprise/examples/basic.rb b/clients/ruby/api_entreprise/examples/basic.rb new file mode 100755 index 0000000000..44d3881c1c --- /dev/null +++ b/clients/ruby/api_entreprise/examples/basic.rb @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Basic happy path against staging. +# TOKEN=$(curl -s https://raw.githubusercontent.com/datagouv/apistration/develop/mocks/tokens/default) +# API_ENTREPRISE_TOKEN=$TOKEN bundle exec ruby examples/basic.rb + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'api_entreprise' + +client = ApiEntreprise::Client.new( + environment: :staging, + default_params: { + recipient: '13002526500013', + context: 'Exemple SDK', + object: 'Démonstration' + } +) + +response = client.insee.unites_legales('418166096') + +puts "status: #{response.http_status}" +puts "last_update: #{response.meta['date_derniere_mise_a_jour']}" +puts "remaining: #{response.rate_limit&.remaining}" +puts "siren: #{response.data&.dig('siren')}" +puts "denomination: #{response.data&.dig('personne_morale_attributs', 'raison_sociale')}" + +# When an upstream provider fails, its name surfaces on the raised exception +# (not on successful responses). +begin + response = client.insee.successions('61229628734734') +rescue ApiEntreprise::Commons::ProviderError => e + puts "provider_error: #{e.first_error_meta['provider'] || 'unknown'} (#{e.first_error_code})" +end diff --git a/clients/ruby/api_entreprise/examples/error_handling.rb b/clients/ruby/api_entreprise/examples/error_handling.rb new file mode 100755 index 0000000000..59fe45aaa0 --- /dev/null +++ b/clients/ruby/api_entreprise/examples/error_handling.rb @@ -0,0 +1,76 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Exercises every branch of the exception hierarchy with a stubbed HTTP layer. +# No network required. +# +# bundle exec ruby examples/error_handling.rb + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'api_entreprise' +require 'webmock' +include WebMock::API +WebMock.enable! +WebMock.disable_net_connect! + +BASE = 'https://staging.entreprise.api.gouv.fr' +PATH = '/v3/insee/sirene/unites_legales/418166096' + +def show(label) + yield +rescue ApiEntreprise::Commons::Error => e + puts "#{label.ljust(30)} -> #{e.class.name.split('::').last} " \ + "(status=#{e.http_status}, code=#{e.first_error_code}, detail=#{e.first_error_detail})" +rescue ArgumentError => e + puts "#{label.ljust(30)} -> #{e.class.name.split('::').last} (#{e.message.split("\n").first})" +end + +client = ApiEntreprise::Client.new( + token: 't', + environment: :staging, + default_params: { recipient: '13002526500013', context: 'ex', object: 'ex' } +) + +fixtures = { + 401 => { code: '00101', title: 'Invalide', detail: 'Token invalide' }, + 403 => { code: '00100', title: 'Forbidden', detail: 'Privilèges insuffisants' }, + 404 => { code: '04040', title: 'Not found', detail: 'SIREN introuvable' }, + 409 => { code: '00015', title: 'Conflict', detail: 'Doublon en cours' }, + 422 => { code: '00301', title: 'Invalid', detail: 'siren malformé' }, + 429 => { code: '00429', title: 'Rate', detail: 'Trop de requêtes' }, + 502 => { code: '04001', title: 'Provider', detail: 'Fournisseur KO', meta: { retry_in: 300 } }, + 503 => { code: '05000', title: 'Unavailable', detail: 'Maintenance' } +} + +fixtures.each do |status, err| + WebMock.reset! + stub_request(:get, %r{#{BASE}#{PATH}}).to_return( + status: status, + headers: { 'Content-Type' => 'application/json' }, + body: { errors: [err] }.to_json + ) + show("HTTP #{status}") { client.insee.unites_legales('418166096') } +end + +# RateLimitError#retry_after from RateLimit-Reset: +WebMock.reset! +stub_request(:get, %r{#{BASE}#{PATH}}).to_return( + status: 429, + headers: { 'Content-Type' => 'application/json', + 'RateLimit-Reset' => (Time.now.to_i + 30).to_s }, + body: { errors: [{ code: '00429', title: 't', detail: 'd', meta: {} }] }.to_json +) +begin + client.insee.unites_legales('418166096') +rescue ApiEntreprise::Commons::RateLimitError => e + puts "#{'429 with Reset'.ljust(30)} -> retry_after=#{e.retry_after}s" +end + +# Local validation, no HTTP at all: +show('local: bad SIREN') { client.insee.unites_legales('not-a-siren') } +show('local: bad recipient SIRET') do + bad = ApiEntreprise::Client.new(token: 't', + default_params: { recipient: '13002526500014', + context: 'c', object: 'o' }) + bad.insee.unites_legales('418166096') +end diff --git a/clients/ruby/api_entreprise/examples/retry.rb b/clients/ruby/api_entreprise/examples/retry.rb new file mode 100755 index 0000000000..9338f9ffe1 --- /dev/null +++ b/clients/ruby/api_entreprise/examples/retry.rb @@ -0,0 +1,38 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Opt-in retry middleware: retries 429/502/503 with backoff, respects retry_after. +# Demonstrated with a stubbed HTTP layer (3 consecutive 502, then a 200). +# +# bundle exec ruby examples/retry.rb + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'api_entreprise' +require 'webmock' +include WebMock::API +WebMock.enable! +WebMock.disable_net_connect! + +BASE = 'https://staging.entreprise.api.gouv.fr' +PATH = '/v3/insee/sirene/unites_legales/418166096' + +stub_request(:get, %r{#{BASE}#{PATH}}) + .to_return( + { status: 502, headers: { 'Content-Type' => 'application/json' }, + body: { errors: [{ code: '04001', title: 't', detail: 'provider KO' }] }.to_json }, + { status: 502, headers: { 'Content-Type' => 'application/json' }, + body: { errors: [{ code: '04001', title: 't', detail: 'provider KO' }] }.to_json }, + { status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: { 'siren' => '418166096' }, links: {}, meta: {} }.to_json } + ) + +client = ApiEntreprise::Client.new( + token: 't', + environment: :staging, + default_params: { recipient: '13002526500013', context: 'retry', object: 'retry' }, + retry: { max: 3, on_status: [429, 502, 503], interval: 0.1, backoff_factor: 2 } +) + +response = client.insee.unites_legales('418166096') +puts "Final status: #{response.http_status}" +puts "Data: #{response.data.inspect}" diff --git a/clients/ruby/api_entreprise/lib/api_entreprise.rb b/clients/ruby/api_entreprise/lib/api_entreprise.rb new file mode 100644 index 0000000000..8c346afacf --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise.rb @@ -0,0 +1,4 @@ +require 'faraday' +require_relative 'api_entreprise/version' +require_relative 'api_entreprise/commons' +require_relative 'api_entreprise/client' diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/client.rb b/clients/ruby/api_entreprise/lib/api_entreprise/client.rb new file mode 100644 index 0000000000..0cdde913ec --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/client.rb @@ -0,0 +1,136 @@ +require_relative 'commons' + +# +require_relative 'resources/ademe' +require_relative 'resources/banque_de_france' +require_relative 'resources/carif_oref' +require_relative 'resources/cibtp' +require_relative 'resources/cma_france' +require_relative 'resources/cnetp' +require_relative 'resources/data_subvention' +require_relative 'resources/dgfip' +require_relative 'resources/djepva' +require_relative 'resources/douanes' +require_relative 'resources/european_commission' +require_relative 'resources/fabrique_numerique_ministeres_sociaux' +require_relative 'resources/fntp' +require_relative 'resources/gip_mds' +require_relative 'resources/infogreffe' +require_relative 'resources/inpi' +require_relative 'resources/insee' +require_relative 'resources/ministere_interieur' +require_relative 'resources/msa' +require_relative 'resources/opqibi' +require_relative 'resources/probtp' +require_relative 'resources/qualibat' +require_relative 'resources/qualifelec' +require_relative 'resources/urssaf' +# + +module ApiEntreprise + BASE_URLS = { + Commons::Configuration::PRODUCTION => 'https://entreprise.api.gouv.fr', + Commons::Configuration::STAGING => 'https://staging.entreprise.api.gouv.fr' + }.freeze + + class Client < Commons::ClientBase + REQUIRED_PARAMS = %i[recipient context object].freeze + SIRET_PARAMS = %i[recipient].freeze + + def initialize(token: nil, environment: nil, default_params: {}, base_url: nil, auth_strategy: nil, **opts) + env_token = token || ENV.fetch('API_ENTREPRISE_TOKEN', nil) + env_env = (environment || ENV.fetch('API_ENTREPRISE_ENV', :production)).to_sym + + config = Commons::Configuration.new( + base_urls: BASE_URLS, + token: env_token, + auth_strategy: auth_strategy, + environment: env_env, + base_url: base_url || ENV.fetch('API_ENTREPRISE_BASE_URL', nil), + default_params: default_params, + user_agent: opts[:user_agent] || Commons::UserAgent.build(product: 'api-entreprise-ruby', version: VERSION), + open_timeout: opts[:open_timeout] || Commons::Configuration::DEFAULT_OPEN_TIMEOUT, + read_timeout: opts[:read_timeout] || Commons::Configuration::DEFAULT_READ_TIMEOUT, + retry: opts[:retry], + logger: opts[:logger], + adapter: opts[:adapter] + ) + super(config, product: :entreprise) + end + + # + def ademe + @ademe ||= Resources::Ademe.new(self) + end + def banque_de_france + @banque_de_france ||= Resources::BanqueDeFrance.new(self) + end + def carif_oref + @carif_oref ||= Resources::CarifOref.new(self) + end + def cibtp + @cibtp ||= Resources::Cibtp.new(self) + end + def cma_france + @cma_france ||= Resources::CmaFrance.new(self) + end + def cnetp + @cnetp ||= Resources::Cnetp.new(self) + end + def data_subvention + @data_subvention ||= Resources::DataSubvention.new(self) + end + def dgfip + @dgfip ||= Resources::Dgfip.new(self) + end + def djepva + @djepva ||= Resources::Djepva.new(self) + end + def douanes + @douanes ||= Resources::Douanes.new(self) + end + def european_commission + @european_commission ||= Resources::EuropeanCommission.new(self) + end + def fabrique_numerique_ministeres_sociaux + @fabrique_numerique_ministeres_sociaux ||= Resources::FabriqueNumeriqueMinisteresSociaux.new(self) + end + def fntp + @fntp ||= Resources::Fntp.new(self) + end + def gip_mds + @gip_mds ||= Resources::GipMds.new(self) + end + def infogreffe + @infogreffe ||= Resources::Infogreffe.new(self) + end + def inpi + @inpi ||= Resources::Inpi.new(self) + end + def insee + @insee ||= Resources::Insee.new(self) + end + def ministere_interieur + @ministere_interieur ||= Resources::MinistereInterieur.new(self) + end + def msa + @msa ||= Resources::Msa.new(self) + end + def opqibi + @opqibi ||= Resources::Opqibi.new(self) + end + def probtp + @probtp ||= Resources::Probtp.new(self) + end + def qualibat + @qualibat ||= Resources::Qualibat.new(self) + end + def qualifelec + @qualifelec ||= Resources::Qualifelec.new(self) + end + def urssaf + @urssaf ||= Resources::Urssaf.new(self) + end + # + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons.rb new file mode 100644 index 0000000000..6d719a3042 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiEntreprise; end +module ApiEntreprise::Commons; end + +require_relative 'commons/version' +require_relative 'commons/errors' +require_relative 'commons/siret' +require_relative 'commons/siren' +require_relative 'commons/rate_limit' +require_relative 'commons/response' +require_relative 'commons/user_agent' +require_relative 'commons/auth/strategy' +require_relative 'commons/auth/bearer_token' +require_relative 'commons/middleware/authentication' +require_relative 'commons/middleware/envelope' +require_relative 'commons/middleware/error_handler' +require_relative 'commons/middleware/rate_limit' +require_relative 'commons/middleware/logging' +require_relative 'commons/configuration' +require_relative 'commons/client_base' diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/auth/bearer_token.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/auth/bearer_token.rb new file mode 100644 index 0000000000..49134d6e4f --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/auth/bearer_token.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require_relative 'strategy' + +module ApiEntreprise::Commons + module Auth + class BearerToken < Strategy + def initialize(token) + raise ArgumentError, 'token must be a non-empty string' if token.nil? || token.to_s.strip.empty? + + @token = token.to_s + end + + def apply(request) + request.headers['Authorization'] = "Bearer #{@token}" + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/auth/strategy.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/auth/strategy.rb new file mode 100644 index 0000000000..b8d01b8f33 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/auth/strategy.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiEntreprise::Commons + module Auth + class Strategy + def apply(request) + raise NotImplementedError, "#{self.class} must implement #apply(request)" + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/client_base.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/client_base.rb new file mode 100644 index 0000000000..5b25051bc5 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/client_base.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' +begin + require 'faraday/retry' +rescue LoadError + # faraday-retry is optional; the :retry middleware is only used when the + # consumer opts in. +end + +require_relative 'middleware/authentication' +require_relative 'middleware/logging' +require_relative 'middleware/rate_limit' +require_relative 'middleware/error_handler' +require_relative 'middleware/envelope' +require_relative 'response' +require_relative 'siret' +require_relative 'errors' + +module ApiEntreprise::Commons + class ClientBase + attr_reader :configuration + + REQUIRED_PARAMS = %i[recipient context object].freeze + SIRET_PARAMS = %i[recipient].freeze + + def initialize(configuration, product:) + @configuration = configuration + @product = product + @connection = build_connection + end + + def get(path, params: {}, headers: {}) + merged = merge_params(params) + validate_required!(merged) + validate_sirets!(merged) + + response = @connection.get(path, clean(merged), headers) + build_response(response) + end + + private + + def merge_params(params) + defaults = @configuration.default_params.transform_keys(&:to_s) + defaults.merge((params || {}).transform_keys(&:to_s)) + end + + def required_params_for(_params) + self.class::REQUIRED_PARAMS + end + + def siret_params_for(_params) + self.class::SIRET_PARAMS + end + + def validate_required!(params) + required_params_for(params).each do |key| + next unless blank?(params[key.to_s]) + + raise MissingParameterError, "required parameter #{key.inspect} is missing" + end + end + + def validate_sirets!(params) + siret_params_for(params).each do |key| + value = params[key.to_s] + next if value.nil? + + Siret.validate!(value, parameter: key) + end + end + + def blank?(value) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + + def clean(params) + params.reject { |_, v| v.nil? } + end + + def build_response(response) + Response.new( + raw: response.body, + http_status: response.status, + headers: response.headers, + rate_limit: response.env[Middleware::RateLimitParser::ENV_KEY] + ) + end + + def build_connection + cfg = @configuration + Faraday.new(url: cfg.base_url) do |conn| + conn.options.open_timeout = cfg.open_timeout + conn.options.timeout = cfg.read_timeout + + conn.headers['User-Agent'] = cfg.user_agent if cfg.user_agent + conn.headers['Accept'] = 'application/json' + + if cfg.retry && defined?(Faraday::Retry) + conn.request :retry, + max: cfg.retry.fetch(:max, 2), + retry_statuses: cfg.retry.fetch(:on_status, [429, 502, 503]), + methods: %i[get], + interval: cfg.retry.fetch(:interval, 0.5), + backoff_factor: cfg.retry.fetch(:backoff_factor, 2), + exceptions: [ + ApiEntreprise::Commons::RateLimitError, + ApiEntreprise::Commons::ProviderError, + ApiEntreprise::Commons::ProviderUnavailableError, + ApiEntreprise::Commons::TransportError + ] + end + + conn.use Middleware::Authentication, auth_strategy: cfg.auth_strategy + conn.use Middleware::Logging, logger: cfg.logger, redact_query: @product == :particulier + + conn.use Middleware::RateLimitParser + conn.use Middleware::ErrorHandler + conn.use Middleware::Envelope + + conn.adapter(cfg.adapter || Faraday.default_adapter) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/configuration.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/configuration.rb new file mode 100644 index 0000000000..2f0274c7f3 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/configuration.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require_relative 'auth/bearer_token' + +module ApiEntreprise::Commons + class Configuration + PRODUCTION = :production + STAGING = :staging + ENVIRONMENTS = [PRODUCTION, STAGING].freeze + + DEFAULT_OPEN_TIMEOUT = 5 + DEFAULT_READ_TIMEOUT = 30 + + attr_reader :base_url, + :environment, + :auth_strategy, + :default_params, + :open_timeout, + :read_timeout, + :retry, + :logger, + :user_agent, + :adapter + + def initialize( + base_urls:, + token: nil, + auth_strategy: nil, + environment: PRODUCTION, + base_url: nil, + default_params: {}, + open_timeout: DEFAULT_OPEN_TIMEOUT, + read_timeout: DEFAULT_READ_TIMEOUT, + retry: nil, + logger: nil, + user_agent: nil, + adapter: nil + ) + @base_urls = base_urls + resolved_env = resolve_environment(environment) + @environment = resolved_env + @explicit_base_url = !base_url.nil? + @base_url = base_url || base_urls.fetch(resolved_env) + @auth_strategy = auth_strategy || build_bearer_strategy(token) + @default_params = default_params.freeze + @open_timeout = open_timeout + @read_timeout = read_timeout + @retry = binding.local_variable_get(:retry) + @logger = logger + @user_agent = user_agent + @adapter = adapter + freeze + end + + def with(**overrides) + self.class.new(**current_attrs.merge(overrides)) + end + alias copy with + + def production? + environment == PRODUCTION + end + + def staging? + environment == STAGING + end + + private + + def current_attrs + { + base_urls: @base_urls, + auth_strategy: auth_strategy, + environment: environment, + base_url: @explicit_base_url ? base_url : nil, + default_params: default_params, + open_timeout: open_timeout, + read_timeout: read_timeout, + retry: @retry, + logger: logger, + user_agent: user_agent, + adapter: adapter + } + end + + def resolve_environment(value) + env = value.to_sym + return env if ENVIRONMENTS.include?(env) + + raise ArgumentError, "environment must be one of #{ENVIRONMENTS.inspect}; got #{value.inspect}" + end + + def build_bearer_strategy(token) + return nil if token.nil? + + Auth::BearerToken.new(token) + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/errors.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/errors.rb new file mode 100644 index 0000000000..90b7750447 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/errors.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiEntreprise::Commons + class Error < StandardError + attr_reader :http_status, :errors, :method, :url + + def initialize(message = nil, http_status: nil, errors: [], method: nil, url: nil) + super(message || default_message(http_status, errors)) + @http_status = http_status + @errors = errors || [] + @method = method + @url = url + end + + def first_error + errors.first || {} + end + + def first_error_code + first_error['code'] || first_error[:code] + end + + def first_error_title + first_error['title'] || first_error[:title] + end + + def first_error_detail + first_error['detail'] || first_error[:detail] + end + + def first_error_source + first_error['source'] || first_error[:source] + end + + def first_error_meta + first_error['meta'] || first_error[:meta] || {} + end + + private + + def default_message(http_status, errors) + first = (errors || []).first || {} + title = first['title'] || first[:title] + detail = first['detail'] || first[:detail] + parts = [http_status, title, detail].compact + parts.empty? ? self.class.name : parts.join(' — ') + end + end + + class ClientError < Error; end + class AuthenticationError < ClientError; end + class AuthorizationError < ClientError; end + class NotFoundError < ClientError; end + class ConflictError < ClientError; end + class ValidationError < ClientError; end + + class RateLimitError < ClientError + attr_reader :retry_after + + def initialize(message = nil, retry_after: nil, **kwargs) + super(message, **kwargs) + @retry_after = retry_after + end + end + + class ServerError < Error; end + class ProviderError < ServerError + attr_reader :retry_after + + def initialize(message = nil, retry_after: nil, **kwargs) + super(message, **kwargs) + @retry_after = retry_after + end + end + class ProviderUnavailableError < ServerError; end + + class TransportError < Error; end + + class InvalidSiretError < ArgumentError; end + class InvalidSirenError < ArgumentError; end + class MissingParameterError < ArgumentError; end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/authentication.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/authentication.rb new file mode 100644 index 0000000000..5ede0759fd --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/authentication.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' + +module ApiEntreprise::Commons + module Middleware + class Authentication < Faraday::Middleware + def initialize(app, auth_strategy:) + super(app) + @auth_strategy = auth_strategy + end + + def on_request(env) + return if @auth_strategy.nil? + + request = RequestWrapper.new(env) + begin + @auth_strategy.apply(request) + rescue StandardError => e + raise ApiEntreprise::Commons::AuthenticationError.new( + "auth strategy raised: #{e.message}", + method: env.method, + url: env.url.to_s + ) + end + end + + class RequestWrapper + def initialize(env) + @env = env + end + + def headers + @env.request_headers + end + + def method + @env.method + end + + def url + @env.url + end + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/envelope.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/envelope.rb new file mode 100644 index 0000000000..2322e03608 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/envelope.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' +require 'json' + +module ApiEntreprise::Commons + module Middleware + class Envelope < Faraday::Middleware + def on_complete(env) + body = env.body + return if body.nil? || body.is_a?(Hash) || body.is_a?(Array) + return unless body.is_a?(String) && !body.empty? + + parsed = + begin + JSON.parse(body) + rescue JSON::ParserError + raise ApiEntreprise::Commons::TransportError.new( + "invalid JSON body: #{body[0, 200]}", + method: env.method, + url: env.url.to_s + ) + end + + env.body = parsed + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/error_handler.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/error_handler.rb new file mode 100644 index 0000000000..6b38e71dc6 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/error_handler.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' +require 'json' + +module ApiEntreprise::Commons + module Middleware + class ErrorHandler < Faraday::Middleware + AUTH_CODES = %w[00101 00103 00105].freeze + AUTHORIZATION_CODES = %w[00100].freeze + + def on_complete(env) + status = env.status + return if status.between?(200, 299) + + exception = map_exception(status, env) + raise exception if exception + end + + def call(env) + super + rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e + raise ApiEntreprise::Commons::TransportError.new( + e.message, + method: env.method, + url: env.url.to_s + ) + end + + private + + def map_exception(status, env) + errors = extract_errors(env.body) + klass = klass_for(status) + return nil unless klass + + kwargs = { + http_status: status, + errors: errors, + method: env.method, + url: env.url.to_s + } + + if klass == ApiEntreprise::Commons::RateLimitError + kwargs[:retry_after] = compute_retry_after(env, errors) + elsif klass == ApiEntreprise::Commons::ProviderError + kwargs[:retry_after] = provider_retry(errors) + end + + klass.new(nil, **kwargs) + end + + def klass_for(status) + case status + when 401 then ApiEntreprise::Commons::AuthenticationError + when 403 then ApiEntreprise::Commons::AuthorizationError + when 404 then ApiEntreprise::Commons::NotFoundError + when 409 then ApiEntreprise::Commons::ConflictError + when 422 then ApiEntreprise::Commons::ValidationError + when 429 then ApiEntreprise::Commons::RateLimitError + when 400..499 then ApiEntreprise::Commons::ClientError + when 502 then ApiEntreprise::Commons::ProviderError + when 503, 504 then ApiEntreprise::Commons::ProviderUnavailableError + when 500..599 then ApiEntreprise::Commons::ServerError + end + end + + def extract_errors(body) + parsed = body + parsed = safely_parse(body) if body.is_a?(String) + return [] unless parsed.is_a?(Hash) + + Array(parsed['errors'] || parsed[:errors]) + end + + def safely_parse(body) + JSON.parse(body) + rescue JSON::ParserError + nil + end + + def compute_retry_after(env, errors) + from_headers = ApiEntreprise::Commons::RateLimit.from_headers(env.response_headers)&.retry_after + return from_headers if from_headers && from_headers.positive? + + provider_retry(errors) || from_headers + end + + def provider_retry(errors) + first = errors.first || {} + meta = first['meta'] || first[:meta] || {} + value = meta['retry_in'] || meta[:retry_in] + return nil if value.nil? + + Integer(value) + rescue ArgumentError, TypeError + nil + end + end + end +end +# No Faraday.register_middleware: symbols are process-global and collide when +# multiple gouv.fr gems are loaded in the same process. Clients pass the class +# directly to conn.response / conn.use. diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/logging.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/logging.rb new file mode 100644 index 0000000000..86d73a59e0 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/logging.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' + +module ApiEntreprise::Commons + module Middleware + class Logging < Faraday::Middleware + def initialize(app, logger: nil, redact_query: false) + super(app) + @logger = logger + @redact_query = redact_query + end + + def call(env) + started = monotonic_now + response = @app.call(env) + log(env, response, monotonic_now - started) if @logger + response + rescue StandardError => e + log_error(env, e, monotonic_now - started) if @logger + raise + end + + private + + def log(env, response, duration_ms) + @logger.info( + method: env.method.to_s.upcase, + url: safe_url(env.url), + status: response.status, + duration_ms: duration_ms.round(1), + rate_limit_remaining: extract_remaining(response.env.response_headers) + ) + end + + def log_error(env, exception, duration_ms) + @logger.error( + method: env.method.to_s.upcase, + url: safe_url(env.url), + error: exception.class.name, + message: exception.message, + duration_ms: duration_ms.round(1) + ) + end + + def safe_url(url) + return url.to_s unless @redact_query + + dup = url.dup + dup.query = nil + "#{dup}?[REDACTED]" + end + + def extract_remaining(headers) + return nil unless headers + + headers.each do |k, v| + return v.to_i if k.to_s.downcase == 'ratelimit-remaining' && !v.nil? && !v.to_s.empty? + end + nil + end + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0 + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/rate_limit.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/rate_limit.rb new file mode 100644 index 0000000000..42e7cd26a4 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/middleware/rate_limit.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' + +module ApiEntreprise::Commons + module Middleware + class RateLimitParser < Faraday::Middleware + ENV_KEY = :api_gouv_rate_limit + + def on_complete(env) + env[ENV_KEY] = ApiEntreprise::Commons::RateLimit.from_headers(env.response_headers) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/rate_limit.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/rate_limit.rb new file mode 100644 index 0000000000..eed059c72c --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/rate_limit.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiEntreprise::Commons + class RateLimit + attr_reader :limit, :remaining, :reset_at + + def self.from_headers(headers) + return nil if headers.nil? + + normalized = headers.transform_keys { |k| k.to_s.downcase } + limit = parse_int(normalized['ratelimit-limit']) + remaining = parse_int(normalized['ratelimit-remaining']) + reset_at = parse_reset(normalized['ratelimit-reset']) + + return nil if limit.nil? && remaining.nil? && reset_at.nil? + + new(limit: limit, remaining: remaining, reset_at: reset_at) + end + + def self.parse_int(value) + return nil if value.nil? || value.to_s.strip.empty? + + Integer(value.to_s, 10) + rescue ArgumentError + nil + end + + def self.parse_reset(value) + ts = parse_int(value) + return nil if ts.nil? + + Time.at(ts).utc + end + + def initialize(limit:, remaining:, reset_at:) + @limit = limit + @remaining = remaining + @reset_at = reset_at + end + + def retry_after(now: Time.now) + return nil if reset_at.nil? + + diff = reset_at.to_i - now.to_i + diff.negative? ? 0 : diff + end + + def to_h + { limit: limit, remaining: remaining, reset_at: reset_at } + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/response.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/response.rb new file mode 100644 index 0000000000..1371d2ebf6 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/response.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiEntreprise::Commons + class Response + attr_reader :raw, :http_status, :headers, :rate_limit + + def initialize(raw:, http_status:, headers:, rate_limit: nil) + @raw = raw.is_a?(Hash) ? raw : {} + @http_status = http_status + @headers = headers || {} + @rate_limit = rate_limit + end + + def data + raw['data'] + end + + def links + raw['links'] || {} + end + + def meta + raw['meta'] || {} + end + + def success? + http_status.to_i.between?(200, 299) + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/siren.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/siren.rb new file mode 100644 index 0000000000..f3ebf9bb14 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/siren.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiEntreprise::Commons + module Siren + module_function + + DIGITS_9 = /\A\d{9}\z/.freeze + LA_POSTE_PATTERN = /\A356000000\z/.freeze + + def valid?(value) + return false if value.nil? + return false unless value.to_s.match?(DIGITS_9) + return true if value.to_s.match?(LA_POSTE_PATTERN) + + (luhn_checksum(value.to_s) % 10).zero? + end + + def validate!(value, parameter:) + return if valid?(value) + + raise InvalidSirenError, + "#{parameter.inspect} must be a 9-digit SIREN passing the Luhn checksum; got #{value.inspect}" + end + + def luhn_checksum(value) + accum = 0 + value.reverse.each_char.map(&:to_i).each_with_index do |digit, index| + t = index.even? ? digit : digit * 2 + t -= 9 if t >= 10 + accum += t + end + accum + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/siret.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/siret.rb new file mode 100644 index 0000000000..5be69aa096 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/siret.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiEntreprise::Commons + module Siret + module_function + + LA_POSTE_PATTERN = /\A356000000\d{5}\z/.freeze + DIGITS_14 = /\A\d{14}\z/.freeze + + def valid?(value) + return false if value.nil? + return false unless value.to_s.match?(DIGITS_14) + return true if value.to_s.match?(LA_POSTE_PATTERN) + + (luhn_checksum(value.to_s) % 10).zero? + end + + def validate!(value, parameter:) + return if valid?(value) + + raise InvalidSiretError, + "#{parameter.inspect} must be a 14-digit SIRET passing the Luhn checksum (or a La Poste SIRET); got #{value.inspect}" + end + + def luhn_checksum(value) + accum = 0 + value.reverse.each_char.map(&:to_i).each_with_index do |digit, index| + t = index.even? ? digit : digit * 2 + t -= 9 if t >= 10 + accum += t + end + accum + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/user_agent.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/user_agent.rb new file mode 100644 index 0000000000..4f6ab02232 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/user_agent.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiEntreprise::Commons + module UserAgent + URL = 'https://github.com/datagouv/apistration'.freeze + + module_function + + def build(product:, version:, suffix: nil) + base = "#{product}/#{version} (+#{URL})" + suffix ? "#{base} #{suffix}" : base + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/commons/version.rb b/clients/ruby/api_entreprise/lib/api_entreprise/commons/version.rb new file mode 100644 index 0000000000..110571d711 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/commons/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiEntreprise::Commons + VERSION = '0.1.0'.freeze +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/ademe.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/ademe.rb new file mode 100644 index 0000000000..33c87f583f --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/ademe.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Ademe + def initialize(client) + @client = client + end + + # Certification RGE + # Logical endpoint: /ademe/etablissements/{siret}/certification_rge + # Versions available: [3] — default: 3 + def certification_rge(siret, version: nil, recipient: nil, context: nil, object: nil, limit: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/ademe/etablissements/#{siret}/certification_rge" + else + raise ArgumentError, "version #{version.inspect} not available for /ademe/etablissements/{siret}/certification_rge; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object, "limit" => limit }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/banque_de_france.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/banque_de_france.rb new file mode 100644 index 0000000000..e8066920c6 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/banque_de_france.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class BanqueDeFrance + def initialize(client) + @client = client + end + + # 3 derniers bilans annuels + # Logical endpoint: /banque_de_france/unites_legales/{siren}/bilans + # Versions available: [3] — default: 3 + def bilans(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/banque_de_france/unites_legales/#{siren}/bilans" + else + raise ArgumentError, "version #{version.inspect} not available for /banque_de_france/unites_legales/{siren}/bilans; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/carif_oref.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/carif_oref.rb new file mode 100644 index 0000000000..536e778a46 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/carif_oref.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class CarifOref + def initialize(client) + @client = client + end + + # Qualiopi & habilitations France compétences + # Logical endpoint: /carif_oref/etablissements/{siret}/certifications_qualiopi_france_competences + # Versions available: [3] — default: 3 + def certifications_qualiopi_france_competences(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/carif_oref/etablissements/#{siret}/certifications_qualiopi_france_competences" + else + raise ArgumentError, "version #{version.inspect} not available for /carif_oref/etablissements/{siret}/certifications_qualiopi_france_competences; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/cibtp.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/cibtp.rb new file mode 100644 index 0000000000..a4effb711a --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/cibtp.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Cibtp + def initialize(client) + @client = client + end + + # Certificat cotisations CIBTP + # Logical endpoint: /cibtp/etablissements/{siret}/attestation_cotisations_conges_payes_chomage_intemperies + # Versions available: [3] — default: 3 + def attestation_cotisations_conges_payes_chomage_intemperies(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/cibtp/etablissements/#{siret}/attestation_cotisations_conges_payes_chomage_intemperies" + else + raise ArgumentError, "version #{version.inspect} not available for /cibtp/etablissements/{siret}/attestation_cotisations_conges_payes_chomage_intemperies; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/cma_france.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/cma_france.rb new file mode 100644 index 0000000000..db73dfb4e6 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/cma_france.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class CmaFrance + def initialize(client) + @client = client + end + + # Données du RNM d'une entreprise artisanale + # Logical endpoint: /cma_france/rnm/unites_legales/{siren} + # Versions available: [3] — default: 3 (deprecated) + def unites_legales(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + warn "[DEPRECATED] /v3/cma_france/rnm/unites_legales/{siren} (#unites_legales): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/cma_france/rnm/unites_legales/#{siren}" + else + raise ArgumentError, "version #{version.inspect} not available for /cma_france/rnm/unites_legales/{siren}; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/cnetp.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/cnetp.rb new file mode 100644 index 0000000000..834a47ae41 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/cnetp.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Cnetp + def initialize(client) + @client = client + end + + # Certificat cotisations CNETP + # Logical endpoint: /cnetp/unites_legales/{siren}/attestation_cotisations_conges_payes_chomage_intemperies + # Versions available: [3] — default: 3 + def attestation_cotisations_conges_payes_chomage_intemperies(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/cnetp/unites_legales/#{siren}/attestation_cotisations_conges_payes_chomage_intemperies" + else + raise ArgumentError, "version #{version.inspect} not available for /cnetp/unites_legales/{siren}/attestation_cotisations_conges_payes_chomage_intemperies; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/data_subvention.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/data_subvention.rb new file mode 100644 index 0000000000..c0fbc89ee8 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/data_subvention.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class DataSubvention + def initialize(client) + @client = client + end + + # Subventions des associations + # Logical endpoint: /data_subvention/associations/{siren_or_siret_or_rna}/subventions + # Versions available: [3] — default: 3 + def subventions(siren_or_siret_or_rna, version: nil, recipient: nil, context: nil, object: nil) + path = + case version || 3 + when 3 + "/v3/data_subvention/associations/#{siren_or_siret_or_rna}/subventions" + else + raise ArgumentError, "version #{version.inspect} not available for /data_subvention/associations/{siren_or_siret_or_rna}/subventions; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/dgfip.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/dgfip.rb new file mode 100644 index 0000000000..159f6f60e0 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/dgfip.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Dgfip + def initialize(client) + @client = client + end + + # Chiffre d'affaires + # Logical endpoint: /dgfip/etablissements/{siret}/chiffres_affaires + # Versions available: [3] — default: 3 + def chiffres_affaires(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/dgfip/etablissements/#{siret}/chiffres_affaires" + else + raise ArgumentError, "version #{version.inspect} not available for /dgfip/etablissements/{siret}/chiffres_affaires; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Attestation fiscale + # Logical endpoint: /dgfip/unites_legales/{siren}/attestation_fiscale + # Versions available: [3, 4] — default: 4 + def attestation_fiscale(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/dgfip/unites_legales/{siren}/attestation_fiscale (#attestation_fiscale): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/dgfip/unites_legales/#{siren}/attestation_fiscale" + when 4 + "/v4/dgfip/unites_legales/#{siren}/attestation_fiscale" + else + raise ArgumentError, "version #{version.inspect} not available for /dgfip/unites_legales/{siren}/attestation_fiscale; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Liasses fiscales + # Logical endpoint: /dgfip/unites_legales/{siren}/liasses_fiscales/{year} + # Versions available: [3] — default: 3 + def liasses_fiscales(siren, year, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/dgfip/unites_legales/#{siren}/liasses_fiscales/#{year}" + else + raise ArgumentError, "version #{version.inspect} not available for /dgfip/unites_legales/{siren}/liasses_fiscales/{year}; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Liens capitalistiques + # Logical endpoint: /dgfip/unites_legales/{siren}/liens_capitalistiques/{year} + # Versions available: [3] — default: 3 + def liens_capitalistiques(siren, year, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/dgfip/unites_legales/#{siren}/liens_capitalistiques/#{year}" + else + raise ArgumentError, "version #{version.inspect} not available for /dgfip/unites_legales/{siren}/liens_capitalistiques/{year}; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/djepva.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/djepva.rb new file mode 100644 index 0000000000..5f716ba778 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/djepva.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Djepva + def initialize(client) + @client = client + end + + # Données association en open data + # Logical endpoint: /djepva/api-association/associations/open_data/{siren_or_rna} + # Versions available: [4] — default: 4 + def open_data(siren_or_rna, version: nil, recipient: nil, context: nil, object: nil) + path = + case version || 4 + when 4 + "/v4/djepva/api-association/associations/open_data/#{siren_or_rna}" + else + raise ArgumentError, "version #{version.inspect} not available for /djepva/api-association/associations/open_data/{siren_or_rna}; supported: [4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Données association + # Logical endpoint: /djepva/api-association/associations/{siren_or_rna} + # Versions available: [4] — default: 4 + def associations(siren_or_rna, version: nil, recipient: nil, context: nil, object: nil) + path = + case version || 4 + when 4 + "/v4/djepva/api-association/associations/#{siren_or_rna}" + else + raise ArgumentError, "version #{version.inspect} not available for /djepva/api-association/associations/{siren_or_rna}; supported: [4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/douanes.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/douanes.rb new file mode 100644 index 0000000000..5fbf58b154 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/douanes.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Douanes + def initialize(client) + @client = client + end + + # Immatriculation EORI + # Logical endpoint: /douanes/etablissements/{siret_or_eori}/immatriculations_eori + # Versions available: [3] — default: 3 + def immatriculations_eori(siret_or_eori, version: nil, recipient: nil, context: nil, object: nil) + path = + case version || 3 + when 3 + "/v3/douanes/etablissements/#{siret_or_eori}/immatriculations_eori" + else + raise ArgumentError, "version #{version.inspect} not available for /douanes/etablissements/{siret_or_eori}/immatriculations_eori; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/european_commission.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/european_commission.rb new file mode 100644 index 0000000000..5d700784e9 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/european_commission.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class EuropeanCommission + def initialize(client) + @client = client + end + + # N°TVA intracommunautaire français + # Logical endpoint: /european_commission/unites_legales/{siren}/numero_tva + # Versions available: [3] — default: 3 + def numero_tva(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/european_commission/unites_legales/#{siren}/numero_tva" + else + raise ArgumentError, "version #{version.inspect} not available for /european_commission/unites_legales/{siren}/numero_tva; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/fabrique_numerique_ministeres_sociaux.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/fabrique_numerique_ministeres_sociaux.rb new file mode 100644 index 0000000000..c25c7c2234 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/fabrique_numerique_ministeres_sociaux.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class FabriqueNumeriqueMinisteresSociaux + def initialize(client) + @client = client + end + + # Conventions collectives + # Logical endpoint: /fabrique_numerique_ministeres_sociaux/etablissements/{siret}/conventions_collectives + # Versions available: [3] — default: 3 (deprecated) + def conventions_collectives(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + warn "[DEPRECATED] /v3/fabrique_numerique_ministeres_sociaux/etablissements/{siret}/conventions_collectives (#conventions_collectives): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/fabrique_numerique_ministeres_sociaux/etablissements/#{siret}/conventions_collectives" + else + raise ArgumentError, "version #{version.inspect} not available for /fabrique_numerique_ministeres_sociaux/etablissements/{siret}/conventions_collectives; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/fntp.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/fntp.rb new file mode 100644 index 0000000000..6e8a9026e4 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/fntp.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Fntp + def initialize(client) + @client = client + end + + # Carte professionnelle travaux publics + # Logical endpoint: /fntp/unites_legales/{siren}/carte_professionnelle_travaux_publics + # Versions available: [3] — default: 3 + def carte_professionnelle_travaux_publics(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/fntp/unites_legales/#{siren}/carte_professionnelle_travaux_publics" + else + raise ArgumentError, "version #{version.inspect} not available for /fntp/unites_legales/{siren}/carte_professionnelle_travaux_publics; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/gip_mds.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/gip_mds.rb new file mode 100644 index 0000000000..2887a2aebb --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/gip_mds.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class GipMds + def initialize(client) + @client = client + end + + # Effectifs mensuels d'un établissement + # Logical endpoint: /gip_mds/etablissements/{siret}/effectifs_mensuels/{month}/annee/{year} + # Versions available: [3] — default: 3 + def annee(siret, year, month, version: nil, recipient: nil, context: nil, object: nil, profondeur: nil, nature_effectif: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/gip_mds/etablissements/#{siret}/effectifs_mensuels/#{month}/annee/#{year}" + else + raise ArgumentError, "version #{version.inspect} not available for /gip_mds/etablissements/{siret}/effectifs_mensuels/{month}/annee/{year}; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object, "profondeur" => profondeur, "nature_effectif" => nature_effectif }.compact) + end + + # Effectifs annuels d'une unité légale + # Logical endpoint: /gip_mds/unites_legales/{siren}/effectifs_annuels/{year} + # Versions available: [3] — default: 3 + def effectifs_annuels(siren, year, version: nil, recipient: nil, context: nil, object: nil, nature_effectif: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/gip_mds/unites_legales/#{siren}/effectifs_annuels/#{year}" + else + raise ArgumentError, "version #{version.inspect} not available for /gip_mds/unites_legales/{siren}/effectifs_annuels/{year}; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object, "nature_effectif" => nature_effectif }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/infogreffe.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/infogreffe.rb new file mode 100644 index 0000000000..b96d56ff69 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/infogreffe.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Infogreffe + def initialize(client) + @client = client + end + + # Extrait RCS + # Logical endpoint: /infogreffe/rcs/unites_legales/{siren}/extrait_kbis + # Versions available: [3] — default: 3 + def extrait_kbis(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/infogreffe/rcs/unites_legales/#{siren}/extrait_kbis" + else + raise ArgumentError, "version #{version.inspect} not available for /infogreffe/rcs/unites_legales/{siren}/extrait_kbis; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Mandataires sociaux + # Logical endpoint: /infogreffe/rcs/unites_legales/{siren}/mandataires_sociaux + # Versions available: [3] — default: 3 + def mandataires_sociaux(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/infogreffe/rcs/unites_legales/#{siren}/mandataires_sociaux" + else + raise ArgumentError, "version #{version.inspect} not available for /infogreffe/rcs/unites_legales/{siren}/mandataires_sociaux; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/inpi.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/inpi.rb new file mode 100644 index 0000000000..32c652d0cc --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/inpi.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Inpi + def initialize(client) + @client = client + end + + # Actes et bilans + # Logical endpoint: /inpi/rne/unites_legales/open_data/{siren}/actes_bilans + # Versions available: [3] — default: 3 + def actes_bilans(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/inpi/rne/unites_legales/open_data/#{siren}/actes_bilans" + else + raise ArgumentError, "version #{version.inspect} not available for /inpi/rne/unites_legales/open_data/{siren}/actes_bilans; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Bénéficiaires effectifs + # Logical endpoint: /inpi/rne/unites_legales/{siren}/beneficiaires_effectifs + # Versions available: [3] — default: 3 + def beneficiaires_effectifs(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/inpi/rne/unites_legales/#{siren}/beneficiaires_effectifs" + else + raise ArgumentError, "version #{version.inspect} not available for /inpi/rne/unites_legales/{siren}/beneficiaires_effectifs; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Attestation d'immatriculation RNE + # Logical endpoint: /inpi/rne/unites_legales/{siren}/extrait_rne + # Versions available: [3] — default: 3 + def extrait_rne(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/inpi/rne/unites_legales/#{siren}/extrait_rne" + else + raise ArgumentError, "version #{version.inspect} not available for /inpi/rne/unites_legales/{siren}/extrait_rne; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/insee.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/insee.rb new file mode 100644 index 0000000000..6488841d26 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/insee.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Insee + def initialize(client) + @client = client + end + + # Données établissement en open data + # Logical endpoint: /insee/sirene/etablissements/diffusibles/{siret} + # Versions available: [3, 4] — default: 4 + def diffusibles(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/insee/sirene/etablissements/diffusibles/{siret} (#diffusibles): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/insee/sirene/etablissements/diffusibles/#{siret}" + when 4 + "/v4/insee/sirene/etablissements/diffusibles/#{siret}" + else + raise ArgumentError, "version #{version.inspect} not available for /insee/sirene/etablissements/diffusibles/{siret}; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Adresse établissement en open data + # Logical endpoint: /insee/sirene/etablissements/diffusibles/{siret}/adresse + # Versions available: [3] — default: 3 + def adresse(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/insee/sirene/etablissements/diffusibles/#{siret}/adresse" + else + raise ArgumentError, "version #{version.inspect} not available for /insee/sirene/etablissements/diffusibles/{siret}/adresse; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Données établissement + # Logical endpoint: /insee/sirene/etablissements/{siret} + # Versions available: [3, 4] — default: 4 + def etablissements(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/insee/sirene/etablissements/{siret} (#etablissements): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/insee/sirene/etablissements/#{siret}" + when 4 + "/v4/insee/sirene/etablissements/#{siret}" + else + raise ArgumentError, "version #{version.inspect} not available for /insee/sirene/etablissements/{siret}; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Adresse établissement + # Logical endpoint: /insee/sirene/etablissements/{siret}/adresse + # Versions available: [3] — default: 3 + def sirene_etablissements_adresse(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/insee/sirene/etablissements/#{siret}/adresse" + else + raise ArgumentError, "version #{version.inspect} not available for /insee/sirene/etablissements/{siret}/adresse; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Liens de succession + # Logical endpoint: /insee/sirene/etablissements/{siret}/successions + # Versions available: [3] — default: 3 + def successions(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/insee/sirene/etablissements/#{siret}/successions" + else + raise ArgumentError, "version #{version.inspect} not available for /insee/sirene/etablissements/{siret}/successions; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Données unité légale en open data + # Logical endpoint: /insee/sirene/unites_legales/diffusibles/{siren} + # Versions available: [3, 4] — default: 4 + def sirene_unites_legales_diffusibles(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/insee/sirene/unites_legales/diffusibles/{siren} (#sirene_unites_legales_diffusibles): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/insee/sirene/unites_legales/diffusibles/#{siren}" + when 4 + "/v4/insee/sirene/unites_legales/diffusibles/#{siren}" + else + raise ArgumentError, "version #{version.inspect} not available for /insee/sirene/unites_legales/diffusibles/{siren}; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Données siège social en open data + # Logical endpoint: /insee/sirene/unites_legales/diffusibles/{siren}/siege_social + # Versions available: [3, 4] — default: 4 + def siege_social(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/insee/sirene/unites_legales/diffusibles/{siren}/siege_social (#siege_social): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/insee/sirene/unites_legales/diffusibles/#{siren}/siege_social" + when 4 + "/v4/insee/sirene/unites_legales/diffusibles/#{siren}/siege_social" + else + raise ArgumentError, "version #{version.inspect} not available for /insee/sirene/unites_legales/diffusibles/{siren}/siege_social; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Données unité légale + # Logical endpoint: /insee/sirene/unites_legales/{siren} + # Versions available: [3, 4] — default: 4 + def unites_legales(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/insee/sirene/unites_legales/{siren} (#unites_legales): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/insee/sirene/unites_legales/#{siren}" + when 4 + "/v4/insee/sirene/unites_legales/#{siren}" + else + raise ArgumentError, "version #{version.inspect} not available for /insee/sirene/unites_legales/{siren}; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Données siège social + # Logical endpoint: /insee/sirene/unites_legales/{siren}/siege_social + # Versions available: [3, 4] — default: 4 + def sirene_unites_legales_siege_social(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/insee/sirene/unites_legales/{siren}/siege_social (#sirene_unites_legales_siege_social): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/insee/sirene/unites_legales/#{siren}/siege_social" + when 4 + "/v4/insee/sirene/unites_legales/#{siren}/siege_social" + else + raise ArgumentError, "version #{version.inspect} not available for /insee/sirene/unites_legales/{siren}/siege_social; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/ministere_interieur.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/ministere_interieur.rb new file mode 100644 index 0000000000..f96ccb496b --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/ministere_interieur.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class MinistereInterieur + def initialize(client) + @client = client + end + + # Données du RNA d'une association + # Logical endpoint: /ministere_interieur/rna/associations/{siret_or_rna} + # Versions available: [3] — default: 3 (deprecated) + def associations(siret_or_rna, version: nil, recipient: nil, context: nil, object: nil) + path = + case version || 3 + when 3 + warn "[DEPRECATED] /v3/ministere_interieur/rna/associations/{siret_or_rna} (#associations): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/ministere_interieur/rna/associations/#{siret_or_rna}" + else + raise ArgumentError, "version #{version.inspect} not available for /ministere_interieur/rna/associations/{siret_or_rna}; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Divers documents d'une association + # Logical endpoint: /ministere_interieur/rna/associations/{siret_or_rna}/documents + # Versions available: [3] — default: 3 (deprecated) + def documents(siret_or_rna, version: nil, recipient: nil, context: nil, object: nil) + path = + case version || 3 + when 3 + warn "[DEPRECATED] /v3/ministere_interieur/rna/associations/{siret_or_rna}/documents (#documents): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/ministere_interieur/rna/associations/#{siret_or_rna}/documents" + else + raise ArgumentError, "version #{version.inspect} not available for /ministere_interieur/rna/associations/{siret_or_rna}/documents; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/msa.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/msa.rb new file mode 100644 index 0000000000..8a1afb4021 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/msa.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Msa + def initialize(client) + @client = client + end + + # Conformité cotisations de sécurité sociale agricole + # Logical endpoint: /msa/etablissements/{siret}/conformite_cotisations + # Versions available: [3] — default: 3 + def conformite_cotisations(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/msa/etablissements/#{siret}/conformite_cotisations" + else + raise ArgumentError, "version #{version.inspect} not available for /msa/etablissements/{siret}/conformite_cotisations; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/opqibi.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/opqibi.rb new file mode 100644 index 0000000000..6f3e69844c --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/opqibi.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Opqibi + def initialize(client) + @client = client + end + + # Certification d'ingénierie OPQIBI + # Logical endpoint: /opqibi/unites_legales/{siren}/certification_ingenierie + # Versions available: [3] — default: 3 + def certification_ingenierie(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 3 + when 3 + "/v3/opqibi/unites_legales/#{siren}/certification_ingenierie" + else + raise ArgumentError, "version #{version.inspect} not available for /opqibi/unites_legales/{siren}/certification_ingenierie; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/probtp.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/probtp.rb new file mode 100644 index 0000000000..bede2659e0 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/probtp.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Probtp + def initialize(client) + @client = client + end + + # Conformité cotisations retraite bâtiment + # Logical endpoint: /probtp/etablissements/{siret}/attestation_cotisations_retraite + # Versions available: [3] — default: 3 + def attestation_cotisations_retraite(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/probtp/etablissements/#{siret}/attestation_cotisations_retraite" + else + raise ArgumentError, "version #{version.inspect} not available for /probtp/etablissements/{siret}/attestation_cotisations_retraite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + + # Conformité cotisations retraite complémentaire + # Logical endpoint: /probtp/etablissements/{siret}/conformite_cotisations_retraite + # Versions available: [3] — default: 3 + def conformite_cotisations_retraite(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/probtp/etablissements/#{siret}/conformite_cotisations_retraite" + else + raise ArgumentError, "version #{version.inspect} not available for /probtp/etablissements/{siret}/conformite_cotisations_retraite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/qualibat.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/qualibat.rb new file mode 100644 index 0000000000..fc419a5173 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/qualibat.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Qualibat + def initialize(client) + @client = client + end + + # Certification Qualibat + # Logical endpoint: /qualibat/etablissements/{siret}/certification_batiment + # Versions available: [3, 4] — default: 4 + def certification_batiment(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/qualibat/etablissements/{siret}/certification_batiment (#certification_batiment): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/qualibat/etablissements/#{siret}/certification_batiment" + when 4 + "/v4/qualibat/etablissements/#{siret}/certification_batiment" + else + raise ArgumentError, "version #{version.inspect} not available for /qualibat/etablissements/{siret}/certification_batiment; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/qualifelec.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/qualifelec.rb new file mode 100644 index 0000000000..7577c3ea50 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/qualifelec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Qualifelec + def initialize(client) + @client = client + end + + # Certification Qualifelec + # Logical endpoint: /qualifelec/etablissements/{siret}/certificats + # Versions available: [3] — default: 3 + def certificats(siret, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siret.validate!(siret, parameter: :siret) + path = + case version || 3 + when 3 + "/v3/qualifelec/etablissements/#{siret}/certificats" + else + raise ArgumentError, "version #{version.inspect} not available for /qualifelec/etablissements/{siret}/certificats; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/resources/urssaf.rb b/clients/ruby/api_entreprise/lib/api_entreprise/resources/urssaf.rb new file mode 100644 index 0000000000..09acade407 --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/resources/urssaf.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiEntreprise + module Resources + class Urssaf + def initialize(client) + @client = client + end + + # Attestation de vigilance + # Logical endpoint: /urssaf/unites_legales/{siren}/attestation_vigilance + # Versions available: [3, 4] — default: 4 + def attestation_vigilance(siren, version: nil, recipient: nil, context: nil, object: nil) + Commons::Siren.validate!(siren, parameter: :siren) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/urssaf/unites_legales/{siren}/attestation_vigilance (#attestation_vigilance): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/urssaf/unites_legales/#{siren}/attestation_vigilance" + when 4 + "/v4/urssaf/unites_legales/#{siren}/attestation_vigilance" + else + raise ArgumentError, "version #{version.inspect} not available for /urssaf/unites_legales/{siren}/attestation_vigilance; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "context" => context, "object" => object }.compact) + end + end + end +end diff --git a/clients/ruby/api_entreprise/lib/api_entreprise/version.rb b/clients/ruby/api_entreprise/lib/api_entreprise/version.rb new file mode 100644 index 0000000000..c4bd87cc6b --- /dev/null +++ b/clients/ruby/api_entreprise/lib/api_entreprise/version.rb @@ -0,0 +1,3 @@ +module ApiEntreprise + VERSION = '0.1.0'.freeze +end diff --git a/clients/ruby/api_entreprise/spec/client_spec.rb b/clients/ruby/api_entreprise/spec/client_spec.rb new file mode 100644 index 0000000000..5ff6509cf3 --- /dev/null +++ b/clients/ruby/api_entreprise/spec/client_spec.rb @@ -0,0 +1,152 @@ +RSpec.describe ApiEntreprise::Client do + let(:default_params) do + { recipient: '13002526500013', context: 'Calcul aide', object: 'Dossier 42' } + end + + describe 'configuration precedence' do + it 'reads the token from API_ENTREPRISE_TOKEN when no arg is given' do + ENV['API_ENTREPRISE_TOKEN'] = 'env-token' + begin + c = described_class.new(environment: :staging, default_params: default_params) + stub = stub_request(:get, %r{staging\.entreprise\.api\.gouv\.fr/v4/insee/sirene/unites_legales/418166096}) + .with(headers: { 'Authorization' => 'Bearer env-token' }) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: {}, links: {}, meta: {} }.to_json) + c.insee.unites_legales('418166096') + expect(stub).to have_been_requested + ensure + ENV.delete('API_ENTREPRISE_TOKEN') + end + end + + it 'explicit token argument wins over ENV' do + ENV['API_ENTREPRISE_TOKEN'] = 'env-token' + begin + c = described_class.new(token: 'arg-token', environment: :staging, default_params: default_params) + stub = stub_request(:get, %r{staging\.entreprise\.api\.gouv\.fr/v4/insee/sirene/unites_legales/418166096}) + .with(headers: { 'Authorization' => 'Bearer arg-token' }) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: {}, links: {}, meta: {} }.to_json) + c.insee.unites_legales('418166096') + expect(stub).to have_been_requested + ensure + ENV.delete('API_ENTREPRISE_TOKEN') + end + end + + it 'reads the base_url from API_ENTREPRISE_BASE_URL' do + ENV['API_ENTREPRISE_BASE_URL'] = 'https://gateway.test' + begin + c = described_class.new(token: 't') + expect(c.configuration.base_url).to eq('https://gateway.test') + ensure + ENV.delete('API_ENTREPRISE_BASE_URL') + end + end + end + + describe 'user agent' do + it 'sets a User-Agent matching §10 format on every request' do + client = described_class.new(token: 't', environment: :staging, default_params: default_params) + stub = stub_request(:get, %r{/v4/insee/sirene/unites_legales/418166096}) + .with(headers: { + 'User-Agent' => %r{\Aapi-entreprise-ruby/\d+\.\d+\.\d+ \(\+https://github\.com/datagouv/apistration\)\z} + }) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: {}, links: {}, meta: {} }.to_json) + client.insee.unites_legales('418166096') + expect(stub).to have_been_requested + end + end + + describe 'environments' do + it 'defaults to production URL' do + c = described_class.new(token: 't') + expect(c.configuration.base_url).to eq('https://entreprise.api.gouv.fr') + end + + it 'switches to staging URL' do + c = described_class.new(token: 't', environment: :staging) + expect(c.configuration.base_url).to eq('https://staging.entreprise.api.gouv.fr') + end + + it 'honours base_url override' do + c = described_class.new(token: 't', base_url: 'https://custom.test') + expect(c.configuration.base_url).to eq('https://custom.test') + end + end + + describe 'end-to-end contract (§12.2)' do + let(:client) do + described_class.new(token: 't', environment: :staging, default_params: default_params) + end + + def stub_staging(path, status:, body:, headers: {}) + stub_request(:get, "https://staging.entreprise.api.gouv.fr#{path}") + .with(query: hash_including('recipient' => '13002526500013'), + headers: { 'Authorization' => 'Bearer t' }) + .to_return(status: status, + headers: { 'Content-Type' => 'application/json' }.merge(headers), + body: body.to_json) + end + + it '200 with data/links/meta envelope and RateLimit-* parsed' do + stub_staging('/v4/insee/sirene/unites_legales/418166096', + status: 200, + headers: { 'RateLimit-Limit' => '50', 'RateLimit-Remaining' => '49', 'RateLimit-Reset' => '1700000000' }, + body: { data: { 'siren' => '418166096' }, links: {}, meta: { 'provider' => 'INSEE' } }) + r = client.insee.unites_legales('418166096') + expect(r.http_status).to eq(200) + expect(r.data['siren']).to eq('418166096') + expect(r.rate_limit.remaining).to eq(49) + end + + it '422 raises ValidationError' do + stub_staging('/v4/insee/sirene/unites_legales/418166096', + status: 422, + body: { errors: [{ code: '00301', title: 'Entité non traitable', detail: 'siren invalide', source: { parameter: 'siren' }, meta: {} }] }) + expect { client.insee.unites_legales('418166096') } + .to raise_error(ApiEntreprise::Commons::ValidationError, /siren invalide/) + end + + it '429 raises RateLimitError with retry_after populated' do + stub_staging('/v4/insee/sirene/unites_legales/418166096', + status: 429, + headers: { 'RateLimit-Reset' => (Time.now.to_i + 17).to_s }, + body: { errors: [{ code: '00429', title: 't', detail: 'd', meta: {} }] }) + begin + client.insee.unites_legales('418166096') + rescue ApiEntreprise::Commons::RateLimitError => e + expect(e.retry_after).to be_between(14, 20).inclusive + end + end + + it '502 raises ProviderError with meta.retry_in surfaced' do + stub_staging('/v4/insee/sirene/unites_legales/418166096', + status: 502, + body: { errors: [{ code: '04001', title: 't', detail: 'd', meta: { retry_in: 300 } }] }) + begin + client.insee.unites_legales('418166096') + rescue ApiEntreprise::Commons::ProviderError => e + expect(e.retry_after).to eq(300) + end + end + end + + describe 'local validation' do + it 'rejects a bad SIREN before any HTTP call' do + client = described_class.new(token: 't', default_params: default_params) + expect { client.insee.unites_legales('bogus') } + .to raise_error(ApiEntreprise::Commons::InvalidSirenError) + expect(a_request(:get, /.+/)).not_to have_been_made + end + + it 'rejects a bad recipient SIRET before any HTTP call' do + client = described_class.new(token: 't', + default_params: default_params.merge(recipient: '13002526500014')) + expect { client.insee.unites_legales('418166096') } + .to raise_error(ApiEntreprise::Commons::InvalidSiretError) + expect(a_request(:get, /.+/)).not_to have_been_made + end + end +end diff --git a/clients/ruby/api_entreprise/spec/resources/scaffold_smoke_spec.rb b/clients/ruby/api_entreprise/spec/resources/scaffold_smoke_spec.rb new file mode 100644 index 0000000000..d64d3c35ea --- /dev/null +++ b/clients/ruby/api_entreprise/spec/resources/scaffold_smoke_spec.rb @@ -0,0 +1,63 @@ +RSpec.describe 'Deprecated endpoint annotation' do + def build_client + ApiEntreprise::Client.new( + token: 't', + environment: :staging, + default_params: { recipient: '13002526500013', context: 'c', object: 'o' } + ) + end + + it 'defaults to the latest available version (v4 for insee.unites_legales)' do + v4_stub = stub_request(:get, %r{https://staging\.entreprise\.api\.gouv\.fr/v4/insee/sirene/unites_legales/418166096}) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: {}, links: {}, meta: {} }.to_json) + build_client.insee.unites_legales('418166096') + expect(v4_stub).to have_been_requested + end + + it 'emits a deprecation warning when pinning to a deprecated version' do + stub_request(:get, %r{https://staging\.entreprise\.api\.gouv\.fr/v3/insee/sirene/unites_legales/418166096}) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: {}, links: {}, meta: {} }.to_json) + expect { build_client.insee.unites_legales('418166096', version: 3) } + .to output(/\[DEPRECATED\].*unites_legales/).to_stderr + end + + it 'honours an older pinned version' do + v3_stub = stub_request(:get, %r{https://staging\.entreprise\.api\.gouv\.fr/v3/insee/sirene/unites_legales/418166096}) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: {}, links: {}, meta: {} }.to_json) + build_client.insee.unites_legales('418166096', version: 3) + expect(v3_stub).to have_been_requested + end + + it 'raises ArgumentError on an unsupported version' do + expect { build_client.insee.unites_legales('418166096', version: 99) } + .to raise_error(ArgumentError, /version 99.*supported/) + end +end + +RSpec.describe 'Generated resources smoke test' do + let(:client) do + ApiEntreprise::Client.new( + token: 't', + environment: :staging, + default_params: { recipient: '13002526500013', context: 'c', object: 'o' } + ) + end + + providers = %i[ + ademe banque_de_france carif_oref cibtp cma_france cnetp data_subvention dgfip + douanes european_commission fabrique_numerique_ministeres_sociaux fntp gip_mds + infogreffe inpi insee ministere_interieur msa opqibi probtp qualibat qualifelec + urssaf + ] + + providers.each do |provider| + it "exposes #{provider} and instantiates its resource" do + resource = client.public_send(provider) + expect(resource).to be_a(ApiEntreprise::Resources.const_get(provider.to_s.split('_').map(&:capitalize).join)) + expect(resource.public_methods(false)).not_to be_empty + end + end +end diff --git a/clients/ruby/api_entreprise/spec/spec_helper.rb b/clients/ruby/api_entreprise/spec/spec_helper.rb new file mode 100644 index 0000000000..2ad1182997 --- /dev/null +++ b/clients/ruby/api_entreprise/spec/spec_helper.rb @@ -0,0 +1,16 @@ +require 'api_entreprise' +require 'webmock/rspec' + +WebMock.disable_net_connect! + +RSpec.configure do |config| + config.expect_with :rspec do |c| + c.syntax = :expect + end + config.mock_with :rspec do |c| + c.verify_partial_doubles = true + end + config.disable_monkey_patching! + config.order = :random + Kernel.srand config.seed +end diff --git a/clients/ruby/api_particulier/.rspec b/clients/ruby/api_particulier/.rspec new file mode 100644 index 0000000000..7a2cc1a6e0 --- /dev/null +++ b/clients/ruby/api_particulier/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--color diff --git a/clients/ruby/api_particulier/CHANGELOG.md b/clients/ruby/api_particulier/CHANGELOG.md new file mode 100644 index 0000000000..9366249762 --- /dev/null +++ b/clients/ruby/api_particulier/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to `api_particulier` (Ruby) are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/) and the project +adheres to [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added +- Initial release — conforms to `clients/SPECS.md` §1–§20. +- `production` / `staging` environments with `base_url` override. +- `BearerToken` auth strategy with a pluggable `Auth::Strategy` seam. +- Client-level `default_params` with per-call override for `recipient` + (Particulier does not require `context` / `object`). +- Local SIRET / SIREN validation before any HTTP call. +- `Response` value object (`data`, `links`, `meta`, `raw`, `http_status`, + `headers`, `rate_limit`). +- Full JSON:API exception hierarchy matching `clients/SPECS.md` §6. +- `RateLimit-*` header parsing and `retry_after` on `RateLimitError`. +- Opt-in retry middleware via `faraday-retry`. +- 9 resource modules scaffolded from the OpenAPI spec, grouped by provider. +- Logging middleware redacts query strings by default (PII protection). +- `examples/{basic,error_handling,retry}.rb`. diff --git a/clients/ruby/api_particulier/Gemfile b/clients/ruby/api_particulier/Gemfile new file mode 100644 index 0000000000..500903f4b6 --- /dev/null +++ b/clients/ruby/api_particulier/Gemfile @@ -0,0 +1,8 @@ +source 'https://rubygems.org' + +gemspec + +group :test do + gem 'rspec', '~> 3.12' + gem 'webmock', '~> 3.19' +end diff --git a/clients/ruby/api_particulier/Gemfile.lock b/clients/ruby/api_particulier/Gemfile.lock new file mode 100644 index 0000000000..1a42a8179f --- /dev/null +++ b/clients/ruby/api_particulier/Gemfile.lock @@ -0,0 +1,85 @@ +PATH + remote: . + specs: + api_particulier (0.1.0) + faraday (~> 2.0) + faraday-retry (~> 2.0) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + bigdecimal (4.1.1) + crack (1.0.1) + bigdecimal + rexml + diff-lcs (1.6.2) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) + hashdiff (1.2.1) + json (2.19.3) + logger (1.7.0) + net-http (0.9.1) + uri (>= 0.11.1) + public_suffix (7.0.5) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + uri (1.1.1) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + api_particulier! + rspec (~> 3.12) + webmock (~> 3.19) + +CHECKSUMS + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + api_particulier (0.1.0) + bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c + faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 + json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90 + +BUNDLED WITH + 4.0.3 diff --git a/clients/ruby/api_particulier/LICENSE b/clients/ruby/api_particulier/LICENSE new file mode 100644 index 0000000000..677e601ebe --- /dev/null +++ b/clients/ruby/api_particulier/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DINUM (Direction Interministérielle du Numérique) + +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/clients/ruby/api_particulier/README.md b/clients/ruby/api_particulier/README.md new file mode 100644 index 0000000000..2720b27b9a --- /dev/null +++ b/clients/ruby/api_particulier/README.md @@ -0,0 +1,66 @@ +# api_particulier + +Ruby client for [API Particulier v3](https://particulier.api.gouv.fr). Conforms +to [`clients/SPECS.md`](../../SPECS.md). + +## Installation + +```ruby +# Gemfile +gem 'api_particulier' +``` + +## Configuration + +```ruby +client = ApiParticulier::Client.new( + token: ENV['API_PARTICULIER_TOKEN'], + environment: :staging, # or :production (default) + default_params: { recipient: '13002526500013' } +) +``` + +ENV vars: `API_PARTICULIER_TOKEN`, `API_PARTICULIER_ENV`, +`API_PARTICULIER_BASE_URL`. + +## Quickstart + +```ruby +response = client.ants.extrait_immatriculation_vehicule(immatriculation: 'AA-123-BB') +response.data # => { "titulaire" => { ... } } +response.rate_limit.remaining +``` + +Low-level escape hatch: `client.get(path, params: {...})`. + +## Error handling + +See the entreprise README and `clients/SPECS.md` §6 — same hierarchy applies +under `ApiParticulier::Commons::*`. + +## Testing + +No embedded mock mode. Stub with WebMock: + +```ruby +stub_request(:get, %r{https://staging\.particulier\.api\.gouv\.fr/v3/ants/.+}) + .with(headers: { 'Authorization' => 'Bearer test' }) + .to_return(status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: { data: {}, links: {}, meta: {} }.to_json) +``` + +PII notice: the default logger redacts the query string on Particulier +requests, since query params carry personal data (names, DOB, INE). + +## Development + +Shared code comes from `clients/ruby/commons/`; resources are scaffolded from +the OpenAPI spec: + +```sh +clients/ruby/bin/sync_commons +clients/ruby/bin/scaffold_resources --api particulier +``` + +CI checks both are in sync. diff --git a/clients/ruby/api_particulier/api_particulier.gemspec b/clients/ruby/api_particulier/api_particulier.gemspec new file mode 100644 index 0000000000..e9cb7e9f99 --- /dev/null +++ b/clients/ruby/api_particulier/api_particulier.gemspec @@ -0,0 +1,29 @@ +require_relative 'lib/api_particulier/version' + +Gem::Specification.new do |spec| + spec.name = 'api_particulier' + spec.version = ApiParticulier::VERSION + spec.authors = ['DINUM'] + spec.email = ['api-particulier@api.gouv.fr'] + spec.summary = 'Official Ruby client for API Particulier v3' + spec.description = 'Idiomatic Ruby client for https://particulier.api.gouv.fr — auth, envelope, error normalisation, rate limit.' + spec.homepage = 'https://github.com/datagouv/apistration' + spec.license = 'MIT' + + spec.required_ruby_version = '>= 3.1' + + spec.metadata = { + 'homepage_uri' => 'https://github.com/datagouv/apistration', + 'source_code_uri' => 'https://github.com/datagouv/apistration/tree/main/clients/ruby/api_particulier', + 'changelog_uri' => 'https://github.com/datagouv/apistration/blob/main/clients/ruby/api_particulier/CHANGELOG.md', + 'bug_tracker_uri' => 'https://github.com/datagouv/apistration/issues', + 'documentation_uri' => 'https://particulier.api.gouv.fr/v3/', + 'rubygems_mfa_required' => 'true' + } + + spec.files = Dir['lib/**/*.rb', 'README.md', 'CHANGELOG.md', 'LICENSE'] + spec.require_paths = ['lib'] + + spec.add_dependency 'faraday', '~> 2.0' + spec.add_dependency 'faraday-retry', '~> 2.0' +end diff --git a/clients/ruby/api_particulier/examples/basic.rb b/clients/ruby/api_particulier/examples/basic.rb new file mode 100755 index 0000000000..4e9bae8c19 --- /dev/null +++ b/clients/ruby/api_particulier/examples/basic.rb @@ -0,0 +1,20 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Basic happy path against staging. +# TOKEN=$(curl -s https://raw.githubusercontent.com/datagouv/apistration/develop/mocks/tokens/default) +# API_PARTICULIER_TOKEN=$TOKEN bundle exec ruby examples/basic.rb + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'api_particulier' + +client = ApiParticulier::Client.new( + environment: :staging, + default_params: { recipient: '13002526500013' } +) + +response = client.ants.extrait_immatriculation_vehicule(immatriculation: 'AA-123-BB') + +puts "status: #{response.http_status}" +puts "remaining: #{response.rate_limit&.remaining}" +puts "data keys: #{response.data&.keys.inspect}" diff --git a/clients/ruby/api_particulier/examples/error_handling.rb b/clients/ruby/api_particulier/examples/error_handling.rb new file mode 100755 index 0000000000..9531e91f5e --- /dev/null +++ b/clients/ruby/api_particulier/examples/error_handling.rb @@ -0,0 +1,46 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Same pattern as api_entreprise/examples/error_handling.rb, adapted to the +# Particulier client. No network required. + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'api_particulier' +require 'webmock' +include WebMock::API +WebMock.enable! +WebMock.disable_net_connect! + +BASE = 'https://staging.particulier.api.gouv.fr' +PATH = '/v3/ants/extrait_immatriculation_vehicule/france_connect' + +client = ApiParticulier::Client.new( + token: 't', + environment: :staging, + default_params: { recipient: '13002526500013' } +) + +def show(label) + yield +rescue ApiParticulier::Commons::Error => e + puts "#{label.ljust(30)} -> #{e.class.name.split('::').last} " \ + "(status=#{e.http_status}, code=#{e.first_error_code})" +rescue ArgumentError => e + puts "#{label.ljust(30)} -> #{e.class.name.split('::').last} (#{e.message.split("\n").first})" +end + +[401, 403, 404, 422, 429, 502, 503].each do |status| + WebMock.reset! + stub_request(:get, %r{#{BASE}#{PATH}}).to_return( + status: status, + headers: { 'Content-Type' => 'application/json' }, + body: { errors: [{ code: '00001', title: 't', detail: "status=#{status}" }] }.to_json + ) + show("HTTP #{status}") { client.ants.extrait_immatriculation_vehicule(immatriculation: 'AA-123-BB') } +end + +# Local validation — no HTTP. +show('local: missing recipient') do + ApiParticulier::Client.new(token: 't') + .ants.extrait_immatriculation_vehicule(immatriculation: 'AA-123-BB') +end diff --git a/clients/ruby/api_particulier/examples/retry.rb b/clients/ruby/api_particulier/examples/retry.rb new file mode 100755 index 0000000000..fc649e6636 --- /dev/null +++ b/clients/ruby/api_particulier/examples/retry.rb @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Opt-in retry middleware demo for api_particulier. + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'api_particulier' +require 'webmock' +include WebMock::API +WebMock.enable! +WebMock.disable_net_connect! + +BASE = 'https://staging.particulier.api.gouv.fr' +PATH = '/v3/ants/extrait_immatriculation_vehicule/france_connect' + +stub_request(:get, %r{#{BASE}#{PATH}}) + .to_return( + { status: 503, headers: { 'Content-Type' => 'application/json' }, + body: { errors: [{ code: '05000', title: 't', detail: 'down' }] }.to_json }, + { status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: { 'plate' => 'AA-123-BB' }, links: {}, meta: {} }.to_json } + ) + +client = ApiParticulier::Client.new( + token: 't', + environment: :staging, + default_params: { recipient: '13002526500013' }, + retry: { max: 2, on_status: [429, 502, 503], interval: 0.1 } +) + +response = client.ants.extrait_immatriculation_vehicule(immatriculation: 'AA-123-BB') +puts "Final status: #{response.http_status}" +puts "Data: #{response.data.inspect}" diff --git a/clients/ruby/api_particulier/lib/api_particulier.rb b/clients/ruby/api_particulier/lib/api_particulier.rb new file mode 100644 index 0000000000..cba15b5c84 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier.rb @@ -0,0 +1,4 @@ +require 'faraday' +require_relative 'api_particulier/version' +require_relative 'api_particulier/commons' +require_relative 'api_particulier/client' diff --git a/clients/ruby/api_particulier/lib/api_particulier/client.rb b/clients/ruby/api_particulier/lib/api_particulier/client.rb new file mode 100644 index 0000000000..aa2e6febf7 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/client.rb @@ -0,0 +1,76 @@ +require_relative 'commons' + +# +require_relative 'resources/ants' +require_relative 'resources/cnous' +require_relative 'resources/dsnj' +require_relative 'resources/dss' +require_relative 'resources/france_travail' +require_relative 'resources/gip_mds' +require_relative 'resources/men' +require_relative 'resources/mesri' +require_relative 'resources/sdh' +# + +module ApiParticulier + BASE_URLS = { + Commons::Configuration::PRODUCTION => 'https://particulier.api.gouv.fr', + Commons::Configuration::STAGING => 'https://staging.particulier.api.gouv.fr' + }.freeze + + class Client < Commons::ClientBase + REQUIRED_PARAMS = %i[recipient].freeze + SIRET_PARAMS = %i[recipient].freeze + + def initialize(token: nil, environment: nil, default_params: {}, base_url: nil, auth_strategy: nil, **opts) + env_token = token || ENV.fetch('API_PARTICULIER_TOKEN', nil) + env_env = (environment || ENV.fetch('API_PARTICULIER_ENV', :production)).to_sym + + config = Commons::Configuration.new( + base_urls: BASE_URLS, + token: env_token, + auth_strategy: auth_strategy, + environment: env_env, + base_url: base_url || ENV.fetch('API_PARTICULIER_BASE_URL', nil), + default_params: default_params, + user_agent: opts[:user_agent] || Commons::UserAgent.build(product: 'api-particulier-ruby', version: VERSION), + open_timeout: opts[:open_timeout] || Commons::Configuration::DEFAULT_OPEN_TIMEOUT, + read_timeout: opts[:read_timeout] || Commons::Configuration::DEFAULT_READ_TIMEOUT, + retry: opts[:retry], + logger: opts[:logger], + adapter: opts[:adapter] + ) + super(config, product: :particulier) + end + + # + def ants + @ants ||= Resources::Ants.new(self) + end + def cnous + @cnous ||= Resources::Cnous.new(self) + end + def dsnj + @dsnj ||= Resources::Dsnj.new(self) + end + def dss + @dss ||= Resources::Dss.new(self) + end + def france_travail + @france_travail ||= Resources::FranceTravail.new(self) + end + def gip_mds + @gip_mds ||= Resources::GipMds.new(self) + end + def men + @men ||= Resources::Men.new(self) + end + def mesri + @mesri ||= Resources::Mesri.new(self) + end + def sdh + @sdh ||= Resources::Sdh.new(self) + end + # + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons.rb b/clients/ruby/api_particulier/lib/api_particulier/commons.rb new file mode 100644 index 0000000000..2f33d0e995 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiParticulier; end +module ApiParticulier::Commons; end + +require_relative 'commons/version' +require_relative 'commons/errors' +require_relative 'commons/siret' +require_relative 'commons/siren' +require_relative 'commons/rate_limit' +require_relative 'commons/response' +require_relative 'commons/user_agent' +require_relative 'commons/auth/strategy' +require_relative 'commons/auth/bearer_token' +require_relative 'commons/middleware/authentication' +require_relative 'commons/middleware/envelope' +require_relative 'commons/middleware/error_handler' +require_relative 'commons/middleware/rate_limit' +require_relative 'commons/middleware/logging' +require_relative 'commons/configuration' +require_relative 'commons/client_base' diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/auth/bearer_token.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/auth/bearer_token.rb new file mode 100644 index 0000000000..585691aa3a --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/auth/bearer_token.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require_relative 'strategy' + +module ApiParticulier::Commons + module Auth + class BearerToken < Strategy + def initialize(token) + raise ArgumentError, 'token must be a non-empty string' if token.nil? || token.to_s.strip.empty? + + @token = token.to_s + end + + def apply(request) + request.headers['Authorization'] = "Bearer #{@token}" + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/auth/strategy.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/auth/strategy.rb new file mode 100644 index 0000000000..8f60913c15 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/auth/strategy.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiParticulier::Commons + module Auth + class Strategy + def apply(request) + raise NotImplementedError, "#{self.class} must implement #apply(request)" + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/client_base.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/client_base.rb new file mode 100644 index 0000000000..9a228f9c35 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/client_base.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' +begin + require 'faraday/retry' +rescue LoadError + # faraday-retry is optional; the :retry middleware is only used when the + # consumer opts in. +end + +require_relative 'middleware/authentication' +require_relative 'middleware/logging' +require_relative 'middleware/rate_limit' +require_relative 'middleware/error_handler' +require_relative 'middleware/envelope' +require_relative 'response' +require_relative 'siret' +require_relative 'errors' + +module ApiParticulier::Commons + class ClientBase + attr_reader :configuration + + REQUIRED_PARAMS = %i[recipient context object].freeze + SIRET_PARAMS = %i[recipient].freeze + + def initialize(configuration, product:) + @configuration = configuration + @product = product + @connection = build_connection + end + + def get(path, params: {}, headers: {}) + merged = merge_params(params) + validate_required!(merged) + validate_sirets!(merged) + + response = @connection.get(path, clean(merged), headers) + build_response(response) + end + + private + + def merge_params(params) + defaults = @configuration.default_params.transform_keys(&:to_s) + defaults.merge((params || {}).transform_keys(&:to_s)) + end + + def required_params_for(_params) + self.class::REQUIRED_PARAMS + end + + def siret_params_for(_params) + self.class::SIRET_PARAMS + end + + def validate_required!(params) + required_params_for(params).each do |key| + next unless blank?(params[key.to_s]) + + raise MissingParameterError, "required parameter #{key.inspect} is missing" + end + end + + def validate_sirets!(params) + siret_params_for(params).each do |key| + value = params[key.to_s] + next if value.nil? + + Siret.validate!(value, parameter: key) + end + end + + def blank?(value) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + + def clean(params) + params.reject { |_, v| v.nil? } + end + + def build_response(response) + Response.new( + raw: response.body, + http_status: response.status, + headers: response.headers, + rate_limit: response.env[Middleware::RateLimitParser::ENV_KEY] + ) + end + + def build_connection + cfg = @configuration + Faraday.new(url: cfg.base_url) do |conn| + conn.options.open_timeout = cfg.open_timeout + conn.options.timeout = cfg.read_timeout + + conn.headers['User-Agent'] = cfg.user_agent if cfg.user_agent + conn.headers['Accept'] = 'application/json' + + if cfg.retry && defined?(Faraday::Retry) + conn.request :retry, + max: cfg.retry.fetch(:max, 2), + retry_statuses: cfg.retry.fetch(:on_status, [429, 502, 503]), + methods: %i[get], + interval: cfg.retry.fetch(:interval, 0.5), + backoff_factor: cfg.retry.fetch(:backoff_factor, 2), + exceptions: [ + ApiParticulier::Commons::RateLimitError, + ApiParticulier::Commons::ProviderError, + ApiParticulier::Commons::ProviderUnavailableError, + ApiParticulier::Commons::TransportError + ] + end + + conn.use Middleware::Authentication, auth_strategy: cfg.auth_strategy + conn.use Middleware::Logging, logger: cfg.logger, redact_query: @product == :particulier + + conn.use Middleware::RateLimitParser + conn.use Middleware::ErrorHandler + conn.use Middleware::Envelope + + conn.adapter(cfg.adapter || Faraday.default_adapter) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/configuration.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/configuration.rb new file mode 100644 index 0000000000..b269e6e4a0 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/configuration.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require_relative 'auth/bearer_token' + +module ApiParticulier::Commons + class Configuration + PRODUCTION = :production + STAGING = :staging + ENVIRONMENTS = [PRODUCTION, STAGING].freeze + + DEFAULT_OPEN_TIMEOUT = 5 + DEFAULT_READ_TIMEOUT = 30 + + attr_reader :base_url, + :environment, + :auth_strategy, + :default_params, + :open_timeout, + :read_timeout, + :retry, + :logger, + :user_agent, + :adapter + + def initialize( + base_urls:, + token: nil, + auth_strategy: nil, + environment: PRODUCTION, + base_url: nil, + default_params: {}, + open_timeout: DEFAULT_OPEN_TIMEOUT, + read_timeout: DEFAULT_READ_TIMEOUT, + retry: nil, + logger: nil, + user_agent: nil, + adapter: nil + ) + @base_urls = base_urls + resolved_env = resolve_environment(environment) + @environment = resolved_env + @explicit_base_url = !base_url.nil? + @base_url = base_url || base_urls.fetch(resolved_env) + @auth_strategy = auth_strategy || build_bearer_strategy(token) + @default_params = default_params.freeze + @open_timeout = open_timeout + @read_timeout = read_timeout + @retry = binding.local_variable_get(:retry) + @logger = logger + @user_agent = user_agent + @adapter = adapter + freeze + end + + def with(**overrides) + self.class.new(**current_attrs.merge(overrides)) + end + alias copy with + + def production? + environment == PRODUCTION + end + + def staging? + environment == STAGING + end + + private + + def current_attrs + { + base_urls: @base_urls, + auth_strategy: auth_strategy, + environment: environment, + base_url: @explicit_base_url ? base_url : nil, + default_params: default_params, + open_timeout: open_timeout, + read_timeout: read_timeout, + retry: @retry, + logger: logger, + user_agent: user_agent, + adapter: adapter + } + end + + def resolve_environment(value) + env = value.to_sym + return env if ENVIRONMENTS.include?(env) + + raise ArgumentError, "environment must be one of #{ENVIRONMENTS.inspect}; got #{value.inspect}" + end + + def build_bearer_strategy(token) + return nil if token.nil? + + Auth::BearerToken.new(token) + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/errors.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/errors.rb new file mode 100644 index 0000000000..a1da9b6fc2 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/errors.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiParticulier::Commons + class Error < StandardError + attr_reader :http_status, :errors, :method, :url + + def initialize(message = nil, http_status: nil, errors: [], method: nil, url: nil) + super(message || default_message(http_status, errors)) + @http_status = http_status + @errors = errors || [] + @method = method + @url = url + end + + def first_error + errors.first || {} + end + + def first_error_code + first_error['code'] || first_error[:code] + end + + def first_error_title + first_error['title'] || first_error[:title] + end + + def first_error_detail + first_error['detail'] || first_error[:detail] + end + + def first_error_source + first_error['source'] || first_error[:source] + end + + def first_error_meta + first_error['meta'] || first_error[:meta] || {} + end + + private + + def default_message(http_status, errors) + first = (errors || []).first || {} + title = first['title'] || first[:title] + detail = first['detail'] || first[:detail] + parts = [http_status, title, detail].compact + parts.empty? ? self.class.name : parts.join(' — ') + end + end + + class ClientError < Error; end + class AuthenticationError < ClientError; end + class AuthorizationError < ClientError; end + class NotFoundError < ClientError; end + class ConflictError < ClientError; end + class ValidationError < ClientError; end + + class RateLimitError < ClientError + attr_reader :retry_after + + def initialize(message = nil, retry_after: nil, **kwargs) + super(message, **kwargs) + @retry_after = retry_after + end + end + + class ServerError < Error; end + class ProviderError < ServerError + attr_reader :retry_after + + def initialize(message = nil, retry_after: nil, **kwargs) + super(message, **kwargs) + @retry_after = retry_after + end + end + class ProviderUnavailableError < ServerError; end + + class TransportError < Error; end + + class InvalidSiretError < ArgumentError; end + class InvalidSirenError < ArgumentError; end + class MissingParameterError < ArgumentError; end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/authentication.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/authentication.rb new file mode 100644 index 0000000000..ec828816e5 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/authentication.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' + +module ApiParticulier::Commons + module Middleware + class Authentication < Faraday::Middleware + def initialize(app, auth_strategy:) + super(app) + @auth_strategy = auth_strategy + end + + def on_request(env) + return if @auth_strategy.nil? + + request = RequestWrapper.new(env) + begin + @auth_strategy.apply(request) + rescue StandardError => e + raise ApiParticulier::Commons::AuthenticationError.new( + "auth strategy raised: #{e.message}", + method: env.method, + url: env.url.to_s + ) + end + end + + class RequestWrapper + def initialize(env) + @env = env + end + + def headers + @env.request_headers + end + + def method + @env.method + end + + def url + @env.url + end + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/envelope.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/envelope.rb new file mode 100644 index 0000000000..e435e746de --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/envelope.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' +require 'json' + +module ApiParticulier::Commons + module Middleware + class Envelope < Faraday::Middleware + def on_complete(env) + body = env.body + return if body.nil? || body.is_a?(Hash) || body.is_a?(Array) + return unless body.is_a?(String) && !body.empty? + + parsed = + begin + JSON.parse(body) + rescue JSON::ParserError + raise ApiParticulier::Commons::TransportError.new( + "invalid JSON body: #{body[0, 200]}", + method: env.method, + url: env.url.to_s + ) + end + + env.body = parsed + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/error_handler.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/error_handler.rb new file mode 100644 index 0000000000..3676f8af67 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/error_handler.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' +require 'json' + +module ApiParticulier::Commons + module Middleware + class ErrorHandler < Faraday::Middleware + AUTH_CODES = %w[00101 00103 00105].freeze + AUTHORIZATION_CODES = %w[00100].freeze + + def on_complete(env) + status = env.status + return if status.between?(200, 299) + + exception = map_exception(status, env) + raise exception if exception + end + + def call(env) + super + rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e + raise ApiParticulier::Commons::TransportError.new( + e.message, + method: env.method, + url: env.url.to_s + ) + end + + private + + def map_exception(status, env) + errors = extract_errors(env.body) + klass = klass_for(status) + return nil unless klass + + kwargs = { + http_status: status, + errors: errors, + method: env.method, + url: env.url.to_s + } + + if klass == ApiParticulier::Commons::RateLimitError + kwargs[:retry_after] = compute_retry_after(env, errors) + elsif klass == ApiParticulier::Commons::ProviderError + kwargs[:retry_after] = provider_retry(errors) + end + + klass.new(nil, **kwargs) + end + + def klass_for(status) + case status + when 401 then ApiParticulier::Commons::AuthenticationError + when 403 then ApiParticulier::Commons::AuthorizationError + when 404 then ApiParticulier::Commons::NotFoundError + when 409 then ApiParticulier::Commons::ConflictError + when 422 then ApiParticulier::Commons::ValidationError + when 429 then ApiParticulier::Commons::RateLimitError + when 400..499 then ApiParticulier::Commons::ClientError + when 502 then ApiParticulier::Commons::ProviderError + when 503, 504 then ApiParticulier::Commons::ProviderUnavailableError + when 500..599 then ApiParticulier::Commons::ServerError + end + end + + def extract_errors(body) + parsed = body + parsed = safely_parse(body) if body.is_a?(String) + return [] unless parsed.is_a?(Hash) + + Array(parsed['errors'] || parsed[:errors]) + end + + def safely_parse(body) + JSON.parse(body) + rescue JSON::ParserError + nil + end + + def compute_retry_after(env, errors) + from_headers = ApiParticulier::Commons::RateLimit.from_headers(env.response_headers)&.retry_after + return from_headers if from_headers && from_headers.positive? + + provider_retry(errors) || from_headers + end + + def provider_retry(errors) + first = errors.first || {} + meta = first['meta'] || first[:meta] || {} + value = meta['retry_in'] || meta[:retry_in] + return nil if value.nil? + + Integer(value) + rescue ArgumentError, TypeError + nil + end + end + end +end +# No Faraday.register_middleware: symbols are process-global and collide when +# multiple gouv.fr gems are loaded in the same process. Clients pass the class +# directly to conn.response / conn.use. diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/logging.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/logging.rb new file mode 100644 index 0000000000..ce76e81493 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/logging.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' + +module ApiParticulier::Commons + module Middleware + class Logging < Faraday::Middleware + def initialize(app, logger: nil, redact_query: false) + super(app) + @logger = logger + @redact_query = redact_query + end + + def call(env) + started = monotonic_now + response = @app.call(env) + log(env, response, monotonic_now - started) if @logger + response + rescue StandardError => e + log_error(env, e, monotonic_now - started) if @logger + raise + end + + private + + def log(env, response, duration_ms) + @logger.info( + method: env.method.to_s.upcase, + url: safe_url(env.url), + status: response.status, + duration_ms: duration_ms.round(1), + rate_limit_remaining: extract_remaining(response.env.response_headers) + ) + end + + def log_error(env, exception, duration_ms) + @logger.error( + method: env.method.to_s.upcase, + url: safe_url(env.url), + error: exception.class.name, + message: exception.message, + duration_ms: duration_ms.round(1) + ) + end + + def safe_url(url) + return url.to_s unless @redact_query + + dup = url.dup + dup.query = nil + "#{dup}?[REDACTED]" + end + + def extract_remaining(headers) + return nil unless headers + + headers.each do |k, v| + return v.to_i if k.to_s.downcase == 'ratelimit-remaining' && !v.nil? && !v.to_s.empty? + end + nil + end + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0 + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/rate_limit.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/rate_limit.rb new file mode 100644 index 0000000000..0a2ae48eec --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/middleware/rate_limit.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +require 'faraday' + +module ApiParticulier::Commons + module Middleware + class RateLimitParser < Faraday::Middleware + ENV_KEY = :api_gouv_rate_limit + + def on_complete(env) + env[ENV_KEY] = ApiParticulier::Commons::RateLimit.from_headers(env.response_headers) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/rate_limit.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/rate_limit.rb new file mode 100644 index 0000000000..5bb85af3d1 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/rate_limit.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiParticulier::Commons + class RateLimit + attr_reader :limit, :remaining, :reset_at + + def self.from_headers(headers) + return nil if headers.nil? + + normalized = headers.transform_keys { |k| k.to_s.downcase } + limit = parse_int(normalized['ratelimit-limit']) + remaining = parse_int(normalized['ratelimit-remaining']) + reset_at = parse_reset(normalized['ratelimit-reset']) + + return nil if limit.nil? && remaining.nil? && reset_at.nil? + + new(limit: limit, remaining: remaining, reset_at: reset_at) + end + + def self.parse_int(value) + return nil if value.nil? || value.to_s.strip.empty? + + Integer(value.to_s, 10) + rescue ArgumentError + nil + end + + def self.parse_reset(value) + ts = parse_int(value) + return nil if ts.nil? + + Time.at(ts).utc + end + + def initialize(limit:, remaining:, reset_at:) + @limit = limit + @remaining = remaining + @reset_at = reset_at + end + + def retry_after(now: Time.now) + return nil if reset_at.nil? + + diff = reset_at.to_i - now.to_i + diff.negative? ? 0 : diff + end + + def to_h + { limit: limit, remaining: remaining, reset_at: reset_at } + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/response.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/response.rb new file mode 100644 index 0000000000..b8f9212425 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/response.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiParticulier::Commons + class Response + attr_reader :raw, :http_status, :headers, :rate_limit + + def initialize(raw:, http_status:, headers:, rate_limit: nil) + @raw = raw.is_a?(Hash) ? raw : {} + @http_status = http_status + @headers = headers || {} + @rate_limit = rate_limit + end + + def data + raw['data'] + end + + def links + raw['links'] || {} + end + + def meta + raw['meta'] || {} + end + + def success? + http_status.to_i.between?(200, 299) + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/siren.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/siren.rb new file mode 100644 index 0000000000..1d4db007ca --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/siren.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiParticulier::Commons + module Siren + module_function + + DIGITS_9 = /\A\d{9}\z/.freeze + LA_POSTE_PATTERN = /\A356000000\z/.freeze + + def valid?(value) + return false if value.nil? + return false unless value.to_s.match?(DIGITS_9) + return true if value.to_s.match?(LA_POSTE_PATTERN) + + (luhn_checksum(value.to_s) % 10).zero? + end + + def validate!(value, parameter:) + return if valid?(value) + + raise InvalidSirenError, + "#{parameter.inspect} must be a 9-digit SIREN passing the Luhn checksum; got #{value.inspect}" + end + + def luhn_checksum(value) + accum = 0 + value.reverse.each_char.map(&:to_i).each_with_index do |digit, index| + t = index.even? ? digit : digit * 2 + t -= 9 if t >= 10 + accum += t + end + accum + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/siret.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/siret.rb new file mode 100644 index 0000000000..57e4298a7a --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/siret.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiParticulier::Commons + module Siret + module_function + + LA_POSTE_PATTERN = /\A356000000\d{5}\z/.freeze + DIGITS_14 = /\A\d{14}\z/.freeze + + def valid?(value) + return false if value.nil? + return false unless value.to_s.match?(DIGITS_14) + return true if value.to_s.match?(LA_POSTE_PATTERN) + + (luhn_checksum(value.to_s) % 10).zero? + end + + def validate!(value, parameter:) + return if valid?(value) + + raise InvalidSiretError, + "#{parameter.inspect} must be a 14-digit SIRET passing the Luhn checksum (or a La Poste SIRET); got #{value.inspect}" + end + + def luhn_checksum(value) + accum = 0 + value.reverse.each_char.map(&:to_i).each_with_index do |digit, index| + t = index.even? ? digit : digit * 2 + t -= 9 if t >= 10 + accum += t + end + accum + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/user_agent.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/user_agent.rb new file mode 100644 index 0000000000..e26967eeb9 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/user_agent.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiParticulier::Commons + module UserAgent + URL = 'https://github.com/datagouv/apistration'.freeze + + module_function + + def build(product:, version:, suffix: nil) + base = "#{product}/#{version} (+#{URL})" + suffix ? "#{base} #{suffix}" : base + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/commons/version.rb b/clients/ruby/api_particulier/lib/api_particulier/commons/version.rb new file mode 100644 index 0000000000..8a01961291 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/commons/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from clients/ruby/commons/ (source digest: 903f3b3aca1a59a6cb1a53ab98f72c365486fc1f). +# Regenerate via clients/ruby/bin/sync_commons. + +module ApiParticulier::Commons + VERSION = '0.1.0'.freeze +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/resources/ants.rb b/clients/ruby/api_particulier/lib/api_particulier/resources/ants.rb new file mode 100644 index 0000000000..267b2b3526 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/resources/ants.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiParticulier + module Resources + class Ants + def initialize(client) + @client = client + end + + # [FranceConnect] Extrait d'immatriculation véhicule + # Logical endpoint: /ants/extrait_immatriculation_vehicule/france_connect + # Versions available: [3] — default: 3 + def extrait_immatriculation_vehicule(version: nil, recipient: nil, immatriculation:) + path = + case version || 3 + when 3 + "/v3/ants/extrait_immatriculation_vehicule/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /ants/extrait_immatriculation_vehicule/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "immatriculation" => immatriculation }.compact) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/resources/cnous.rb b/clients/ruby/api_particulier/lib/api_particulier/resources/cnous.rb new file mode 100644 index 0000000000..045589fa9d --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/resources/cnous.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiParticulier + module Resources + class Cnous + def initialize(client) + @client = client + end + + # [FranceConnect] Statut étudiant boursier + # Logical endpoint: /cnous/etudiant_boursier/france_connect + # Versions available: [3, 4] — default: 4 + def etudiant_boursier(version: nil, recipient: nil, campaign_year: nil) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/cnous/etudiant_boursier/france_connect (#etudiant_boursier): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/cnous/etudiant_boursier/france_connect" + when 4 + "/v4/cnous/etudiant_boursier/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /cnous/etudiant_boursier/france_connect; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "campaignYear" => campaign_year }.compact) + end + + # [Identité] Statut étudiant boursier + # Logical endpoint: /cnous/etudiant_boursier/identite + # Versions available: [3, 4] — default: 4 + def etudiant_boursier_identite(version: nil, recipient: nil, nom_naissance:, prenoms:, annee_date_naissance:, mois_date_naissance:, jour_date_naissance:, sexe_etat_civil: nil, code_cog_insee_commune_naissance: nil, nom_commune_naissance: nil, code_cog_insee_departement_naissance: nil, campaign_year: nil) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/cnous/etudiant_boursier/identite (#etudiant_boursier_identite): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/cnous/etudiant_boursier/identite" + when 4 + "/v4/cnous/etudiant_boursier/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /cnous/etudiant_boursier/identite; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "nomCommuneNaissance" => nom_commune_naissance, "codeCogInseeDepartementNaissance" => code_cog_insee_departement_naissance, "campaignYear" => campaign_year }.compact) + end + + # [INE] Statut étudiant boursier + # Logical endpoint: /cnous/etudiant_boursier/ine + # Versions available: [3, 4] — default: 4 + def ine(version: nil, recipient: nil, ine:, campaign_year: nil) + path = + case version || 4 + when 3 + warn "[DEPRECATED] /v3/cnous/etudiant_boursier/ine (#ine): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/cnous/etudiant_boursier/ine" + when 4 + "/v4/cnous/etudiant_boursier/ine" + else + raise ArgumentError, "version #{version.inspect} not available for /cnous/etudiant_boursier/ine; supported: [3, 4]" + end + @client.get(path, params: { "recipient" => recipient, "ine" => ine, "campaignYear" => campaign_year }.compact) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/resources/dsnj.rb b/clients/ruby/api_particulier/lib/api_particulier/resources/dsnj.rb new file mode 100644 index 0000000000..5f7ddf2f72 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/resources/dsnj.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiParticulier + module Resources + class Dsnj + def initialize(client) + @client = client + end + + # [FranceConnect] API Service national + # Logical endpoint: /dsnj/service_national/france_connect + # Versions available: [3] — default: 3 + def service_national(version: nil, recipient: nil) + path = + case version || 3 + when 3 + "/v3/dsnj/service_national/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /dsnj/service_national/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient }.compact) + end + + # [Identité] API Service national + # Logical endpoint: /dsnj/service_national/identite + # Versions available: [3] — default: 3 + def service_national_identite(version: nil, recipient: nil, nom_naissance:, prenoms:, annee_date_naissance:, mois_date_naissance:, jour_date_naissance:, sexe_etat_civil:, code_cog_insee_commune_naissance: nil, code_cog_insee_pays_naissance:) + path = + case version || 3 + when 3 + "/v3/dsnj/service_national/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /dsnj/service_national/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "codeCogInseePaysNaissance" => code_cog_insee_pays_naissance }.compact) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/resources/dss.rb b/clients/ruby/api_particulier/lib/api_particulier/resources/dss.rb new file mode 100644 index 0000000000..e64f0a3d8d --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/resources/dss.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiParticulier + module Resources + class Dss + def initialize(client) + @client = client + end + + # [FranceConnect] Statut allocation adulte handicapé (AAH) + # Logical endpoint: /dss/allocation_adulte_handicape/france_connect + # Versions available: [3] — default: 3 + def allocation_adulte_handicape(version: nil, recipient: nil) + path = + case version || 3 + when 3 + "/v3/dss/allocation_adulte_handicape/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/allocation_adulte_handicape/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient }.compact) + end + + # [Identité] Statut allocation adulte handicapé (AAH) + # Logical endpoint: /dss/allocation_adulte_handicape/identite + # Versions available: [3] — default: 3 + def allocation_adulte_handicape_identite(version: nil, recipient: nil, nom_naissance:, nom_usage: nil, prenoms:, annee_date_naissance: nil, mois_date_naissance: nil, jour_date_naissance: nil, sexe_etat_civil:, code_cog_insee_pays_naissance:, code_cog_insee_commune_naissance: nil, nom_commune_naissance: nil, code_cog_insee_departement_naissance: nil) + path = + case version || 3 + when 3 + "/v3/dss/allocation_adulte_handicape/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/allocation_adulte_handicape/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "nomUsage" => nom_usage, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseePaysNaissance" => code_cog_insee_pays_naissance, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "nomCommuneNaissance" => nom_commune_naissance, "codeCogInseeDepartementNaissance" => code_cog_insee_departement_naissance }.compact) + end + + # [FranceConnect] Statut allocation d'éducation de l'enfant handicapé (AEEH) + # Logical endpoint: /dss/allocation_enfant_handicape/france_connect + # Versions available: [3] — default: 3 + def allocation_enfant_handicape(version: nil, recipient: nil) + path = + case version || 3 + when 3 + "/v3/dss/allocation_enfant_handicape/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/allocation_enfant_handicape/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient }.compact) + end + + # [Identité] Statut allocation d'éducation de l'enfant handicapé (AEEH) + # Logical endpoint: /dss/allocation_enfant_handicape/identite + # Versions available: [3] — default: 3 + def allocation_enfant_handicape_identite(version: nil, recipient: nil, nom_naissance:, nom_usage: nil, prenoms:, annee_date_naissance: nil, mois_date_naissance: nil, jour_date_naissance: nil, sexe_etat_civil:, code_cog_insee_pays_naissance:, code_cog_insee_commune_naissance: nil, nom_commune_naissance: nil, code_cog_insee_departement_naissance: nil) + path = + case version || 3 + when 3 + "/v3/dss/allocation_enfant_handicape/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/allocation_enfant_handicape/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "nomUsage" => nom_usage, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseePaysNaissance" => code_cog_insee_pays_naissance, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "nomCommuneNaissance" => nom_commune_naissance, "codeCogInseeDepartementNaissance" => code_cog_insee_departement_naissance }.compact) + end + + # [FranceConnect] Statut allocation de soutien familial (ASF) + # Logical endpoint: /dss/allocation_soutien_familial/france_connect + # Versions available: [3] — default: 3 + def allocation_soutien_familial(version: nil, recipient: nil) + path = + case version || 3 + when 3 + "/v3/dss/allocation_soutien_familial/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/allocation_soutien_familial/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient }.compact) + end + + # [Identité] Statut allocation de soutien familial (ASF) + # Logical endpoint: /dss/allocation_soutien_familial/identite + # Versions available: [3] — default: 3 + def allocation_soutien_familial_identite(version: nil, recipient: nil, nom_naissance:, nom_usage: nil, prenoms:, annee_date_naissance: nil, mois_date_naissance: nil, jour_date_naissance: nil, sexe_etat_civil:, code_cog_insee_pays_naissance:, code_cog_insee_commune_naissance: nil, nom_commune_naissance: nil, code_cog_insee_departement_naissance: nil) + path = + case version || 3 + when 3 + "/v3/dss/allocation_soutien_familial/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/allocation_soutien_familial/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "nomUsage" => nom_usage, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseePaysNaissance" => code_cog_insee_pays_naissance, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "nomCommuneNaissance" => nom_commune_naissance, "codeCogInseeDepartementNaissance" => code_cog_insee_departement_naissance }.compact) + end + + # [FranceConnect] Statut complémentaire santé solidaire (C2S) + # Logical endpoint: /dss/complementaire_sante_solidaire/france_connect + # Versions available: [3] — default: 3 + def complementaire_sante_solidaire(version: nil, recipient: nil) + path = + case version || 3 + when 3 + "/v3/dss/complementaire_sante_solidaire/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/complementaire_sante_solidaire/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient }.compact) + end + + # [Identité] Statut complémentaire santé solidaire (C2S) + # Logical endpoint: /dss/complementaire_sante_solidaire/identite + # Versions available: [3] — default: 3 + def complementaire_sante_solidaire_identite(version: nil, recipient: nil, nom_naissance:, nom_usage: nil, prenoms:, annee_date_naissance: nil, mois_date_naissance: nil, jour_date_naissance: nil, sexe_etat_civil:, code_cog_insee_pays_naissance:, code_cog_insee_commune_naissance: nil, nom_commune_naissance: nil, code_cog_insee_departement_naissance: nil) + path = + case version || 3 + when 3 + "/v3/dss/complementaire_sante_solidaire/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/complementaire_sante_solidaire/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "nomUsage" => nom_usage, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseePaysNaissance" => code_cog_insee_pays_naissance, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "nomCommuneNaissance" => nom_commune_naissance, "codeCogInseeDepartementNaissance" => code_cog_insee_departement_naissance }.compact) + end + + # [FranceConnect] Participation familiale EAJE + # Logical endpoint: /dss/participation_familiale_eaje/france_connect + # Versions available: [3] — default: 3 + def participation_familiale_eaje(version: nil, recipient: nil) + path = + case version || 3 + when 3 + "/v3/dss/participation_familiale_eaje/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/participation_familiale_eaje/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient }.compact) + end + + # [Identité] Participation familiale EAJE + # Logical endpoint: /dss/participation_familiale_eaje/identite + # Versions available: [3] — default: 3 + def participation_familiale_eaje_identite(version: nil, recipient: nil, nom_naissance:, nom_usage: nil, prenoms:, annee_date_naissance: nil, mois_date_naissance: nil, jour_date_naissance: nil, sexe_etat_civil:, code_cog_insee_pays_naissance:, code_cog_insee_commune_naissance: nil, nom_commune_naissance: nil, code_cog_insee_departement_naissance: nil) + path = + case version || 3 + when 3 + "/v3/dss/participation_familiale_eaje/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/participation_familiale_eaje/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "nomUsage" => nom_usage, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseePaysNaissance" => code_cog_insee_pays_naissance, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "nomCommuneNaissance" => nom_commune_naissance, "codeCogInseeDepartementNaissance" => code_cog_insee_departement_naissance }.compact) + end + + # [FranceConnect] Statut prime d'activité + # Logical endpoint: /dss/prime_activite/france_connect + # Versions available: [3] — default: 3 + def prime_activite(version: nil, recipient: nil) + path = + case version || 3 + when 3 + "/v3/dss/prime_activite/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/prime_activite/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient }.compact) + end + + # [Identité] Statut prime d'activité + # Logical endpoint: /dss/prime_activite/identite + # Versions available: [3] — default: 3 + def prime_activite_identite(version: nil, recipient: nil, nom_naissance:, nom_usage: nil, prenoms:, annee_date_naissance: nil, mois_date_naissance: nil, jour_date_naissance: nil, sexe_etat_civil:, code_cog_insee_pays_naissance:, code_cog_insee_commune_naissance: nil, nom_commune_naissance: nil, code_cog_insee_departement_naissance: nil) + path = + case version || 3 + when 3 + "/v3/dss/prime_activite/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/prime_activite/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "nomUsage" => nom_usage, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseePaysNaissance" => code_cog_insee_pays_naissance, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "nomCommuneNaissance" => nom_commune_naissance, "codeCogInseeDepartementNaissance" => code_cog_insee_departement_naissance }.compact) + end + + # [FranceConnect] Quotient familial CAF & MSA + # Logical endpoint: /dss/quotient_familial/france_connect + # Versions available: [3] — default: 3 + def quotient_familial(version: nil, recipient: nil, annee: nil, mois: nil) + path = + case version || 3 + when 3 + "/v3/dss/quotient_familial/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/quotient_familial/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "annee" => annee, "mois" => mois }.compact) + end + + # [Identité] Quotient familial CAF & MSA + # Logical endpoint: /dss/quotient_familial/identite + # Versions available: [3] — default: 3 + def quotient_familial_identite(version: nil, recipient: nil, nom_naissance:, nom_usage: nil, prenoms:, annee_date_naissance: nil, mois_date_naissance: nil, jour_date_naissance: nil, sexe_etat_civil:, code_cog_insee_pays_naissance:, code_cog_insee_commune_naissance: nil, nom_commune_naissance: nil, code_cog_insee_departement_naissance: nil, annee: nil, mois: nil) + path = + case version || 3 + when 3 + "/v3/dss/quotient_familial/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/quotient_familial/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "nomUsage" => nom_usage, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseePaysNaissance" => code_cog_insee_pays_naissance, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "nomCommuneNaissance" => nom_commune_naissance, "codeCogInseeDepartementNaissance" => code_cog_insee_departement_naissance, "annee" => annee, "mois" => mois }.compact) + end + + # [FranceConnect] Statut revenu de solidarité active (RSA) + # Logical endpoint: /dss/revenu_solidarite_active/france_connect + # Versions available: [3] — default: 3 + def revenu_solidarite_active(version: nil, recipient: nil) + path = + case version || 3 + when 3 + "/v3/dss/revenu_solidarite_active/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/revenu_solidarite_active/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient }.compact) + end + + # [Identité] Statut revenu de solidarité active (RSA) + # Logical endpoint: /dss/revenu_solidarite_active/identite + # Versions available: [3] — default: 3 + def revenu_solidarite_active_identite(version: nil, recipient: nil, nom_naissance:, nom_usage: nil, prenoms:, annee_date_naissance: nil, mois_date_naissance: nil, jour_date_naissance: nil, sexe_etat_civil:, code_cog_insee_pays_naissance:, code_cog_insee_commune_naissance: nil, nom_commune_naissance: nil, code_cog_insee_departement_naissance: nil) + path = + case version || 3 + when 3 + "/v3/dss/revenu_solidarite_active/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /dss/revenu_solidarite_active/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "nomUsage" => nom_usage, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseePaysNaissance" => code_cog_insee_pays_naissance, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "nomCommuneNaissance" => nom_commune_naissance, "codeCogInseeDepartementNaissance" => code_cog_insee_departement_naissance }.compact) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/resources/france_travail.rb b/clients/ruby/api_particulier/lib/api_particulier/resources/france_travail.rb new file mode 100644 index 0000000000..dbabd08dec --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/resources/france_travail.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiParticulier + module Resources + class FranceTravail + def initialize(client) + @client = client + end + + # Paiements versés par France Travail + # Logical endpoint: /france_travail/indemnites/identifiant + # Versions available: [3] — default: 3 + def indemnites(version: nil, recipient: nil, identifiant:) + path = + case version || 3 + when 3 + "/v3/france_travail/indemnites/identifiant" + else + raise ArgumentError, "version #{version.inspect} not available for /france_travail/indemnites/identifiant; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "identifiant" => identifiant }.compact) + end + + # Statut demandeur d'emploi + # Logical endpoint: /france_travail/statut/identifiant + # Versions available: [3] — default: 3 + def statut(version: nil, recipient: nil, identifiant:) + path = + case version || 3 + when 3 + "/v3/france_travail/statut/identifiant" + else + raise ArgumentError, "version #{version.inspect} not available for /france_travail/statut/identifiant; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "identifiant" => identifiant }.compact) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/resources/gip_mds.rb b/clients/ruby/api_particulier/lib/api_particulier/resources/gip_mds.rb new file mode 100644 index 0000000000..7016893671 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/resources/gip_mds.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiParticulier + module Resources + class GipMds + def initialize(client) + @client = client + end + + # [FranceConnect] Statut service civique + # Logical endpoint: /gip_mds/service_civique/france_connect + # Versions available: [3] — default: 3 + def service_civique(version: nil, recipient: nil) + path = + case version || 3 + when 3 + "/v3/gip_mds/service_civique/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /gip_mds/service_civique/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient }.compact) + end + + # [Identité] Statut service civique + # Logical endpoint: /gip_mds/service_civique/identite + # Versions available: [3] — default: 3 + def service_civique_identite(version: nil, recipient: nil, nom_naissance:, prenoms:, annee_date_naissance:, mois_date_naissance:, jour_date_naissance:) + path = + case version || 3 + when 3 + "/v3/gip_mds/service_civique/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /gip_mds/service_civique/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance }.compact) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/resources/men.rb b/clients/ruby/api_particulier/lib/api_particulier/resources/men.rb new file mode 100644 index 0000000000..7e12944108 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/resources/men.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiParticulier + module Resources + class Men + def initialize(client) + @client = client + end + + # Statut élève scolarisé et boursier + # Logical endpoint: /men/scolarites/identite + # Versions available: [3, 4, 5] — default: 5 + def scolarites(version: nil, recipient: nil, nom_naissance:, prenoms:, sexe_etat_civil:, annee_date_naissance:, mois_date_naissance:, jour_date_naissance:, code_etablissement: nil, annee_scolaire:, degre_etablissement: nil, codes_bcn_departements: nil, codes_bcn_regions: nil) + path = + case version || 5 + when 3 + warn "[DEPRECATED] /v3/men/scolarites/identite (#scolarites): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v3/men/scolarites/identite" + when 4 + warn "[DEPRECATED] /v4/men/scolarites/identite (#scolarites): marked deprecated in the OpenAPI spec.", uplevel: 1 + "/v4/men/scolarites/identite" + when 5 + "/v5/men/scolarites/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /men/scolarites/identite; supported: [3, 4, 5]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "prenoms" => prenoms, "sexeEtatCivil" => sexe_etat_civil, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "codeEtablissement" => code_etablissement, "anneeScolaire" => annee_scolaire, "degreEtablissement" => degre_etablissement, "codesBcnDepartements" => codes_bcn_departements, "codesBcnRegions" => codes_bcn_regions }.compact) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/resources/mesri.rb b/clients/ruby/api_particulier/lib/api_particulier/resources/mesri.rb new file mode 100644 index 0000000000..1edb9e9157 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/resources/mesri.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiParticulier + module Resources + class Mesri + def initialize(client) + @client = client + end + + # [FranceConnect] Statut étudiant + # Logical endpoint: /mesri/statut_etudiant/france_connect + # Versions available: [3] — default: 3 + def statut_etudiant(version: nil, recipient: nil) + path = + case version || 3 + when 3 + "/v3/mesri/statut_etudiant/france_connect" + else + raise ArgumentError, "version #{version.inspect} not available for /mesri/statut_etudiant/france_connect; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient }.compact) + end + + # [Identité] Statut étudiant + # Logical endpoint: /mesri/statut_etudiant/identite + # Versions available: [3] — default: 3 + def statut_etudiant_identite(version: nil, recipient: nil, nom_naissance:, prenoms:, annee_date_naissance:, mois_date_naissance:, jour_date_naissance:, sexe_etat_civil:, code_cog_insee_commune_naissance: nil, nom_commune_naissance: nil, code_cog_insee_departement_naissance: nil) + path = + case version || 3 + when 3 + "/v3/mesri/statut_etudiant/identite" + else + raise ArgumentError, "version #{version.inspect} not available for /mesri/statut_etudiant/identite; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "nomNaissance" => nom_naissance, "prenoms" => prenoms, "anneeDateNaissance" => annee_date_naissance, "moisDateNaissance" => mois_date_naissance, "jourDateNaissance" => jour_date_naissance, "sexeEtatCivil" => sexe_etat_civil, "codeCogInseeCommuneNaissance" => code_cog_insee_commune_naissance, "nomCommuneNaissance" => nom_commune_naissance, "codeCogInseeDepartementNaissance" => code_cog_insee_departement_naissance }.compact) + end + + # [INE] Statut étudiant + # Logical endpoint: /mesri/statut_etudiant/ine + # Versions available: [3] — default: 3 + def ine(version: nil, recipient: nil, ine:) + path = + case version || 3 + when 3 + "/v3/mesri/statut_etudiant/ine" + else + raise ArgumentError, "version #{version.inspect} not available for /mesri/statut_etudiant/ine; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "ine" => ine }.compact) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/resources/sdh.rb b/clients/ruby/api_particulier/lib/api_particulier/resources/sdh.rb new file mode 100644 index 0000000000..bed98f27b4 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/resources/sdh.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by +# clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold +# script instead. + +module ApiParticulier + module Resources + class Sdh + def initialize(client) + @client = client + end + + # [Identifiant] API Statut sportif de haut niveau et sur liste ministérielle + # Logical endpoint: /sdh/statut_sportif/identifiant + # Versions available: [3] — default: 3 + def statut_sportif(version: nil, recipient: nil, identifiant:) + path = + case version || 3 + when 3 + "/v3/sdh/statut_sportif/identifiant" + else + raise ArgumentError, "version #{version.inspect} not available for /sdh/statut_sportif/identifiant; supported: [3]" + end + @client.get(path, params: { "recipient" => recipient, "identifiant" => identifiant }.compact) + end + end + end +end diff --git a/clients/ruby/api_particulier/lib/api_particulier/version.rb b/clients/ruby/api_particulier/lib/api_particulier/version.rb new file mode 100644 index 0000000000..f955c6a4a6 --- /dev/null +++ b/clients/ruby/api_particulier/lib/api_particulier/version.rb @@ -0,0 +1,3 @@ +module ApiParticulier + VERSION = '0.1.0'.freeze +end diff --git a/clients/ruby/api_particulier/spec/client_spec.rb b/clients/ruby/api_particulier/spec/client_spec.rb new file mode 100644 index 0000000000..29fc894ccc --- /dev/null +++ b/clients/ruby/api_particulier/spec/client_spec.rb @@ -0,0 +1,92 @@ +RSpec.describe ApiParticulier::Client do + let(:default_params) { { recipient: '13002526500013' } } + + describe 'environments' do + it 'defaults to production URL' do + c = described_class.new(token: 't') + expect(c.configuration.base_url).to eq('https://particulier.api.gouv.fr') + end + + it 'switches to staging URL' do + c = described_class.new(token: 't', environment: :staging) + expect(c.configuration.base_url).to eq('https://staging.particulier.api.gouv.fr') + end + + it 'honours base_url override' do + c = described_class.new(token: 't', base_url: 'https://custom.test') + expect(c.configuration.base_url).to eq('https://custom.test') + end + end + + describe 'end-to-end contract (§12.2)' do + let(:client) do + described_class.new(token: 't', environment: :staging, default_params: default_params) + end + + def stub_staging(path, status:, body:, headers: {}) + stub_request(:get, "https://staging.particulier.api.gouv.fr#{path}") + .with(query: hash_including('recipient' => '13002526500013'), + headers: { 'Authorization' => 'Bearer t' }) + .to_return(status: status, + headers: { 'Content-Type' => 'application/json' }.merge(headers), + body: body.to_json) + end + + it '200 envelope + rate-limit parsed' do + stub_staging('/v3/ants/extrait_immatriculation_vehicule/france_connect', + status: 200, + headers: { 'RateLimit-Remaining' => '49' }, + body: { data: { 'immat' => 'AA-123-BB' }, links: {}, meta: {} }) + r = client.ants.extrait_immatriculation_vehicule(immatriculation: 'AA-123-BB') + expect(r.data['immat']).to eq('AA-123-BB') + expect(r.rate_limit.remaining).to eq(49) + end + + it '422 raises ValidationError' do + stub_staging('/v3/ants/extrait_immatriculation_vehicule/france_connect', + status: 422, + body: { errors: [{ code: '00201', title: 't', detail: 'd', meta: {} }] }) + expect { client.ants.extrait_immatriculation_vehicule(immatriculation: 'x') } + .to raise_error(ApiParticulier::Commons::ValidationError) + end + + it '429 populates retry_after' do + stub_staging('/v3/ants/extrait_immatriculation_vehicule/france_connect', + status: 429, + headers: { 'RateLimit-Reset' => (Time.now.to_i + 11).to_s }, + body: { errors: [{ code: '00429', title: 't', detail: 'd', meta: {} }] }) + begin + client.ants.extrait_immatriculation_vehicule(immatriculation: 'x') + rescue ApiParticulier::Commons::RateLimitError => e + expect(e.retry_after).to be_between(8, 14).inclusive + end + end + + it '502 surfaces meta.retry_in on ProviderError' do + stub_staging('/v3/ants/extrait_immatriculation_vehicule/france_connect', + status: 502, + body: { errors: [{ code: '04001', title: 't', detail: 'd', meta: { retry_in: 60 } }] }) + begin + client.ants.extrait_immatriculation_vehicule(immatriculation: 'x') + rescue ApiParticulier::Commons::ProviderError => e + expect(e.retry_after).to eq(60) + end + end + end + + describe 'local validation' do + it 'rejects a bad recipient SIRET before any HTTP call' do + bad = described_class.new(token: 't', default_params: { recipient: '13002526500014' }) + expect { bad.ants.extrait_immatriculation_vehicule(immatriculation: 'x') } + .to raise_error(ApiParticulier::Commons::InvalidSiretError) + expect(a_request(:get, /.+/)).not_to have_been_made + end + + it 'requires recipient' do + barebone = described_class.new(token: 't') + expect { barebone.ants.extrait_immatriculation_vehicule(immatriculation: 'x') } + .to raise_error(ApiParticulier::Commons::MissingParameterError, /recipient/) + expect(a_request(:get, /.+/)).not_to have_been_made + end + end +end diff --git a/clients/ruby/api_particulier/spec/resources/scaffold_smoke_spec.rb b/clients/ruby/api_particulier/spec/resources/scaffold_smoke_spec.rb new file mode 100644 index 0000000000..220e903ffc --- /dev/null +++ b/clients/ruby/api_particulier/spec/resources/scaffold_smoke_spec.rb @@ -0,0 +1,19 @@ +RSpec.describe 'Generated resources smoke test' do + let(:client) do + ApiParticulier::Client.new( + token: 't', + environment: :staging, + default_params: { recipient: '13002526500013' } + ) + end + + providers = %i[ants cnous dsnj dss france_travail gip_mds men mesri sdh] + + providers.each do |provider| + it "exposes #{provider} and its resource has methods" do + resource = client.public_send(provider) + expect(resource).to be_a(ApiParticulier::Resources.const_get(provider.to_s.split('_').map(&:capitalize).join)) + expect(resource.public_methods(false)).not_to be_empty + end + end +end diff --git a/clients/ruby/api_particulier/spec/spec_helper.rb b/clients/ruby/api_particulier/spec/spec_helper.rb new file mode 100644 index 0000000000..97cd04c548 --- /dev/null +++ b/clients/ruby/api_particulier/spec/spec_helper.rb @@ -0,0 +1,16 @@ +require 'api_particulier' +require 'webmock/rspec' + +WebMock.disable_net_connect! + +RSpec.configure do |config| + config.expect_with :rspec do |c| + c.syntax = :expect + end + config.mock_with :rspec do |c| + c.verify_partial_doubles = true + end + config.disable_monkey_patching! + config.order = :random + Kernel.srand config.seed +end diff --git a/clients/ruby/bin/scaffold_resources b/clients/ruby/bin/scaffold_resources new file mode 100755 index 0000000000..4120a08a8a --- /dev/null +++ b/clients/ruby/bin/scaffold_resources @@ -0,0 +1,296 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'yaml' +require 'fileutils' +require 'optparse' + +ROOT = File.expand_path('..', __dir__) +COMMONS_SWAGGER = File.expand_path('../../commons/swagger', ROOT) + +SPECS = { + entreprise: { + file: File.join(COMMONS_SWAGGER, 'openapi-entreprise.yaml'), + gem: 'api_entreprise', + namespace: 'ApiEntreprise' + }, + particulier: { + file: File.join(COMMONS_SWAGGER, 'openapi-particulier.yaml'), + gem: 'api_particulier', + namespace: 'ApiParticulier' + } +}.freeze + +VERSION_PATH_RE = %r{\A/v(\d+)/}.freeze +MIN_SUPPORTED_VERSION = 3 + +STOP_WORDS = %w[identite identifiant france_connect].freeze +SIRET_PARAMS = %w[siret].freeze +SIREN_PARAMS = %w[siren].freeze + +def snake(str) + str.to_s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .tr('-', '_') + .downcase +end + +def kwarg_name(raw) + snake(raw.to_s.sub(/\[\]\z/, '')) +end + +def camel(str) + str.to_s.split('_').map(&:capitalize).join +end + +def path_segments(path) + path.split('/').reject(&:empty?) +end + +def provider_from_path(path) + segs = path_segments(path) + segs[1] || segs[0] +end + +def logical_path(path) + path.sub(VERSION_PATH_RE, '/') +end + +def version_of(path) + m = path.match(VERSION_PATH_RE) + m && m[1].to_i +end + +def method_name_for(path, existing_names) + segs = path_segments(path) + # drop version prefix + segs = segs.drop(1) if segs.first&.match?(/\Av\d+\z/) + # drop provider + segs = segs.drop(1) + # turn templates {foo} into nil markers + usable = segs.map { |s| s.start_with?('{') ? nil : s } + non_templated = usable.compact + + candidate = non_templated.last + candidate = non_templated[-2] if STOP_WORDS.include?(candidate) && non_templated.size > 1 + + name = snake(candidate.to_s) + return name unless name.empty? || existing_names.include?(name) + + # Disambiguate with preceding segments. + tail = non_templated.reverse.take(3).reverse + candidate_full = snake(tail.join('_')) + candidate_full = "endpoint_#{existing_names.size + 1}" if candidate_full.empty? + candidate_full +end + +def path_params(operation) + (operation['parameters'] || []).select { |p| p['in'] == 'path' } +end + +def query_params(operation) + (operation['parameters'] || []).select { |p| p['in'] == 'query' } +end + +def build_method(logical, variants, existing_names) + sorted_versions = variants.keys.sort + default_version = pick_default_version(variants) + reference = variants[default_version] + operation = reference[:operation] + + path_vars = path_params(operation).map { |p| p['name'] } + method_name = method_name_for(logical, existing_names) + existing_names << method_name + + positional = path_vars.map { |v| snake(v) } + + validations = path_vars.filter_map do |name| + sn = snake(name) + if SIRET_PARAMS.include?(sn) + "Commons::Siret.validate!(#{sn}, parameter: :#{sn})" + elsif SIREN_PARAMS.include?(sn) + "Commons::Siren.validate!(#{sn}, parameter: :#{sn})" + end + end + + qparams = query_params(operation) + audit_params = %w[recipient context object] + kwargs = qparams.map { |p| + required = p['required'] && !audit_params.include?(p['name']) + required ? "#{kwarg_name(p['name'])}:" : "#{kwarg_name(p['name'])}: nil" + } + kwargs = ['version: nil'] + kwargs + signature = (positional + kwargs).join(', ') + + param_hash = qparams.map { |p| + # Strip trailing [] from array-type param names: Faraday auto-appends [] to + # array values, so keeping it here produces prenoms[][] on the wire. + wire_name = p['name'].sub(/\[\]\z/, '') + "\"#{wire_name}\" => #{kwarg_name(p['name'])}" + }.join(', ') + query_merge = param_hash.empty? ? '{}' : "{ #{param_hash} }.compact" + + case_lines = sorted_versions.map do |v| + info = variants[v] + interpolated = info[:path].gsub(/\{(\w+)\}/) { "\#{#{snake(Regexp.last_match(1))}}" } + branch = ["when #{v}"] + if info[:operation]['deprecated'] + branch << " warn \"[DEPRECATED] #{info[:path]} (##{method_name}): marked deprecated in the OpenAPI spec.\", uplevel: 1" + end + branch << " \"#{interpolated}\"" + branch.join("\n") + end + + body = [] + validations.each { |v| body << v } + body << 'path =' + body << " case version || #{default_version}" + case_lines.each { |b| body << b.split("\n").map { |l| " #{l}" }.join("\n") } + body << ' else' + body << " raise ArgumentError, \"version \#{version.inspect} not available for #{logical}; supported: #{sorted_versions.inspect}\"" + body << ' end' + body << "@client.get(path, params: #{query_merge})" + + comment_lines = [] + comment_lines << "# #{operation['summary']}" if operation['summary'] + comment_lines << "# Logical endpoint: #{logical}" + deprecated_note = variants[default_version][:operation]['deprecated'] ? ' (deprecated)' : '' + comment_lines << "# Versions available: #{sorted_versions.inspect} — default: #{default_version}#{deprecated_note}" + + [ + *comment_lines, + "def #{method_name}(#{signature})", + *body.map { |l| " #{l}" }, + 'end' + ].join("\n") +end + +def pick_default_version(variants) + variants.keys.max +end + +def render_resource(provider:, namespace:, endpoints:) + class_name = camel(provider) + existing = [] + methods = endpoints.map { |logical, variants| build_method(logical, variants, existing) } + + methods_block = methods.map { |m| indent(m, 6) }.join("\n\n") + <<~RUBY + # frozen_string_literal: true + # DO NOT EDIT — generated from commons/swagger/openapi-*.yaml by + # clients/ruby/bin/scaffold_resources. Edit the OpenAPI spec or the scaffold + # script instead. + + module #{namespace} + module Resources + class #{class_name} + def initialize(client) + @client = client + end + + #{methods_block} + end + end + end + RUBY +end + +def indent(text, spaces) + pad = ' ' * spaces + text.split("\n").map { |line| line.empty? ? '' : pad + line }.join("\n") +end + +def scaffold(api:, check:) + spec = SPECS.fetch(api) + doc = YAML.load_file(spec[:file]) + # grouped[provider][logical_path][version] = {path:, operation:} + grouped = Hash.new { |h, k| h[k] = Hash.new { |hh, kk| hh[kk] = {} } } + + doc['paths'].each do |path, node| + v = version_of(path) + next if v.nil? || v < MIN_SUPPORTED_VERSION + + node.each do |verb, operation| + next unless verb == 'get' + next unless operation.is_a?(Hash) + + provider = provider_from_path(path) + logical = logical_path(path) + grouped[provider][logical][v] = { path: path, operation: operation } + end + end + + target_dir = File.join(ROOT, spec[:gem], 'lib', snake(spec[:namespace]), 'resources') + client_path = File.join(ROOT, spec[:gem], 'lib', snake(spec[:namespace]), 'client.rb') + drift = false + + unless check + FileUtils.mkdir_p(target_dir) + Dir.glob(File.join(target_dir, '*.rb')).each { |f| File.delete(f) unless File.basename(f) == 'README.md' } + end + + grouped.keys.sort.each do |provider| + endpoints = grouped[provider].sort_by { |logical, _| logical } + content = render_resource(provider: provider, namespace: spec[:namespace], endpoints: endpoints) + file = File.join(target_dir, "#{snake(provider)}.rb") + + if check + existing_content = File.exist?(file) ? File.read(file) : '' + drift = true if existing_content != content + else + File.write(file, content) + end + end + + providers = grouped.keys.sort + unless check + update_client_dispatcher(client_path: client_path, namespace: spec[:namespace], providers: providers) + end + + [providers, drift] +end + +def update_client_dispatcher(client_path:, namespace:, providers:) + src = File.read(client_path) + marker_begin = ' # ' + marker_end = ' # ' + dispatchers = providers.map do |p| + " def #{snake(p)}\n @#{snake(p)} ||= Resources::#{camel(p)}.new(self)\n end" + end + requires = providers.map { |p| "require_relative 'resources/#{snake(p)}'" } + + block = [marker_begin, *dispatchers, marker_end].join("\n") + requires_block = ["# ", *requires, "# "].join("\n") + + if src.include?(marker_begin) + src.sub!(/#{Regexp.escape(marker_begin)}.*#{Regexp.escape(marker_end)}/m, block) + else + src.sub!(/^ end\n^end\n?\z/, "#{block}\n end\nend\n") + end + + if src.include?('# ') + src.sub!(/# .*# /m, requires_block) + else + src.sub!(/^require_relative 'resources\/[^']+'\n/, "#{requires_block}\n") + end + + File.write(client_path, src) +end + +options = { api: :entreprise, check: false } +OptionParser.new do |opts| + opts.banner = 'Usage: bin/scaffold_resources [--api entreprise|particulier|all] [--check]' + opts.on('--api API', %w[entreprise particulier all]) { |v| options[:api] = v.to_sym } + opts.on('--check') { options[:check] = true } +end.parse! + +targets = options[:api] == :all ? %i[entreprise particulier] : [options[:api]] +drift = false + +targets.each do |api| + providers, d = scaffold(api: api, check: options[:check]) + drift ||= d + puts "#{api}: #{providers.size} providers (#{providers.join(', ')})" +end + +exit(drift ? 1 : 0) if options[:check] diff --git a/clients/ruby/bin/sync_commons b/clients/ruby/bin/sync_commons new file mode 100755 index 0000000000..7d0c21e9bb --- /dev/null +++ b/clients/ruby/bin/sync_commons @@ -0,0 +1,143 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'digest' +require 'fileutils' +require 'optparse' + +ROOT = File.expand_path('..', __dir__) +COMMONS_DIR = File.join(ROOT, 'commons', 'lib', 'api_gouv_commons') + +TARGETS = { + 'api_entreprise' => { outer: 'ApiEntreprise', inner: 'Commons' }, + 'api_particulier' => { outer: 'ApiParticulier', inner: 'Commons' } +}.freeze + +def camel_to_snake(name) + name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase +end + +def rewrite(content, namespace:) + rewritten = content.dup + rewritten.gsub!('ApiGouvCommons', namespace) + rewritten +end + +def source_digest + paths = Dir.glob(File.join(COMMONS_DIR, '**', '*.rb')).sort + Digest::SHA1.hexdigest(paths.map { |p| File.read(p) }.join) +end + +def header(digest) + <<~HEADER + # frozen_string_literal: true + # DO NOT EDIT — generated from clients/ruby/commons/ (source digest: #{digest}). + # Regenerate via clients/ruby/bin/sync_commons. + + HEADER +end + +def each_source + Dir.glob(File.join(COMMONS_DIR, '**', '*.rb')).sort.each do |src| + relative = src.sub("#{COMMONS_DIR}/", '') + yield src, relative + end +end + +def load_order + requires = [ + 'version', + 'errors', + 'siret', + 'siren', + 'rate_limit', + 'response', + 'user_agent', + 'auth/strategy', + 'auth/bearer_token', + 'middleware/authentication', + 'middleware/envelope', + 'middleware/error_handler', + 'middleware/rate_limit', + 'middleware/logging', + 'configuration', + 'client_base' + ] + requires +end + +def build_entry(outer:, inner:, digest:) + inner_snake = camel_to_snake(inner) + lines = [ + header(digest).rstrip, + '', + "module #{outer}; end", + "module #{outer}::#{inner}; end", + '' + ] + load_order.each do |path| + lines << "require_relative '#{inner_snake}/#{path}'" + end + lines.join("\n") + "\n" +end + +def sync_target(gem_dir, outer:, inner:, digest:, check:) + inner_snake = camel_to_snake(inner) + target_lib = File.join(ROOT, gem_dir, 'lib', camel_to_snake(outer), inner_snake) + entry_file = File.join(ROOT, gem_dir, 'lib', camel_to_snake(outer), "#{inner_snake}.rb") + namespace = "#{outer}::#{inner}" + drift = false + + unless check + FileUtils.rm_rf(target_lib) + FileUtils.mkdir_p(target_lib) + end + + each_source do |src, relative| + dest = File.join(target_lib, relative) + content = header(digest) + rewrite(File.read(src), namespace: namespace) + + if check + existing = File.exist?(dest) ? File.read(dest) : '' + drift = true if existing != content + else + FileUtils.mkdir_p(File.dirname(dest)) + File.write(dest, content) + end + end + + entry_content = build_entry(outer: outer, inner: inner, digest: digest) + if check + existing = File.exist?(entry_file) ? File.read(entry_file) : '' + drift = true if existing != entry_content + else + File.write(entry_file, entry_content) + end + + drift +end + +options = { check: false } +OptionParser.new do |opts| + opts.banner = 'Usage: bin/sync_commons [--check]' + opts.on('--check', 'Exit non-zero if vendored copies are stale') { options[:check] = true } +end.parse! + +digest = source_digest +drift = false + +TARGETS.each do |gem_dir, cfg| + drift |= sync_target(gem_dir, digest: digest, check: options[:check], **cfg) +end + +if options[:check] + if drift + warn 'sync_commons: vendored copies are stale — run clients/ruby/bin/sync_commons' + exit 1 + end + puts 'sync_commons: vendored copies are up to date.' +else + puts "sync_commons: synced commons@#{digest[0, 10]} to #{TARGETS.keys.join(', ')}" +end diff --git a/clients/ruby/commons/.rspec b/clients/ruby/commons/.rspec new file mode 100644 index 0000000000..7a2cc1a6e0 --- /dev/null +++ b/clients/ruby/commons/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--color diff --git a/clients/ruby/commons/Gemfile b/clients/ruby/commons/Gemfile new file mode 100644 index 0000000000..6175b71845 --- /dev/null +++ b/clients/ruby/commons/Gemfile @@ -0,0 +1,9 @@ +source 'https://rubygems.org' + +gem 'faraday', '~> 2.0' +gem 'faraday-retry', '~> 2.0' + +group :test do + gem 'rspec', '~> 3.12' + gem 'webmock', '~> 3.19' +end diff --git a/clients/ruby/commons/Gemfile.lock b/clients/ruby/commons/Gemfile.lock new file mode 100644 index 0000000000..31d39b70e2 --- /dev/null +++ b/clients/ruby/commons/Gemfile.lock @@ -0,0 +1,78 @@ +GEM + remote: https://rubygems.org/ + specs: + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + bigdecimal (4.1.1) + crack (1.0.1) + bigdecimal + rexml + diff-lcs (1.6.2) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) + hashdiff (1.2.1) + json (2.19.3) + logger (1.7.0) + net-http (0.9.1) + uri (>= 0.11.1) + public_suffix (7.0.5) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + uri (1.1.1) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + faraday (~> 2.0) + faraday-retry (~> 2.0) + rspec (~> 3.12) + webmock (~> 3.19) + +CHECKSUMS + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c + faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 + json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90 + +BUNDLED WITH + 4.0.3 diff --git a/clients/ruby/commons/lib/api_gouv_commons.rb b/clients/ruby/commons/lib/api_gouv_commons.rb new file mode 100644 index 0000000000..9e3741bcca --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons.rb @@ -0,0 +1,16 @@ +require_relative 'api_gouv_commons/version' +require_relative 'api_gouv_commons/errors' +require_relative 'api_gouv_commons/siret' +require_relative 'api_gouv_commons/siren' +require_relative 'api_gouv_commons/rate_limit' +require_relative 'api_gouv_commons/response' +require_relative 'api_gouv_commons/user_agent' +require_relative 'api_gouv_commons/auth/strategy' +require_relative 'api_gouv_commons/auth/bearer_token' +require_relative 'api_gouv_commons/middleware/authentication' +require_relative 'api_gouv_commons/middleware/envelope' +require_relative 'api_gouv_commons/middleware/error_handler' +require_relative 'api_gouv_commons/middleware/rate_limit' +require_relative 'api_gouv_commons/middleware/logging' +require_relative 'api_gouv_commons/configuration' +require_relative 'api_gouv_commons/client_base' diff --git a/clients/ruby/commons/lib/api_gouv_commons/auth/bearer_token.rb b/clients/ruby/commons/lib/api_gouv_commons/auth/bearer_token.rb new file mode 100644 index 0000000000..bc4c4d689e --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/auth/bearer_token.rb @@ -0,0 +1,17 @@ +require_relative 'strategy' + +module ApiGouvCommons + module Auth + class BearerToken < Strategy + def initialize(token) + raise ArgumentError, 'token must be a non-empty string' if token.nil? || token.to_s.strip.empty? + + @token = token.to_s + end + + def apply(request) + request.headers['Authorization'] = "Bearer #{@token}" + end + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/auth/strategy.rb b/clients/ruby/commons/lib/api_gouv_commons/auth/strategy.rb new file mode 100644 index 0000000000..c9a23e16da --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/auth/strategy.rb @@ -0,0 +1,9 @@ +module ApiGouvCommons + module Auth + class Strategy + def apply(request) + raise NotImplementedError, "#{self.class} must implement #apply(request)" + end + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/client_base.rb b/clients/ruby/commons/lib/api_gouv_commons/client_base.rb new file mode 100644 index 0000000000..58db66c19d --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/client_base.rb @@ -0,0 +1,124 @@ +require 'faraday' +begin + require 'faraday/retry' +rescue LoadError + # faraday-retry is optional; the :retry middleware is only used when the + # consumer opts in. +end + +require_relative 'middleware/authentication' +require_relative 'middleware/logging' +require_relative 'middleware/rate_limit' +require_relative 'middleware/error_handler' +require_relative 'middleware/envelope' +require_relative 'response' +require_relative 'siret' +require_relative 'errors' + +module ApiGouvCommons + class ClientBase + attr_reader :configuration + + REQUIRED_PARAMS = %i[recipient context object].freeze + SIRET_PARAMS = %i[recipient].freeze + + def initialize(configuration, product:) + @configuration = configuration + @product = product + @connection = build_connection + end + + def get(path, params: {}, headers: {}) + merged = merge_params(params) + validate_required!(merged) + validate_sirets!(merged) + + response = @connection.get(path, clean(merged), headers) + build_response(response) + end + + private + + def merge_params(params) + defaults = @configuration.default_params.transform_keys(&:to_s) + defaults.merge((params || {}).transform_keys(&:to_s)) + end + + def required_params_for(_params) + self.class::REQUIRED_PARAMS + end + + def siret_params_for(_params) + self.class::SIRET_PARAMS + end + + def validate_required!(params) + required_params_for(params).each do |key| + next unless blank?(params[key.to_s]) + + raise MissingParameterError, "required parameter #{key.inspect} is missing" + end + end + + def validate_sirets!(params) + siret_params_for(params).each do |key| + value = params[key.to_s] + next if value.nil? + + Siret.validate!(value, parameter: key) + end + end + + def blank?(value) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + + def clean(params) + params.reject { |_, v| v.nil? } + end + + def build_response(response) + Response.new( + raw: response.body, + http_status: response.status, + headers: response.headers, + rate_limit: response.env[Middleware::RateLimitParser::ENV_KEY] + ) + end + + def build_connection + cfg = @configuration + Faraday.new(url: cfg.base_url) do |conn| + conn.options.open_timeout = cfg.open_timeout + conn.options.timeout = cfg.read_timeout + + conn.headers['User-Agent'] = cfg.user_agent if cfg.user_agent + conn.headers['Accept'] = 'application/json' + + if cfg.retry && defined?(Faraday::Retry) + conn.request :retry, + max: cfg.retry.fetch(:max, 2), + retry_statuses: cfg.retry.fetch(:on_status, [429, 502, 503]), + methods: %i[get], + interval: cfg.retry.fetch(:interval, 0.5), + backoff_factor: cfg.retry.fetch(:backoff_factor, 2), + exceptions: [ + ApiGouvCommons::RateLimitError, + ApiGouvCommons::ProviderError, + ApiGouvCommons::ProviderUnavailableError, + ApiGouvCommons::TransportError + ] + end + + conn.use Middleware::Authentication, auth_strategy: cfg.auth_strategy + conn.use Middleware::Logging, logger: cfg.logger, redact_query: @product == :particulier + + conn.use Middleware::RateLimitParser + conn.use Middleware::ErrorHandler + conn.use Middleware::Envelope + + conn.adapter(cfg.adapter || Faraday.default_adapter) + end + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/configuration.rb b/clients/ruby/commons/lib/api_gouv_commons/configuration.rb new file mode 100644 index 0000000000..01ff2090b2 --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/configuration.rb @@ -0,0 +1,97 @@ +require_relative 'auth/bearer_token' + +module ApiGouvCommons + class Configuration + PRODUCTION = :production + STAGING = :staging + ENVIRONMENTS = [PRODUCTION, STAGING].freeze + + DEFAULT_OPEN_TIMEOUT = 5 + DEFAULT_READ_TIMEOUT = 30 + + attr_reader :base_url, + :environment, + :auth_strategy, + :default_params, + :open_timeout, + :read_timeout, + :retry, + :logger, + :user_agent, + :adapter + + def initialize( + base_urls:, + token: nil, + auth_strategy: nil, + environment: PRODUCTION, + base_url: nil, + default_params: {}, + open_timeout: DEFAULT_OPEN_TIMEOUT, + read_timeout: DEFAULT_READ_TIMEOUT, + retry: nil, + logger: nil, + user_agent: nil, + adapter: nil + ) + @base_urls = base_urls + resolved_env = resolve_environment(environment) + @environment = resolved_env + @explicit_base_url = !base_url.nil? + @base_url = base_url || base_urls.fetch(resolved_env) + @auth_strategy = auth_strategy || build_bearer_strategy(token) + @default_params = default_params.freeze + @open_timeout = open_timeout + @read_timeout = read_timeout + @retry = binding.local_variable_get(:retry) + @logger = logger + @user_agent = user_agent + @adapter = adapter + freeze + end + + def with(**overrides) + self.class.new(**current_attrs.merge(overrides)) + end + alias copy with + + def production? + environment == PRODUCTION + end + + def staging? + environment == STAGING + end + + private + + def current_attrs + { + base_urls: @base_urls, + auth_strategy: auth_strategy, + environment: environment, + base_url: @explicit_base_url ? base_url : nil, + default_params: default_params, + open_timeout: open_timeout, + read_timeout: read_timeout, + retry: @retry, + logger: logger, + user_agent: user_agent, + adapter: adapter + } + end + + def resolve_environment(value) + env = value.to_sym + return env if ENVIRONMENTS.include?(env) + + raise ArgumentError, "environment must be one of #{ENVIRONMENTS.inspect}; got #{value.inspect}" + end + + def build_bearer_strategy(token) + return nil if token.nil? + + Auth::BearerToken.new(token) + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/errors.rb b/clients/ruby/commons/lib/api_gouv_commons/errors.rb new file mode 100644 index 0000000000..969b4933a4 --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/errors.rb @@ -0,0 +1,80 @@ +module ApiGouvCommons + class Error < StandardError + attr_reader :http_status, :errors, :method, :url + + def initialize(message = nil, http_status: nil, errors: [], method: nil, url: nil) + super(message || default_message(http_status, errors)) + @http_status = http_status + @errors = errors || [] + @method = method + @url = url + end + + def first_error + errors.first || {} + end + + def first_error_code + first_error['code'] || first_error[:code] + end + + def first_error_title + first_error['title'] || first_error[:title] + end + + def first_error_detail + first_error['detail'] || first_error[:detail] + end + + def first_error_source + first_error['source'] || first_error[:source] + end + + def first_error_meta + first_error['meta'] || first_error[:meta] || {} + end + + private + + def default_message(http_status, errors) + first = (errors || []).first || {} + title = first['title'] || first[:title] + detail = first['detail'] || first[:detail] + parts = [http_status, title, detail].compact + parts.empty? ? self.class.name : parts.join(' — ') + end + end + + class ClientError < Error; end + class AuthenticationError < ClientError; end + class AuthorizationError < ClientError; end + class NotFoundError < ClientError; end + class ConflictError < ClientError; end + class ValidationError < ClientError; end + + class RateLimitError < ClientError + attr_reader :retry_after + + def initialize(message = nil, retry_after: nil, **kwargs) + super(message, **kwargs) + @retry_after = retry_after + end + end + + class ServerError < Error; end + class ProviderError < ServerError + attr_reader :retry_after + + def initialize(message = nil, retry_after: nil, **kwargs) + super(message, **kwargs) + @retry_after = retry_after + end + end + class ProviderUnavailableError < ServerError; end + + class TransportError < Error; end + + class InvalidSiretError < ArgumentError; end + class InvalidSirenError < ArgumentError; end + class MissingParameterError < ArgumentError; end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/middleware/authentication.rb b/clients/ruby/commons/lib/api_gouv_commons/middleware/authentication.rb new file mode 100644 index 0000000000..af3ac84a4e --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/middleware/authentication.rb @@ -0,0 +1,45 @@ +require 'faraday' + +module ApiGouvCommons + module Middleware + class Authentication < Faraday::Middleware + def initialize(app, auth_strategy:) + super(app) + @auth_strategy = auth_strategy + end + + def on_request(env) + return if @auth_strategy.nil? + + request = RequestWrapper.new(env) + begin + @auth_strategy.apply(request) + rescue StandardError => e + raise ApiGouvCommons::AuthenticationError.new( + "auth strategy raised: #{e.message}", + method: env.method, + url: env.url.to_s + ) + end + end + + class RequestWrapper + def initialize(env) + @env = env + end + + def headers + @env.request_headers + end + + def method + @env.method + end + + def url + @env.url + end + end + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/middleware/envelope.rb b/clients/ruby/commons/lib/api_gouv_commons/middleware/envelope.rb new file mode 100644 index 0000000000..219fa1c324 --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/middleware/envelope.rb @@ -0,0 +1,27 @@ +require 'faraday' +require 'json' + +module ApiGouvCommons + module Middleware + class Envelope < Faraday::Middleware + def on_complete(env) + body = env.body + return if body.nil? || body.is_a?(Hash) || body.is_a?(Array) + return unless body.is_a?(String) && !body.empty? + + parsed = + begin + JSON.parse(body) + rescue JSON::ParserError + raise ApiGouvCommons::TransportError.new( + "invalid JSON body: #{body[0, 200]}", + method: env.method, + url: env.url.to_s + ) + end + + env.body = parsed + end + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/middleware/error_handler.rb b/clients/ruby/commons/lib/api_gouv_commons/middleware/error_handler.rb new file mode 100644 index 0000000000..d5c0918029 --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/middleware/error_handler.rb @@ -0,0 +1,102 @@ +require 'faraday' +require 'json' + +module ApiGouvCommons + module Middleware + class ErrorHandler < Faraday::Middleware + AUTH_CODES = %w[00101 00103 00105].freeze + AUTHORIZATION_CODES = %w[00100].freeze + + def on_complete(env) + status = env.status + return if status.between?(200, 299) + + exception = map_exception(status, env) + raise exception if exception + end + + def call(env) + super + rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e + raise ApiGouvCommons::TransportError.new( + e.message, + method: env.method, + url: env.url.to_s + ) + end + + private + + def map_exception(status, env) + errors = extract_errors(env.body) + klass = klass_for(status) + return nil unless klass + + kwargs = { + http_status: status, + errors: errors, + method: env.method, + url: env.url.to_s + } + + if klass == ApiGouvCommons::RateLimitError + kwargs[:retry_after] = compute_retry_after(env, errors) + elsif klass == ApiGouvCommons::ProviderError + kwargs[:retry_after] = provider_retry(errors) + end + + klass.new(nil, **kwargs) + end + + def klass_for(status) + case status + when 401 then ApiGouvCommons::AuthenticationError + when 403 then ApiGouvCommons::AuthorizationError + when 404 then ApiGouvCommons::NotFoundError + when 409 then ApiGouvCommons::ConflictError + when 422 then ApiGouvCommons::ValidationError + when 429 then ApiGouvCommons::RateLimitError + when 400..499 then ApiGouvCommons::ClientError + when 502 then ApiGouvCommons::ProviderError + when 503, 504 then ApiGouvCommons::ProviderUnavailableError + when 500..599 then ApiGouvCommons::ServerError + end + end + + def extract_errors(body) + parsed = body + parsed = safely_parse(body) if body.is_a?(String) + return [] unless parsed.is_a?(Hash) + + Array(parsed['errors'] || parsed[:errors]) + end + + def safely_parse(body) + JSON.parse(body) + rescue JSON::ParserError + nil + end + + def compute_retry_after(env, errors) + from_headers = ApiGouvCommons::RateLimit.from_headers(env.response_headers)&.retry_after + return from_headers if from_headers && from_headers.positive? + + provider_retry(errors) || from_headers + end + + def provider_retry(errors) + first = errors.first || {} + meta = first['meta'] || first[:meta] || {} + value = meta['retry_in'] || meta[:retry_in] + return nil if value.nil? + + Integer(value) + rescue ArgumentError, TypeError + nil + end + end + end +end +# No Faraday.register_middleware: symbols are process-global and collide when +# multiple gouv.fr gems are loaded in the same process. Clients pass the class +# directly to conn.response / conn.use. diff --git a/clients/ruby/commons/lib/api_gouv_commons/middleware/logging.rb b/clients/ruby/commons/lib/api_gouv_commons/middleware/logging.rb new file mode 100644 index 0000000000..dda04658e2 --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/middleware/logging.rb @@ -0,0 +1,66 @@ +require 'faraday' + +module ApiGouvCommons + module Middleware + class Logging < Faraday::Middleware + def initialize(app, logger: nil, redact_query: false) + super(app) + @logger = logger + @redact_query = redact_query + end + + def call(env) + started = monotonic_now + response = @app.call(env) + log(env, response, monotonic_now - started) if @logger + response + rescue StandardError => e + log_error(env, e, monotonic_now - started) if @logger + raise + end + + private + + def log(env, response, duration_ms) + @logger.info( + method: env.method.to_s.upcase, + url: safe_url(env.url), + status: response.status, + duration_ms: duration_ms.round(1), + rate_limit_remaining: extract_remaining(response.env.response_headers) + ) + end + + def log_error(env, exception, duration_ms) + @logger.error( + method: env.method.to_s.upcase, + url: safe_url(env.url), + error: exception.class.name, + message: exception.message, + duration_ms: duration_ms.round(1) + ) + end + + def safe_url(url) + return url.to_s unless @redact_query + + dup = url.dup + dup.query = nil + "#{dup}?[REDACTED]" + end + + def extract_remaining(headers) + return nil unless headers + + headers.each do |k, v| + return v.to_i if k.to_s.downcase == 'ratelimit-remaining' && !v.nil? && !v.to_s.empty? + end + nil + end + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0 + end + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/middleware/rate_limit.rb b/clients/ruby/commons/lib/api_gouv_commons/middleware/rate_limit.rb new file mode 100644 index 0000000000..2f43cae94d --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/middleware/rate_limit.rb @@ -0,0 +1,13 @@ +require 'faraday' + +module ApiGouvCommons + module Middleware + class RateLimitParser < Faraday::Middleware + ENV_KEY = :api_gouv_rate_limit + + def on_complete(env) + env[ENV_KEY] = ApiGouvCommons::RateLimit.from_headers(env.response_headers) + end + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/rate_limit.rb b/clients/ruby/commons/lib/api_gouv_commons/rate_limit.rb new file mode 100644 index 0000000000..8d83e4c497 --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/rate_limit.rb @@ -0,0 +1,50 @@ +module ApiGouvCommons + class RateLimit + attr_reader :limit, :remaining, :reset_at + + def self.from_headers(headers) + return nil if headers.nil? + + normalized = headers.transform_keys { |k| k.to_s.downcase } + limit = parse_int(normalized['ratelimit-limit']) + remaining = parse_int(normalized['ratelimit-remaining']) + reset_at = parse_reset(normalized['ratelimit-reset']) + + return nil if limit.nil? && remaining.nil? && reset_at.nil? + + new(limit: limit, remaining: remaining, reset_at: reset_at) + end + + def self.parse_int(value) + return nil if value.nil? || value.to_s.strip.empty? + + Integer(value.to_s, 10) + rescue ArgumentError + nil + end + + def self.parse_reset(value) + ts = parse_int(value) + return nil if ts.nil? + + Time.at(ts).utc + end + + def initialize(limit:, remaining:, reset_at:) + @limit = limit + @remaining = remaining + @reset_at = reset_at + end + + def retry_after(now: Time.now) + return nil if reset_at.nil? + + diff = reset_at.to_i - now.to_i + diff.negative? ? 0 : diff + end + + def to_h + { limit: limit, remaining: remaining, reset_at: reset_at } + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/response.rb b/clients/ruby/commons/lib/api_gouv_commons/response.rb new file mode 100644 index 0000000000..7f30596bdd --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/response.rb @@ -0,0 +1,28 @@ +module ApiGouvCommons + class Response + attr_reader :raw, :http_status, :headers, :rate_limit + + def initialize(raw:, http_status:, headers:, rate_limit: nil) + @raw = raw.is_a?(Hash) ? raw : {} + @http_status = http_status + @headers = headers || {} + @rate_limit = rate_limit + end + + def data + raw['data'] + end + + def links + raw['links'] || {} + end + + def meta + raw['meta'] || {} + end + + def success? + http_status.to_i.between?(200, 299) + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/siren.rb b/clients/ruby/commons/lib/api_gouv_commons/siren.rb new file mode 100644 index 0000000000..d6046a47a3 --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/siren.rb @@ -0,0 +1,33 @@ +module ApiGouvCommons + module Siren + module_function + + DIGITS_9 = /\A\d{9}\z/.freeze + LA_POSTE_PATTERN = /\A356000000\z/.freeze + + def valid?(value) + return false if value.nil? + return false unless value.to_s.match?(DIGITS_9) + return true if value.to_s.match?(LA_POSTE_PATTERN) + + (luhn_checksum(value.to_s) % 10).zero? + end + + def validate!(value, parameter:) + return if valid?(value) + + raise InvalidSirenError, + "#{parameter.inspect} must be a 9-digit SIREN passing the Luhn checksum; got #{value.inspect}" + end + + def luhn_checksum(value) + accum = 0 + value.reverse.each_char.map(&:to_i).each_with_index do |digit, index| + t = index.even? ? digit : digit * 2 + t -= 9 if t >= 10 + accum += t + end + accum + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/siret.rb b/clients/ruby/commons/lib/api_gouv_commons/siret.rb new file mode 100644 index 0000000000..e99c042ee7 --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/siret.rb @@ -0,0 +1,33 @@ +module ApiGouvCommons + module Siret + module_function + + LA_POSTE_PATTERN = /\A356000000\d{5}\z/.freeze + DIGITS_14 = /\A\d{14}\z/.freeze + + def valid?(value) + return false if value.nil? + return false unless value.to_s.match?(DIGITS_14) + return true if value.to_s.match?(LA_POSTE_PATTERN) + + (luhn_checksum(value.to_s) % 10).zero? + end + + def validate!(value, parameter:) + return if valid?(value) + + raise InvalidSiretError, + "#{parameter.inspect} must be a 14-digit SIRET passing the Luhn checksum (or a La Poste SIRET); got #{value.inspect}" + end + + def luhn_checksum(value) + accum = 0 + value.reverse.each_char.map(&:to_i).each_with_index do |digit, index| + t = index.even? ? digit : digit * 2 + t -= 9 if t >= 10 + accum += t + end + accum + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/user_agent.rb b/clients/ruby/commons/lib/api_gouv_commons/user_agent.rb new file mode 100644 index 0000000000..a7f132d3d3 --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/user_agent.rb @@ -0,0 +1,12 @@ +module ApiGouvCommons + module UserAgent + URL = 'https://github.com/datagouv/apistration'.freeze + + module_function + + def build(product:, version:, suffix: nil) + base = "#{product}/#{version} (+#{URL})" + suffix ? "#{base} #{suffix}" : base + end + end +end diff --git a/clients/ruby/commons/lib/api_gouv_commons/version.rb b/clients/ruby/commons/lib/api_gouv_commons/version.rb new file mode 100644 index 0000000000..7a6390571f --- /dev/null +++ b/clients/ruby/commons/lib/api_gouv_commons/version.rb @@ -0,0 +1,3 @@ +module ApiGouvCommons + VERSION = '0.1.0'.freeze +end diff --git a/clients/ruby/commons/spec/auth/bearer_token_spec.rb b/clients/ruby/commons/spec/auth/bearer_token_spec.rb new file mode 100644 index 0000000000..506d52fab0 --- /dev/null +++ b/clients/ruby/commons/spec/auth/bearer_token_spec.rb @@ -0,0 +1,12 @@ +RSpec.describe ApiGouvCommons::Auth::BearerToken do + it 'sets the Authorization header' do + request = Struct.new(:headers).new({}) + described_class.new('abc').apply(request) + expect(request.headers['Authorization']).to eq('Bearer abc') + end + + it 'rejects empty tokens' do + expect { described_class.new('') }.to raise_error(ArgumentError) + expect { described_class.new(nil) }.to raise_error(ArgumentError) + end +end diff --git a/clients/ruby/commons/spec/client_base_spec.rb b/clients/ruby/commons/spec/client_base_spec.rb new file mode 100644 index 0000000000..6f69f68af3 --- /dev/null +++ b/clients/ruby/commons/spec/client_base_spec.rb @@ -0,0 +1,81 @@ +RSpec.describe ApiGouvCommons::ClientBase do + let(:client) { TestClientSupport.build_client } + + def stub_ok(path, headers: {}, body: nil) + stub_request(:get, "https://example.test#{path}") + .with(query: hash_including({})) + .to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }.merge(headers), + body: body || { + data: { 'siren' => '418166096' }, + links: {}, + meta: { 'provider' => 'INSEE' } + }.to_json + ) + end + + describe 'happy path' do + it 'returns a Response with data/links/meta and rate-limit parsed' do + stub_ok('/v3/foo', + headers: { + 'RateLimit-Limit' => '50', + 'RateLimit-Remaining' => '49', + 'RateLimit-Reset' => '1700000000' + }) + response = client.get('/v3/foo') + expect(response.success?).to be true + expect(response.data).to eq('siren' => '418166096') + expect(response.meta).to eq('provider' => 'INSEE') + expect(response.rate_limit.remaining).to eq(49) + expect(response.rate_limit.limit).to eq(50) + end + + it 'sends the Bearer token' do + stub = stub_ok('/v3/foo').with(headers: { 'Authorization' => 'Bearer test-token' }) + client.get('/v3/foo') + expect(stub).to have_been_requested + end + + it 'merges default_params with per-call params and drops nil' do + stub = stub_request(:get, 'https://example.test/v3/foo') + .with(query: hash_including('recipient' => '13002526500013', 'context' => 'override', 'object' => 'obj')) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: {}, links: {}, meta: {} }.to_json) + client.get('/v3/foo', params: { context: 'override', extra: nil }) + expect(stub).to have_been_requested + end + end + + describe 'local validation (before any HTTP call)' do + it 'raises MissingParameterError if a required param is missing' do + barebone = TestClientSupport.build_client(default_params: { recipient: '13002526500013' }) + expect { barebone.get('/v3/foo') } + .to raise_error(ApiGouvCommons::MissingParameterError, /context/) + expect(a_request(:get, /.+/)).not_to have_been_made + end + + it 'raises InvalidSiretError on a malformed recipient' do + expect { client.get('/v3/foo', params: { recipient: 'nope' }) } + .to raise_error(ApiGouvCommons::InvalidSiretError) + expect(a_request(:get, /.+/)).not_to have_been_made + end + + it 'raises AuthenticationError without an HTTP call when auth strategy raises' do + strategy = Class.new(ApiGouvCommons::Auth::Strategy) do + def apply(_req) + raise 'boom' + end + end.new + cfg = ApiGouvCommons::Configuration.new( + base_urls: TestClientSupport::BASE_URLS, + auth_strategy: strategy, + default_params: { recipient: '13002526500013', context: 'c', object: 'o' } + ) + faulty = ApiGouvCommons::ClientBase.new(cfg, product: :entreprise) + expect { faulty.get('/v3/foo') } + .to raise_error(ApiGouvCommons::AuthenticationError, /boom/) + expect(a_request(:get, /.+/)).not_to have_been_made + end + end +end diff --git a/clients/ruby/commons/spec/configuration_spec.rb b/clients/ruby/commons/spec/configuration_spec.rb new file mode 100644 index 0000000000..c99ac2bf35 --- /dev/null +++ b/clients/ruby/commons/spec/configuration_spec.rb @@ -0,0 +1,63 @@ +RSpec.describe ApiGouvCommons::Configuration do + let(:base_urls) do + { + ApiGouvCommons::Configuration::PRODUCTION => 'https://prod.test', + ApiGouvCommons::Configuration::STAGING => 'https://staging.test' + } + end + + it 'defaults to production and builds a BearerToken strategy from a token' do + config = described_class.new(base_urls: base_urls, token: 'abc') + expect(config.production?).to be true + expect(config.base_url).to eq('https://prod.test') + expect(config.auth_strategy).to be_a(ApiGouvCommons::Auth::BearerToken) + end + + it 'resolves staging' do + config = described_class.new(base_urls: base_urls, token: 'abc', environment: :staging) + expect(config.base_url).to eq('https://staging.test') + expect(config.staging?).to be true + end + + it 'accepts a base_url override' do + config = described_class.new(base_urls: base_urls, token: 'x', base_url: 'https://custom.test') + expect(config.base_url).to eq('https://custom.test') + end + + it 'rejects unknown environments' do + expect { described_class.new(base_urls: base_urls, token: 'x', environment: :dev) } + .to raise_error(ArgumentError, /environment must be/) + end + + it 'is frozen after construction' do + config = described_class.new(base_urls: base_urls, token: 'x') + expect(config).to be_frozen + end + + describe '#with' do + it 'returns a new instance without mutating the source' do + original = described_class.new(base_urls: base_urls, token: 'x') + copy = original.with(environment: :staging) + expect(copy).not_to equal(original) + expect(original.staging?).to be false + expect(copy.staging?).to be true + end + + it 'is aliased as copy' do + original = described_class.new(base_urls: base_urls, token: 'x') + expect(original.method(:copy)).to eq(original.method(:with)) + end + end + + it 'defaults timeouts to 5 s / 30 s' do + config = described_class.new(base_urls: base_urls, token: 'x') + expect(config.open_timeout).to eq(5) + expect(config.read_timeout).to eq(30) + end + + it 'accepts a preconstructed auth_strategy and ignores token' do + strategy = ApiGouvCommons::Auth::BearerToken.new('zzz') + config = described_class.new(base_urls: base_urls, auth_strategy: strategy) + expect(config.auth_strategy).to equal(strategy) + end +end diff --git a/clients/ruby/commons/spec/errors_spec.rb b/clients/ruby/commons/spec/errors_spec.rb new file mode 100644 index 0000000000..325b55259b --- /dev/null +++ b/clients/ruby/commons/spec/errors_spec.rb @@ -0,0 +1,38 @@ +RSpec.describe ApiGouvCommons::Error do + it 'exposes http_status, method, url, and errors' do + err = described_class.new( + nil, + http_status: 422, + errors: [{ 'code' => '00201', 'title' => 'Entité non traitable', 'detail' => 'Missing context' }], + method: :get, + url: 'https://x.test/foo' + ) + expect(err.http_status).to eq(422) + expect(err.method).to eq(:get) + expect(err.url).to eq('https://x.test/foo') + expect(err.first_error_code).to eq('00201') + expect(err.first_error_detail).to eq('Missing context') + end + + it 'builds a default message from http status + title/detail' do + err = described_class.new( + nil, + http_status: 404, + errors: [{ 'title' => 'Not found', 'detail' => 'Gone' }] + ) + expect(err.message).to include('404', 'Not found', 'Gone') + end + + it 'handles no errors array gracefully' do + err = described_class.new(nil, http_status: 500) + expect(err.first_error_code).to be_nil + expect(err.first_error_meta).to eq({}) + end + + it 'exposes retry_after on RateLimitError and ProviderError' do + rl = ApiGouvCommons::RateLimitError.new(nil, http_status: 429, retry_after: 12) + expect(rl.retry_after).to eq(12) + pr = ApiGouvCommons::ProviderError.new(nil, http_status: 502, retry_after: 300) + expect(pr.retry_after).to eq(300) + end +end diff --git a/clients/ruby/commons/spec/middleware/envelope_spec.rb b/clients/ruby/commons/spec/middleware/envelope_spec.rb new file mode 100644 index 0000000000..ce7d5e72b9 --- /dev/null +++ b/clients/ruby/commons/spec/middleware/envelope_spec.rb @@ -0,0 +1,18 @@ +RSpec.describe 'Envelope middleware' do + let(:client) { TestClientSupport.build_client } + + it 'parses JSON body into a Hash' do + stub_request(:get, 'https://example.test/v3/foo') + .with(query: hash_including('recipient' => '13002526500013')) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: '{"data":{"a":1},"links":{},"meta":{}}') + expect(client.get('/v3/foo').data).to eq('a' => 1) + end + + it 'raises TransportError on non-JSON body' do + stub_request(:get, 'https://example.test/v3/foo') + .with(query: hash_including('recipient' => '13002526500013')) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, body: 'not-json') + expect { client.get('/v3/foo') }.to raise_error(ApiGouvCommons::TransportError, /invalid JSON/) + end +end diff --git a/clients/ruby/commons/spec/middleware/error_handler_spec.rb b/clients/ruby/commons/spec/middleware/error_handler_spec.rb new file mode 100644 index 0000000000..fc474add2b --- /dev/null +++ b/clients/ruby/commons/spec/middleware/error_handler_spec.rb @@ -0,0 +1,78 @@ +RSpec.describe 'ErrorHandler middleware' do + let(:client) { TestClientSupport.build_client } + + def stub_error(status, errors:, headers: {}) + stub_request(:get, 'https://example.test/v3/foo') + .with(query: hash_including('recipient' => '13002526500013')) + .to_return(status: status, + headers: { 'Content-Type' => 'application/json' }.merge(headers), + body: { errors: errors }.to_json) + end + + matrix = [ + [401, '00101', ApiGouvCommons::AuthenticationError], + [401, '00103', ApiGouvCommons::AuthenticationError], + [401, '00105', ApiGouvCommons::AuthenticationError], + [403, '00100', ApiGouvCommons::AuthorizationError], + [404, '04040', ApiGouvCommons::NotFoundError], + [409, '00015', ApiGouvCommons::ConflictError], + [422, '00201', ApiGouvCommons::ValidationError], + [422, '00301', ApiGouvCommons::ValidationError], + [502, '04001', ApiGouvCommons::ProviderError], + [503, '05000', ApiGouvCommons::ProviderUnavailableError], + [418, 'any', ApiGouvCommons::ClientError], + [599, 'any', ApiGouvCommons::ServerError] + ] + + matrix.each do |status, code, klass| + it "maps #{status}/#{code} to #{klass}" do + stub_error(status, errors: [{ code: code, title: 't', detail: 'd' }]) + + expect { client.get('/v3/foo') }.to raise_error(klass) do |e| + expect(e.http_status).to eq(status) + expect(e.first_error_code).to eq(code) + expect(e.first_error_detail).to eq('d') + expect(e.method).to eq(:get) + expect(e.url).to include('/v3/foo') + end + end + end + + it 'wraps Faraday::ConnectionFailed as TransportError' do + stub_request(:get, 'https://example.test/v3/foo') + .with(query: hash_including('recipient' => '13002526500013')) + .to_raise(Errno::ECONNREFUSED) + expect { client.get('/v3/foo') }.to raise_error(ApiGouvCommons::TransportError) + end + + describe 'RateLimitError.retry_after' do + it 'derives from RateLimit-Reset' do + stub_error(429, + errors: [{ code: '00429', title: 't', detail: 'd', meta: {} }], + headers: { 'RateLimit-Reset' => (Time.now.to_i + 42).to_s }) + begin + client.get('/v3/foo') + rescue ApiGouvCommons::RateLimitError => e + expect(e.retry_after).to be_between(40, 44).inclusive + end + end + + it 'falls back to meta.retry_in when no reset header' do + stub_error(429, errors: [{ code: '00429', title: 't', detail: 'd', meta: { retry_in: 7 } }]) + begin + client.get('/v3/foo') + rescue ApiGouvCommons::RateLimitError => e + expect(e.retry_after).to eq(7) + end + end + end + + it 'surfaces meta.retry_in on ProviderError (502)' do + stub_error(502, errors: [{ code: '04001', title: 't', detail: 'd', meta: { retry_in: 300 } }]) + begin + client.get('/v3/foo') + rescue ApiGouvCommons::ProviderError => e + expect(e.retry_after).to eq(300) + end + end +end diff --git a/clients/ruby/commons/spec/middleware/logging_spec.rb b/clients/ruby/commons/spec/middleware/logging_spec.rb new file mode 100644 index 0000000000..b96504bc5d --- /dev/null +++ b/clients/ruby/commons/spec/middleware/logging_spec.rb @@ -0,0 +1,55 @@ +RSpec.describe 'Logging middleware' do + let(:logger) do + Class.new do + attr_reader :info_calls, :error_calls + + def initialize + @info_calls = [] + @error_calls = [] + end + + def info(payload) + @info_calls << payload + end + + def error(payload) + @error_calls << payload + end + end.new + end + + it 'logs method/url/status/duration/rate_limit_remaining on success' do + client = TestClientSupport.build_client(logger: logger) + stub_request(:get, 'https://example.test/v3/foo') + .with(query: hash_including('recipient' => '13002526500013')) + .to_return(status: 200, + headers: { 'Content-Type' => 'application/json', 'RateLimit-Remaining' => '48' }, + body: { data: {}, links: {}, meta: {} }.to_json) + client.get('/v3/foo') + expect(logger.info_calls.last).to include( + method: 'GET', + status: 200, + rate_limit_remaining: 48 + ) + expect(logger.info_calls.last[:duration_ms]).to be >= 0 + end + + it 'redacts the query string for the particulier product' do + cfg = ApiGouvCommons::Configuration.new( + base_urls: TestClientSupport::BASE_URLS, + token: 't', + default_params: { recipient: '13002526500013' }, + logger: logger + ) + client = ApiGouvCommons::ClientBase.new(cfg, product: :particulier) + client.singleton_class.send(:define_method, :required_params_for) { |_| [:recipient] } + + stub_request(:get, /https:\/\/example\.test\/v3\/bar/) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: {}, links: {}, meta: {} }.to_json) + + client.get('/v3/bar', params: { nomNaissance: 'DUPONT' }) + expect(logger.info_calls.last[:url]).to include('[REDACTED]') + expect(logger.info_calls.last[:url]).not_to include('DUPONT') + end +end diff --git a/clients/ruby/commons/spec/middleware/retry_spec.rb b/clients/ruby/commons/spec/middleware/retry_spec.rb new file mode 100644 index 0000000000..56fa3375c1 --- /dev/null +++ b/clients/ruby/commons/spec/middleware/retry_spec.rb @@ -0,0 +1,54 @@ +RSpec.describe 'Retry middleware' do + let(:path) { '/v3/foo' } + let(:url) { "https://example.test#{path}" } + + def build_client(retry_opts) + cfg = ApiGouvCommons::Configuration.new( + base_urls: TestClientSupport::BASE_URLS, + token: 't', + default_params: { recipient: '13002526500013', context: 'c', object: 'o' }, + retry: retry_opts + ) + ApiGouvCommons::ClientBase.new(cfg, product: :entreprise) + end + + it 'retries 502 up to max and returns on success' do + stub = stub_request(:get, url) + .with(query: hash_including('recipient' => '13002526500013')) + .to_return( + { status: 502, headers: { 'Content-Type' => 'application/json' }, + body: { errors: [{ code: '04001', title: 't', detail: 'd' }] }.to_json }, + { status: 502, headers: { 'Content-Type' => 'application/json' }, + body: { errors: [{ code: '04001', title: 't', detail: 'd' }] }.to_json }, + { status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: { ok: true }, links: {}, meta: {} }.to_json } + ) + + client = build_client(max: 3, on_status: [502], interval: 0, backoff_factor: 1) + response = client.get(path) + expect(response.http_status).to eq(200) + expect(stub).to have_been_requested.times(3) + end + + it 'never retries 404' do + stub = stub_request(:get, url) + .with(query: hash_including('recipient' => '13002526500013')) + .to_return(status: 404, headers: { 'Content-Type' => 'application/json' }, + body: { errors: [{ code: '00404', title: 't', detail: 'd' }] }.to_json) + + client = build_client(max: 5, on_status: [429, 502, 503], interval: 0) + expect { client.get(path) }.to raise_error(ApiGouvCommons::NotFoundError) + expect(stub).to have_been_requested.once + end + + it 'does not retry when retry config is absent' do + stub = stub_request(:get, url) + .with(query: hash_including('recipient' => '13002526500013')) + .to_return(status: 502, headers: { 'Content-Type' => 'application/json' }, + body: { errors: [{ code: '04001', title: 't', detail: 'd' }] }.to_json) + + client = TestClientSupport.build_client + expect { client.get(path) }.to raise_error(ApiGouvCommons::ProviderError) + expect(stub).to have_been_requested.once + end +end diff --git a/clients/ruby/commons/spec/rate_limit_spec.rb b/clients/ruby/commons/spec/rate_limit_spec.rb new file mode 100644 index 0000000000..0fdf88485f --- /dev/null +++ b/clients/ruby/commons/spec/rate_limit_spec.rb @@ -0,0 +1,49 @@ +RSpec.describe ApiGouvCommons::RateLimit do + it 'parses well-formed RateLimit-* headers' do + rl = described_class.from_headers( + 'RateLimit-Limit' => '50', + 'RateLimit-Remaining' => '47', + 'RateLimit-Reset' => '1637223155' + ) + expect(rl.limit).to eq(50) + expect(rl.remaining).to eq(47) + expect(rl.reset_at).to eq(Time.at(1_637_223_155).utc) + end + + it 'handles lowercase headers (Faraday normalised)' do + rl = described_class.from_headers('ratelimit-limit' => '50', 'ratelimit-remaining' => '49', 'ratelimit-reset' => '1700000000') + expect(rl.remaining).to eq(49) + end + + it 'returns nil when no rate-limit headers are present' do + expect(described_class.from_headers('content-type' => 'application/json')).to be_nil + end + + it 'returns nil for malformed values rather than raising' do + rl = described_class.from_headers( + 'RateLimit-Limit' => 'not-a-number', + 'RateLimit-Remaining' => 'oops', + 'RateLimit-Reset' => 'NaN' + ) + expect(rl).to be_nil + end + + describe '#retry_after' do + it 'returns seconds remaining when reset_at is in the future' do + now = Time.now + rl = described_class.new(limit: 50, remaining: 0, reset_at: now + 30) + expect(rl.retry_after(now: now)).to eq(30) + end + + it 'clamps to zero when reset_at is in the past' do + now = Time.now + rl = described_class.new(limit: 50, remaining: 0, reset_at: now - 30) + expect(rl.retry_after(now: now)).to eq(0) + end + + it 'returns nil if reset_at is nil' do + rl = described_class.new(limit: 50, remaining: 10, reset_at: nil) + expect(rl.retry_after).to be_nil + end + end +end diff --git a/clients/ruby/commons/spec/response_spec.rb b/clients/ruby/commons/spec/response_spec.rb new file mode 100644 index 0000000000..0b6adb98d9 --- /dev/null +++ b/clients/ruby/commons/spec/response_spec.rb @@ -0,0 +1,26 @@ +RSpec.describe ApiGouvCommons::Response do + it 'exposes data, links, meta from a well-formed envelope' do + response = described_class.new( + raw: { 'data' => { 'siren' => '418166096' }, 'links' => { 'next' => '/n' }, 'meta' => { 'provider' => 'INSEE' } }, + http_status: 200, + headers: { 'Content-Type' => 'application/json' } + ) + expect(response.data).to eq('siren' => '418166096') + expect(response.links).to eq('next' => '/n') + expect(response.meta).to eq('provider' => 'INSEE') + expect(response.success?).to be true + end + + it 'returns empty hashes for missing links/meta' do + response = described_class.new(raw: { 'data' => {} }, http_status: 200, headers: {}) + expect(response.links).to eq({}) + expect(response.meta).to eq({}) + end + + it 'tolerates non-hash raw by exposing empty envelope' do + response = described_class.new(raw: nil, http_status: 204, headers: {}) + expect(response.data).to be_nil + expect(response.links).to eq({}) + expect(response.meta).to eq({}) + end +end diff --git a/clients/ruby/commons/spec/siren_spec.rb b/clients/ruby/commons/spec/siren_spec.rb new file mode 100644 index 0000000000..2a5a82e543 --- /dev/null +++ b/clients/ruby/commons/spec/siren_spec.rb @@ -0,0 +1,27 @@ +RSpec.describe ApiGouvCommons::Siren do + it 'accepts a Luhn-valid SIREN' do + expect(described_class.valid?('418166096')).to be true + end + + it 'rejects a 9-digit string that fails Luhn' do + expect(described_class.valid?('418166097')).to be false + end + + it 'rejects wrong length' do + expect(described_class.valid?('12345678')).to be false + expect(described_class.valid?('1234567890')).to be false + end + + it 'accepts La Poste SIREN' do + expect(described_class.valid?('356000000')).to be true + end + + it 'rejects nil' do + expect(described_class.valid?(nil)).to be false + end + + it 'raises InvalidSirenError on validate! for a bad value' do + expect { described_class.validate!('bogus', parameter: :siren) } + .to raise_error(ApiGouvCommons::InvalidSirenError, /siren/) + end +end diff --git a/clients/ruby/commons/spec/siret_spec.rb b/clients/ruby/commons/spec/siret_spec.rb new file mode 100644 index 0000000000..6aa7e17e25 --- /dev/null +++ b/clients/ruby/commons/spec/siret_spec.rb @@ -0,0 +1,40 @@ +RSpec.describe ApiGouvCommons::Siret do + describe '.valid?' do + it 'accepts a Luhn-valid SIRET' do + expect(described_class.valid?('13002526500013')).to be true + end + + it 'accepts a La Poste SIRET even if Luhn fails' do + expect(described_class.valid?('35600000000001')).to be true + end + + it 'rejects a 14-digit string that fails Luhn' do + expect(described_class.valid?('13002526500014')).to be false + end + + it 'rejects wrong length' do + expect(described_class.valid?('1234567890123')).to be false + expect(described_class.valid?('123456789012345')).to be false + end + + it 'rejects non-digit characters' do + expect(described_class.valid?('1300252650001A')).to be false + end + + it 'rejects nil and empty' do + expect(described_class.valid?(nil)).to be false + expect(described_class.valid?('')).to be false + end + end + + describe '.validate!' do + it 'raises InvalidSiretError with the parameter name on failure' do + expect { described_class.validate!('nope', parameter: :recipient) } + .to raise_error(ApiGouvCommons::InvalidSiretError, /recipient/) + end + + it 'is silent on a valid SIRET' do + expect { described_class.validate!('13002526500013', parameter: :recipient) }.not_to raise_error + end + end +end diff --git a/clients/ruby/commons/spec/spec_helper.rb b/clients/ruby/commons/spec/spec_helper.rb new file mode 100644 index 0000000000..3f5825e1b9 --- /dev/null +++ b/clients/ruby/commons/spec/spec_helper.rb @@ -0,0 +1,17 @@ +require 'api_gouv_commons' +require 'webmock/rspec' +require_relative 'support/test_client' + +WebMock.disable_net_connect! + +RSpec.configure do |config| + config.expect_with :rspec do |c| + c.syntax = :expect + end + config.mock_with :rspec do |c| + c.verify_partial_doubles = true + end + config.disable_monkey_patching! + config.order = :random + Kernel.srand config.seed +end diff --git a/clients/ruby/commons/spec/support/test_client.rb b/clients/ruby/commons/spec/support/test_client.rb new file mode 100644 index 0000000000..87929bee78 --- /dev/null +++ b/clients/ruby/commons/spec/support/test_client.rb @@ -0,0 +1,21 @@ +require 'api_gouv_commons' + +module TestClientSupport + BASE_URLS = { + ApiGouvCommons::Configuration::PRODUCTION => 'https://example.test', + ApiGouvCommons::Configuration::STAGING => 'https://staging.example.test' + }.freeze + + module_function + + def build_client(overrides = {}) + config = ApiGouvCommons::Configuration.new( + base_urls: BASE_URLS, + token: 'test-token', + default_params: overrides.delete(:default_params) || + { recipient: '13002526500013', context: 'ctx', object: 'obj' }, + **overrides + ) + ApiGouvCommons::ClientBase.new(config, product: :entreprise) + end +end diff --git a/clients/ruby/commons/spec/user_agent_spec.rb b/clients/ruby/commons/spec/user_agent_spec.rb new file mode 100644 index 0000000000..5b8100395c --- /dev/null +++ b/clients/ruby/commons/spec/user_agent_spec.rb @@ -0,0 +1,11 @@ +RSpec.describe ApiGouvCommons::UserAgent do + it 'builds the §10 format' do + ua = described_class.build(product: 'api-entreprise-ruby', version: '1.2.3') + expect(ua).to eq('api-entreprise-ruby/1.2.3 (+https://github.com/datagouv/apistration)') + end + + it 'appends an optional suffix' do + ua = described_class.build(product: 'api-particulier-ruby', version: '0.1.0', suffix: 'MyApp/4.5') + expect(ua).to eq('api-particulier-ruby/0.1.0 (+https://github.com/datagouv/apistration) MyApp/4.5') + end +end diff --git a/mocks/payloads/api_entreprise_v3_insee_successions/403.yaml b/mocks/payloads/api_entreprise_v3_insee_successions/403.yaml index ff7fb14007..b33f7701a3 100644 --- a/mocks/payloads/api_entreprise_v3_insee_successions/403.yaml +++ b/mocks/payloads/api_entreprise_v3_insee_successions/403.yaml @@ -10,7 +10,7 @@ payload: |- { "title": "Privilèges insuffisants", "detail": "Votre token est valide mais vos privilèges sont insuffisants. Listez vos privilèges sur /v2/privileges", - "code": "0100" + "code": "00100" } ] } diff --git a/mocks/payloads/api_entreprise_v3_insee_successions/502.yaml b/mocks/payloads/api_entreprise_v3_insee_successions/502.yaml index baa6891837..f92b77b40c 100644 --- a/mocks/payloads/api_entreprise_v3_insee_successions/502.yaml +++ b/mocks/payloads/api_entreprise_v3_insee_successions/502.yaml @@ -10,7 +10,10 @@ payload: |- { "title": "Erreur inconnue du fournisseur de données", "detail": "La réponse retournée par le fournisseur de données est invalide et inconnue de notre service. L'équipe technique a été notifiée de cette erreur pour investigation.", - "code": "01999" + "code": "01999", + "meta": { + "provider": "INSEE" + } } ] } diff --git a/mocks/payloads/api_entreprise_v3_insee_successions/504.yaml b/mocks/payloads/api_entreprise_v3_insee_successions/504.yaml index 2f95f026ab..c705c7b313 100644 --- a/mocks/payloads/api_entreprise_v3_insee_successions/504.yaml +++ b/mocks/payloads/api_entreprise_v3_insee_successions/504.yaml @@ -10,7 +10,10 @@ payload: |- { "title": "Service non disponible", "detail": "Service du fournisseur de données temporairement indisponible ou en maintenance.", - "code": "01001" + "code": "01001", + "meta": { + "provider": "INSEE" + } } ] }