diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de12a0..fb22bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,71 @@ All notable changes to `bulutklinik-sdk` are documented here. The format is base on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.1] + +Documentation and contract corrections found by auditing the SDKs against the +API source before release. No wire change. + +### Fixed + +- `doctors.search` no longer lets `searchParams` default to an empty map. The + server rule is `required|array` and PHP's `required` rejects an empty array, so + `{}` was a guaranteed `422` rather than an unfiltered search. +- Corrected the `measures.health_information` note. The defect it described — the API + nulling `identity` before the patient lookup — was fixed API-side on + 2026-07-21. What actually remains is looser and worth knowing: the lookup is + `identity OR phoneNumber` against the global user table and takes the first + row, so a phone number alone can resolve a person whose TCKN differs from the + one you sent. + +## [1.0.0] + +The SDK becomes **partner-only**. Everything that required a patient login is +gone; the company-scoped `/outher` surface that shipped under `client.partner.*` +in 0.6.0 is now the client root. See `DESIGN.md` §12 for the full migration. + +### Changed — BREAKING + +- **`client.partner.` → `client.`.** The six partner groups + (`doctors`, `slots`, `appointments`, `measures`, `laboratory`, `diets`) moved to + the root on both the sync and async clients. Their paths, bodies and behaviour + are unchanged — this is a rename. Resource classes lost the `Partner` prefix + (`PartnerDoctorsResource` → `DoctorsResource`, `AsyncPartnerDoctorsResource` → + `AsyncDoctorsResource`); `PartnerNamespace` / `AsyncPartnerNamespace` are gone. +- **`TokenStore` now holds one partner token**: `get_token` / `set_token` / + `clear` replace `get_access_token` / `get_refresh_token` / `set_tokens`. + `InMemoryTokenStore` takes the token as its single argument. +- **`partner_token` is now the client's credential** and is required for every + call. Passing both `partner_token` and `token_store` raises `ValueError` at + construction rather than silently picking one. +- **No silent refresh.** A `401` / `resultType 4` raises `AuthenticationError` + with no retry — a partner token is issued out of band and cannot be renewed + from here. Install a newly issued token in the token store instead. +- **A missing token fails before dispatch** with `AuthenticationError`, rather + than sending an anonymous request that returns an opaque `401`. +- **Escape hatch `auth` defaults to `"partner"`**; the `"bearer"` mode no longer + exists. `"public"` remains, for unauthenticated endpoints outside the surface. +- `_BASE_URLS` → `API_ROOTS` (now version-less); `resolve_base_url` takes an + `api_version` argument. +- `measures.partner_health_information` → `measures.health_information`. +- `doctors.search` no longer accepts `other_params` / `per_page_limit`, and + `order_params` no longer accepts `point` — the `/outher` search has neither. + +### Added + +- **`api_version="v3" | "v4"`** client option (plus an `ApiVersion` enum). Every + path is version-agnostic, so targeting v4 is configuration, not a code change. + Default stays `v3`. + +### Removed + +- `client.auth` (all 11 methods), `client.payments` (5), `client.skin`, + `client.meals`, `client.addresses` (4) — no company-scoped equivalent exists. +- The patient-persona `doctors` / `slots` / `appointments` / `measures` / + `laboratory` / `diets` that lived at the root in 0.6.0. +- `client_id` / `client_secret` client options. +- The `LoginResult` model and the `bulutklinik.models` module. + ## [0.6.0] ### Added diff --git a/DESIGN.md b/DESIGN.md index 8f2fc38..a450a80 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -6,38 +6,66 @@ > The canonical copy lives at `dev-kits/DESIGN.md`; an identical copy is vendored > into each language repository and re-synced whenever this file changes. > -> Wire contract is derived from `dev-kits/Bulutklinik.postman_collection.json` -> ("Bulutklinik API — Randevu & Ödeme Akışı"), validated against the BulutklinikAPI -> source (Laravel 8.12, OAuth2/Passport). +> Wire contract is derived from the BulutklinikAPI source (Laravel 8.12, +> OAuth2/Passport) — `app/Packages/Integration/Outher` and `routes/{v3,v4}/outher.php`. -- **Spec version:** 0.6.0 (completes the registration flows and closes flow-gaps found in the API audit: adds `auth.confirmRegistrationEmail` (the e-mail-branch middle step that `register` actually needs), the social sign-up pair `auth.verifyRegistrationSocial`/`auth.registerSocial`, the password-reset pair `auth.forgotPassword`/`auth.resetPassword`, `appointments.list`/`appointments.reservations` (the source of the `event_id` that `cancel` needs), and the new `addresses` group required by `laboratory.order`; 0.5.0 added `auth.verifyRegistration`; 0.4.0 added `laboratory` + `diets`; 0.3.0 added `skin` + `meals`; 0.2.0 added the §7.2 escape hatch) -- **API:** BulutklinikAPI v3 -- **Scope:** 11 services / 48 endpoints (patient persona). Designed to grow. +- **Spec version:** 1.0.1 — **breaking** (from 0.6.x). The SDKs become a single-persona, + partner-only surface. Everything that required a patient login is gone; every + method now runs on the company-scoped `/outher` channel with a pre-issued + partner token. See §12 for what was removed and why. +- **API:** BulutklinikAPI `v3` (default) or `v4` — selectable per client. +- **Scope:** 6 services / 28 endpoints (partner persona). --- ## 1. Scope -The SDKs cover the patient appointment-and-payment flow, health measurements, and -AI image analysis: - -| Service | Endpoints | Purpose | -|----------------|:---------:|------------------------------------------------------| -| `auth` | 11 | Login, 2FA, refresh, registration (verify/e-mail-confirm/create), social sign-up, password reset, logout | -| `doctors` | 5 | Branches, locations, quick/filtered search, detail | -| `slots` | 1 | Doctor availability (materialized slots) | -| `appointments` | 5 | Reserve, physical appointment, cancel, list, reservations | -| `payments` | 5 | Discount check, saved cards, pay (3DS) | -| `measures` | 8 | Health measurements (CRUD, list, graph, partner) | -| `skin` | 1 | AI skin-lesion analysis ("Cildimde Neyim Var") | -| `meals` | 1 | AI meal-photo calorie/nutrition estimation | -| `laboratory` | 5 | Lab results, orderable test catalog, test pre-order | -| `diets` | 2 | Diet lists (list + detail) written by the dietitian | -| `addresses` | 4 | The patient's saved addresses (needed by `laboratory.order`) | - -Out of scope for this collection (may be added later): "Anlık randevu" (programs), -video-call (calls). The SDK surface is designed so new services slot in as new -resource groups without breaking existing ones. +The SDKs expose the **partner** persona: a clinic-integration channel where the +caller is a company, authenticated by a pre-issued partner token, acting on the +patients of **its own company**. + +| Service | Endpoints | Purpose | +|----------------|:---------:|-------------------------------------------------------------| +| `doctors` | 4 | Doctor discovery: search, branches, detail, city list | +| `slots` | 1 | Doctor availability (materialized slots) | +| `appointments` | 9 | Reserve, confirm, free-form booking, cancel, list, lookup | +| `measures` | 8 | Health measurements for a named patient (read + write) | +| `laboratory` | 4 | Lab results for a named patient + orderable test catalog | +| `diets` | 2 | Diet lists written by a dietitian, for a named patient | + +### 1.1 What "partner persona" means + +| | partner (this SDK) | +|---|---| +| Who authenticates | your **company**, via a pre-issued partner token | +| Whose data you see | patients of **your own company only** | +| How a patient is named | **inline on every call** (`patient` / `user` object) — there is no session | +| Token lifecycle | issued out of band, ~30 days; **the SDK cannot refresh it** (§5.3) | + +There is no patient login, no session, and no per-user access token. Two calls +for two different patients are indistinguishable to the transport — the patient +reference travels in the request body. + +**Patient identity is carried in the body, never in the URL.** A TCKN in a path +segment would land in access logs, proxy logs and Sentry breadcrumbs. This is why +several read endpoints are `POST` although they are semantically reads. + +### 1.2 Deliberately out of scope + +Not exposed, because the API has no company-scoped equivalent: + +- **Patient authentication and registration** — login, 2FA, token refresh, sign-up, + social sign-up, password reset, logout. +- **Payments** — discount codes, the saved-card vault, 3-D Secure. Partner booking + hands payment off to a browser `url` (§6.3); no partner endpoint produces a + financial record. +- **Self-service AI** — skin-lesion analysis, meal photo analysis. +- **Patient address book** — it exists only to feed the patient-side lab order, + which is itself unavailable to partners. +- Other partner scopes that exist server-side but are separate integrations: + `apilab` (laboratory result write-back), `apidevice` (medical devices), and the + plain-`apiusers` doctor-calendar endpoints. These may be added later as their + own resource groups. --- @@ -45,14 +73,24 @@ resource groups without breaking existing ones. ### 2.1 Base URLs -| Env | Base URL | -|--------------|------------------------------------------------| -| `production` | `https://api.bulutklinik.com/api/v3` | -| `test` | `https://apitest.bulutklinik.com/api/v3` | -| `local` | `https://api-bulutklinik.test/api/v3` (Herd) | +The base URL is `/`. -The client accepts either a named environment preset or an explicit base URL. -Default: `production`. +| Env | API root | +|--------------|------------------------------------------| +| `production` | `https://api.bulutklinik.com/api` | +| `test` | `https://apitest.bulutklinik.com/api` | +| `local` | `https://api-bulutklinik.test/api` (Herd)| + +| `apiVersion` | Segment | Notes | +|--------------|---------|----------------------------------------------------------| +| `v3` | `/v3` | **Default.** The long-standing surface. | +| `v4` | `/v4` | The consolidated architecture. Route-for-route identical for `/outher` — the API's `outher:audit-routes` command enforces v3/v4 parity. | + +The client accepts a named environment preset **plus** an `apiVersion`, or an +explicit `baseUrl` that overrides both. Defaults: `production` + `v3`. + +> Every path in §6 is version-agnostic (`/outher/...`); only the base URL differs. +> Switching `apiVersion` is a configuration change, not a code change. ### 2.2 Required headers @@ -61,13 +99,13 @@ Default: `production`. | `Accept` | `application/json` | Always. | | `Content-Type` | `application/json` | On requests with a body. | | `lang` | `tr` (default), `en`, `de`, `az` | Configurable per-client and per-request. | -| `Authorization`| `Bearer ` | Protected endpoints only. Omitted on public endpoints; partner endpoint uses the partner token. | +| `Authorization`| `Bearer ` | On every endpoint in §6. | ### 2.3 HTTP methods Endpoints use `GET`, `POST`, `PUT`, `DELETE` as specified per endpoint in §6. -Path parameters (e.g. `{id}`, `{type}`, `{page}`) are URL segments, not query -string. Request bodies are JSON. +Path parameters (e.g. `{type}`, `{period}`, `{doctorId}`) are URL segments, not +query string. Request bodies are JSON. --- @@ -100,12 +138,24 @@ typed error (§4). |:-----:|----------|------------------------------------------------------------------------------| | `0` | Success | Return `data`. | | `1` | Error | Raise `ApiError` (or a more specific subtype based on HTTP status / `errorType`). | -| `2` | Logout | Clear the token store, raise `AuthenticationError` (session revoked). | -| `3` | Update | Raise `ApiError` with an "update required" marker (client/app too old). | -| `4` | Refresh | Token expired. **Not** returned by `refreshApi`; returned by the global handler on any protected call that receives an expired/invalid token (HTTP 401). Triggers the auto-refresh+retry flow (§5.4). | +| `2` | Logout | Clear the token store, raise `AuthenticationError` (token revoked). | +| `3` | Update | Raise `ApiError` with an "update required" marker. | +| `4` | Refresh | The partner token is expired or invalid. **There is nothing to refresh** (§5.3) — raise `AuthenticationError` telling the caller to install a newly issued token. | + +> Implementation note: `/outher` returns `resultType 4` with HTTP `401` on an +> expired token. A bare HTTP `401` without a parseable envelope MUST be treated +> identically. Neither triggers a retry. -> Implementation note: `resultType 4` is the canonical refresh signal, but a bare -> HTTP `401` (without a parseable envelope) MUST be treated identically. +### 3.2 The `501` convention + +`/outher` reports most business-rule failures as HTTP **`501`** with +`resultType 1` — "patient not found in your company", "diet list is not yours", +"slot no longer free", "doctor not bookable through your integration". It is not +a server crash. Callers should read `errorMessage`, not the status code alone. + +The read endpoints deliberately return the **same** message for "this patient is +not in your company" and "this patient does not exist". Distinguishing them would +turn the endpoint into a TCKN-probing oracle. --- @@ -121,317 +171,212 @@ BulutklinikError (base — all SDK errors derive from this) ├── TransportError (network failure, timeout, DNS, TLS — no HTTP response) └── ApiError (got an HTTP response that wasn't a success) ├── ValidationError (422, or errorType=validation) - ├── AuthenticationError (401 / resultType 2 logout / failed refresh) - ├── AuthorizationError (403 — authenticated but not permitted/scoped) + ├── AuthenticationError (401 / resultType 2 / resultType 4 — token invalid, expired or revoked) + ├── AuthorizationError (403 — authenticated but the token lacks the scope, or carries no company) ├── NotFoundError (404) └── RateLimitError (429 — throttled; carries Retry-After if present) ``` Each `ApiError` carries: `httpStatus`, `resultType`, `errorType`, `errorMessage`, the raw `data`, and the originating request (method + path) for debugging. -Mapping precedence: logout (`resultType == 2`) → string `errorType == "validation"` -→ HTTP status (401→Auth, 403→Authz, 404→NotFound, 422→Validation, 429→RateLimit) -→ otherwise (incl. numeric `errorType`, or success HTTP with `resultType != 0`) → `ApiError`. +Mapping precedence: logout/expiry (`resultType == 2` or `4`) → string +`errorType == "validation"` → HTTP status (401→Auth, 403→Authz, 404→NotFound, +422→Validation, 429→RateLimit) → otherwise (incl. numeric `errorType`, or success +HTTP with `resultType != 0`) → `ApiError`. Because `errorType` may be numeric (§3), guard before string-matching it. ---- - -## 5. Authentication & token lifecycle - -OAuth2 via Laravel Passport. Access token lifetime ~30 days, refresh token ~130 -days. The token grant happens server-side inside `connectApi` (no direct -`oauth/token` HTTP call from the SDK). - -### 5.1 Login — `auth.connect` - -`POST /general/connectApi` (also aliased at root `/connectApi`). **Public** (no Bearer). - -Request body: - -| Field | Required | Notes | -|-------------------|:--------:|------------------------------------------------------------------| -| `apiUserName` | ✓ | Identifier per `loginMode` (email / TC / phone / user_id). | -| `apiUserPassword` | ✓* | Required except `social` / `afterRegister` modes. | -| `apiClientId` | ✓ | OAuth client id. | -| `apiSecretKey` | ✓ | OAuth client secret. | -| `loginMode` | ✓ | `email` \| `identity` \| `phone` \| `user_id` \| `social` \| `afterRegister`. | -| `withPhoneNumber` | — | Some installs require it in `phone` mode. | - -`loginMode` `social` / `afterRegister` skip password validation -(`validateForPassportPasswordGrant`). - -Success → `data: { access_token, refresh_token, password_policy }`. The SDK -persists both tokens via the token store (§5.5). - -**2FA branch:** if SMS 2FA is enabled (`sms_2fa_status=1`), `data.access_token` is -absent and `data.response` carries an encrypted blob. The SDK surfaces this as a -*two-factor challenge* (typed result, not an error) so the caller can collect the -SMS code and call `auth.connectWithTwoFactor`. - -### 5.2 2FA verification — `auth.connectWithTwoFactor` - -`POST /general/connectApiWithTwoFactor`. **Public** (middleware verifies the SMS code -inside the encrypted blob). - -Request body: - -| Field | Required | Notes | -|------------------------|:--------:|------------------------------------------------| -| `smsVerificationCode` | ✓ | The code the user received by SMS. | -| `response` | ✓ | The encrypted blob from `connect`'s `data.response`. | - -(The collection also sends `tokenInfo`, but the server ignores it — the real token -is decrypted from `response`. SDKs send only `smsVerificationCode` + `response`.) - -Success → `data: { access_token, refresh_token }`. Token is **not** re-minted here; -it was minted during `connect` and is returned now. - -### 5.3 Token refresh — `auth.refresh` - -`POST /general/refreshApi`. **Public.** Uses the Passport `refresh_token` grant. - -Request body: `{ refreshToken, clientId, clientSecretKey }`. -Success → `data: { access_token, refresh_token }` (both rotated; persist both). - -### 5.4 Silent auto-refresh + retry (mandatory in every SDK) - -On any **protected** call: - -1. Send the request with the current access token. -2. If the response is `401` **or** `resultType == 4`, and a refresh token exists, - and this request has **not** already been retried: - a. Call `auth.refresh` with the stored refresh token + client credentials. - b. Persist the new tokens. - c. Retry the original request **once**. -3. If the refresh call itself fails, or `resultType == 2` (logout), clear the - token store and raise `AuthenticationError`. -4. Auto-refresh must be **concurrency-safe**: simultaneous 401s share a single - in-flight refresh (no refresh stampede). Single-threaded SDKs (e.g. plain JS) - gate on one shared promise; threaded SDKs (Java/C#/Go/C++) use a mutex. - -The retry is bounded to one attempt to prevent loops. - -### 5.5 Token store (pluggable) - -A `TokenStore` abstraction holds the access + refresh tokens. Default -implementation is in-memory. Consumers may inject a custom store (file, DB, -secure storage). Required operations (named per language): - -- get access token / get refresh token -- set tokens (access, refresh) — atomically -- clear (on logout / revoked session) +> `403` deserves a note: it is raised not only for a missing `apiouther` scope but +> also when the token resolves to a user with no company. The company boundary is +> derived from the authenticated principal, never from request input — so a `403` +> here means the credential itself is wrong, and retrying with different body +> parameters will never help. -### 5.6 Registration — `verifyRegistration` → (`confirmRegistrationEmail`) → `register` - -Registration is a multi-call flow. `verifyRegistration` returns `confirmationType`: - -- **`"sms"`** → feed its `response` + the SMS code straight into `register`. -- **`"email"`** → the user gets the code by **e-mail**; call `confirmRegistrationEmail` - first (it verifies the e-mail code, sends an **SMS** code, and returns a *fresh* - `response` blob), then feed that blob + the SMS code into `register`. - -⚠️ **A headerless SDK caller always gets `"email"`.** The SMS branch of -`verifyAddingNewPatient` only triggers when an `appversion` header ≤ 5.27 is present; -SDKs send no such header, so `confirmRegistrationEmail` is a **required** middle step, -not optional. `register` (guarded by `checkPhoneVerificationSmsCode`, which requires -`smsVerificationCode`/`smsVerificationExpire` in the blob) cannot consume the e-mail -blob directly — it returns 501. (Social sign-up uses a separate 2-step pair, §5.6.4.) - -#### 5.6.1 Verify — `auth.verifyRegistration` - -`POST /patients/verifyAddingNewPatient`. **Not public** — guarded by -`auth:apiusers`, so it uses the SDK's **partner** token (the same apiusers bearer as -`partnerHealthInformation`, no specific scope required here), plus a -`throttle:perHourFifty` limit. This is the reason the step needs a configured -`partnerToken`; a patient bearer will **not** satisfy the guard. +--- -Request body: `name`, `surname`, `phoneNumber`, `phone_code`, `email`, `password`, -`passwordAgain`, `acceptUserAgreement`, one of `g-recaptcha-response-v2` / `captcha`, -optional `userAgreements[]`. +## 5. Authentication -Rules (validated in `VerifyAddingNewPatientRequest`): -- `phoneNumber` must match `^[+]([0-9\s\(\)]*)$` and be **unique** in `mbl_users` - (this is where duplicate-account detection happens). -- `phone_code` must match `^\+\d{1,3}$` (e.g. `+90`). -- `email` is required (modern app versions) and unique in `mbl_users`. -- `passwordAgain` must equal `password`; the SDKs auto-fill it from `password`. -- **CAPTCHA is mandatory** (`g-recaptcha-response-v2` *or* `captcha`, required-without - each other), validated last via a live Cloudflare/Google call. A pure server-side - caller cannot mint this token — it must come from a browser/human. The SDK method is - therefore a **thin passthrough**: the caller supplies the captcha token. +### 5.1 The partner token -Success → `data: { response: "", confirmationType: "sms" | "email" }`. -The SDK returns `data` verbatim; feed `response` (and the code the user receives) into -`register`. The `response` blob is opaque and passed through unchanged (§8.2). +OAuth2 via Laravel Passport, guard `apiusers`, scope `apiouther` (plus `teusan` +for `measures.healthInformation`). The token is **issued out of band** — through +the Bulutklinik Developer Platform, not by any SDK call. There is no +client-credentials grant the SDK can drive, and no `oauth/token` request. -#### 5.6.2 Confirm e-mail — `auth.confirmRegistrationEmail` +Consequences the SDKs must honour: -`POST /patients/emailConfirmationRegister`. **Public** (guarded by -`checkEmailVerificationCode` + throttle). Called only when `verifyRegistration` -returned `confirmationType: "email"`. +1. The token is a **configuration input**, like an API key. +2. There is **no login method** on the client. +3. There is **no auto-refresh** and no retry-after-refresh (contrast: spec 0.x §5.4). +4. The company the token belongs to is fixed at issue time. It cannot be + overridden per request. -Request body: `verificationCode` (the e-mailed code), `response` (the blob from -`verifyRegistration`), optional `userAgreements[]`. The rest of the profile is -carried inside the encrypted blob and merged server-side (`prepareForValidation`), -so the SDK only sends these fields. +### 5.2 Token store (pluggable) -Success → `data: { response: "", confirmationType: "sms" }`. Feed that -`response` + the SMS code into `register`. +The token is read through a `TokenStore` on **every** request, so a long-lived +process can rotate the credential without being rebuilt — point the store at a +file, a database, or a secret manager and the next call picks up the new value. -#### 5.6.3 Create — `auth.register` +Required operations (named per language): -`POST /patients/addNewPatient`. **Public** but guarded by SMS verification -(`checkPhoneVerificationSmsCode`) + throttle. +| Operation | Purpose | +|------------|------------------------------------------------------------------| +| get token | Return the current partner token, or null/empty if none. | +| set token | Replace the stored token (accepts null to unset). | +| clear | Drop the stored token. Called automatically on `resultType 2`. | -Request body: `name`, `surname`, `apiUserName`, `phoneNumber`, `password`, -`smsVerificationCode`, `response` (the SMS blob from the previous step), -`acceptUserAgreement` (1), `apiClientId`, `apiSecretKey`. +The default implementation is in-memory. The `partnerToken` client option is a +convenience that seeds one: -Rules (validated): -- `phoneNumber` must match `^[+]([0-9\s\(\)]*)$` — i.e. start with `+` and country - code (e.g. `+90 555 111 22 33`). Bare digits are rejected. -- `apiUserName` is used as the `afterRegister` token username; send the **same** - `+CC` value as `phoneNumber`, otherwise auto-login mints a wrong/empty token. -- Password is stored as `Hash::make(BULUT_API_ENC_KEY . password)` (bcrypt rounds=12). - -Success → patient created + automatic `afterRegister` login → `data: { access_token, refresh_token }`. - -#### 5.6.4 Social sign-up — `verifyRegistrationSocial` → `registerSocial` - -A separate **public** 2-step pair for users who authenticate via a social provider. -Unlike `verifyRegistration`, both are public — **no CAPTCHA and no partner token**. - -- `verifyRegistrationSocial` → `POST /patients/verifyAddingNewPatientSocial`. Body: - `name`, `surname`, `phoneNumber`, `password`, `passwordAgain`, `socialType`, `key`, - optional `email`, `acceptUserAgreement`, `userAgreements[]`. Sends the SMS code → - `data: { response }` (note: **no** `confirmationType`). -- `registerSocial` → `POST /patients/addNewPatientWithSocial` (guarded by - `checkPhoneVerificationSmsCode`). Body: `smsVerificationCode`, `response` (blob from - the verify step), optional `userAgreements[]`; the profile is merged from the blob. - Creates the social patient and its `sec_social_login` link row. **Does not - auto-login** — obtain tokens afterwards with `connect({ loginMode: "social" })`. +``` +new Client({ partnerToken: "…" }) ⇒ in-memory store seeded with the token +new Client({ tokenStore: myVaultStore }) ⇒ the token comes from your store +new Client({ partnerToken: …, tokenStore: … }) ⇒ configuration error, raised at construction +``` -### 5.7 Logout — `auth.disconnect` +Passing both is rejected rather than silently resolved: either the literal or the +store is the source of truth, and guessing which one the caller meant is how +credential bugs get shipped. -`POST /general/disconnectApi`. **Bearer required** (`auth:patients,apiusers,doctors`). -Revokes the current access + refresh tokens server-side. The SDK then clears the -token store. Optional device-token fields (firebase/ios) may be added to the body. +If no token is available when a request is dispatched, the SDK raises +`AuthenticationError` **before** touching the network — an unauthenticated call to +`/outher` would only come back as a confusing `401`/`resultType 4`. -### 5.8 Password reset — `forgotPassword` → `resetPassword` - -A **public** 2-step self-service reset flow. +### 5.3 Expiry -- `forgotPassword` → `POST /patients/forgotPassword`. Body: `phoneNumber` (must be a - registered number), optional `birthdate` (`YYYY-MM-DD`; some installs verify it), - and one of `g-recaptcha-response-v2` / `captcha` — **CAPTCHA is mandatory outside - the local environment** (browser-minted, like `verifyRegistration`). Sends the SMS - confirm code → `data: { response }`. -- `resetPassword` → `PUT /patients/forgotPassword` (guarded by - `checkForgotPasswordConfirmSmsCode`). Body: `smsConfirmCode`, `response` (blob from - `forgotPassword`), `password`, `passwordAgain`. Sets the new password (keyed by the - phone/birthdate carried in the blob). Terminal — returns a success message, no tokens. +Passport issues these tokens with a ~30 day lifetime. When one expires the API +answers `401` + `resultType 4`; the SDK raises `AuthenticationError` and does +**not** retry. Recovery is operational: obtain a newly issued token and write it +into the token store (or rebuild the client). SDK READMEs must say this plainly — +`resultType 4` used to mean "the SDK will fix this silently" and now means the +opposite. --- -## 6. Endpoint reference (48) +## 6. Endpoint reference (28) Notation: **Canonical name** = language-neutral concept → per-language naming -follows §7. `[public]` = no auth; `[bearer]` = access token; `[partner]` = partner -token; `[scope:…]` = required OAuth scope. - -### 6.1 `auth` - -| Canonical | Method | Path | Auth | -|----------------------|--------|------------------------------------|----------| -| `connect` | POST | `/general/connectApi` | public | -| `connectWithTwoFactor`| POST | `/general/connectApiWithTwoFactor` | public | -| `refresh` | POST | `/general/refreshApi` | public | -| `verifyRegistration` | POST | `/patients/verifyAddingNewPatient` | partner | -| `confirmRegistrationEmail` | POST | `/patients/emailConfirmationRegister` | public | -| `register` | POST | `/patients/addNewPatient` | public* | -| `verifyRegistrationSocial` | POST | `/patients/verifyAddingNewPatientSocial` | public | -| `registerSocial` | POST | `/patients/addNewPatientWithSocial` | public* | -| `forgotPassword` | POST | `/patients/forgotPassword` | public | -| `resetPassword` | PUT | `/patients/forgotPassword` | public | -| `disconnect` | POST | `/general/disconnectApi` | bearer | - -(Bodies and responses in §5.) - -### 6.2 `doctors` `[bearer] [scope:patients,bulutweb]` - -| Canonical | Method | Path | Body / params | -|----------------|--------|----------------------------------------|---------------| -| `branches` | GET | `/patients/allBranches` | — | -| `locations` | GET | `/patients/allLocations` | — | -| `quickSearch` | POST | `/patients/quickSearch` | `searchText` (3–100, req), `listType` (`interview`\|`appointment`\|null), `location` (null) | -| `search` | POST | `/patients/filteredSearch` | `searchParams{}`, `orderParams[]`, `otherParams[]`, `currentPage` (≥1, req), `perPageLimit` (10–100) | -| `detail` | GET | `/patients/doctorDetail/{id}/{corporate?}` | path `id` (req), optional `corporate` | - -- `quickSearch` response: `{ searchedBranches, searchedDoctors, searchedCompanies, searchedGivenTreatments, searchedBlogs, queryText }`; each item `{ result_id, result_text, result_url, result_sub_text, result_type, result_image }`. -- `search.searchParams` keys: `withFreeText`, `withDoctorName`, `withBranchName`, `withBranchId` (`-1` excludes psychology/diet), `withLocationName`, `withLocationId`, `withCompanyName`, `withCompanyId`, `withGivenTreatments`, `withExpertyId`, `withInstitutionId`, `withNearestSlotDayRange`. - `orderParams`: `name` | `point` | `slot` | `order`. `otherParams`: `isKizilay` | `isQuestionable` | `isInterviewable` | `isAppointmentable`. - Response: `data: { foundDoctorsCount, foundDoctors: [ { doctor_id, name, surname, branch_name, star_rate, nearest_slot, isInterviewable, isAppointmentable, url, user_image, … } ] }`. -- `detail` returns `doctorGeneralInfo` (prices, session length, branch), education, languages, reviews, videos, special services, related clinics. The `doctor_id` here feeds later steps. - -### 6.3 `slots` `[bearer]` - -| Canonical | Method | Path | Body | -|------------|--------|-------------------------------|------| -| `schedule` | POST | `/patients/doctorScheduler` | `doctorId` (numeric, req); `scheduleDate` (`Y-m-d`, today..+21, optional); `scheduleStep` + `schedulePage` (window paging — both required when `scheduleDate` omitted); `listType` (req: `interview` → online slot_type 1,2; else physical slot_type 0,2) | - -Response: `data` = date-keyed map → for each date `[ { slotId, slotStart "HH:mm:ss", slotEnd "HH:mm:ss", available: true } ]`. Empty days are `[]`. -Next step's `appointmentDate` = `"Y-m-d H:i"` (date key + `slotStart`, **drop seconds**). - -### 6.4 `appointments` `[bearer] [scope:patients,bulutweb]` - -| Canonical | Method | Path | Body / params | -|--------------------|--------|--------------------------------------------|---------------| -| `reserveInterview` | POST | `/patients/addInterviewDateReservation` | `doctorId` (numeric, req), `appointmentDate` (`Y-m-d H:i`, today..+21, req), `appointmentType` (`interview`\|`appointment`, default `interview`) | -| `addPhysical` | POST | `/patients/addNewAppointment` | `doctorId` (numeric, req), `appointmentDate` (`Y-m-d H:i`, req). No `appointmentType`. | -| `cancel` | DELETE | `/patients/deleteUserAppointment/{eventId}`| path `eventId` (= `cln_events.id`) | -| `list` | GET | `/patients/userAppointments/{page?}` | optional path `page` (paging disabled — page 1 = full list) | -| `reservations` | GET | `/patients/userReservations` | — | - -`reserveInterview` success → `{ resultType: 0, data: null }`; failure → 501. -`cancel` → 501 for insurance appointments, past cancel-window, or not found. -Slot is resolved server-side from `doctorId` + `appointmentDate` (no `slotId` in request). -- `list` → `data: { foundAppointmentsCount, foundAppointments: [ { event_id, event_start_date, doctor_id, doctor_name, doctor_surname, status, amount, online_call, … } ] }`. **`event_id` is the id `cancel` takes**; rows with `event_id == "0"` are paid-order/refund entries (not cancellable) — filter them out. -- `reservations` → a bare array of active online-slot holds: `{ appoinment_date, doctor_id, doctor_name, doctor_surname, medical_branch_name, minute_diff, second_diff }` (pair `minute_diff`+`second_diff` for a countdown). - -### 6.5 `payments` - -| Canonical | Method | Path | Auth | Notes | -|--------------------|--------|-----------------------------------|--------|-------| -| `checkDiscountCode`| POST | `/patients/checkDiscountCode` | bearer | **`patients` prefix, not `payments`.** | -| `getCards` | GET | `/payments/getCards` | bearer | | -| `saveCard` | POST | `/payments/saveCard` | bearer | Flat fields (not nested). | -| `pay` | POST | `/payments/interviewPayment` | bearer | Throttle 20/h/IP. Returns `payment3DUrl`. | -| `deleteCard` | DELETE | `/payments/deleteCard/{cardId}` | bearer | path `cardId` | - -- `checkDiscountCode` body: `checkType` (`question`\|`appointment`\|`lab`\|`special`\|`physicallyAppointment`\|`tmcLab`\|`program`), `doctorId` (required except lab/tmcLab/program), `discountCode` (req), plus `orderId`/`specialServiceId`/`programSlug` per type. Valid → `data: { discount_code, discount_title, discount_id, prices }`. -- `getCards` → `data.cards[]: { id, card_holder_name, card_number (masked), card_type, created_at }`. `id` → `cardId`. -- `saveCard` body (flat, `SavePatientCardRequest`): `cardHolder`, `cardNumber`, `cardExpMonth` (`m`), `cardExpYear` (`Y`), `cardCvv` — all required. -- `pay` body: `doctorId` (req), `appointmentDate` (`Y-m-d H:i`, req), `appointmentType` (`interview`→order_type 0 / `appointment`→3), `is3D` (bool, req), `termsAccept` (accepted, req), `saveCard` (1=tokenize), `discountCode` (opt), `caseDetail` (opt, encrypted), **and** either `cardInfo{ cardHolder, cardNumber, cardExpMonth, cardExpYear, cardCvv }` (all-or-none) **or** `cardId` (saved card). Amount is computed server-side (no `amount` in request). -- `pay` response: see §8.1 (`payment3DUrl` handling). - -### 6.6 `measures` - -Patient endpoints `[bearer] [scope:patients]`; partner endpoint `[partner] [scope:teusan]`. -Records are written to the authenticated patient (`bas_com_company_id` from token). - -| Canonical | Method | Path | Body / params | -|----------------------------|--------|------------------------------------------------------|---------------| -| `addList` | POST | `/patients/addNewUserMeasures` | `data[]` — each item: `type` + that type's fields + `date_time`. **Primary "submit health data" endpoint.** | -| `add` | POST | `/patients/addNewUserMeasures/{type}` | path `type`; body: `date_time` + type fields | -| `update` | PUT | `/patients/updateUserMeasures/{type}` | path `type`; body: `id` (req) + fields + `date_time` | -| `delete` | DELETE | `/patients/deleteUserMeasures/{type}` | path `type`; body: `id` (req) | -| `last` | GET | `/patients/measuresList` | Latest value per type. | -| `list` | GET | `/patients/userMeasuresList/{type}/{page}/{glucoseType?}` | path; `glucoseType` 0/1 only for glucose | -| `graph` | GET | `/patients/userMeasuresGraph/{type}/{period}/{page}/{glucoseType?}` | `period` 1=day,2=week,3=month,4=year | -| `partnerHealthInformation` | POST | `/outher/healthInformation` | partner token; body: `identity`, `phoneNumber`, `data[]` | - -`addList` runs in a DB transaction; submit multiple measurements in one call. -`last` returns the most-recent of each type (tension splits into hypertension/hypotension; glucose splits into `hunger_glucose`/`postprandial_glucose`), each with a `*Date`. +follows §7. Every endpoint below requires the partner token; the scope column +lists the OAuth scope the token must carry. + +Two patient-reference shapes recur; both are defined in §8.1. + +- **`patientRef`** — `{ identityNumber?, phoneNumber? }`, at least one. Used by + **reads**. Never creates anything. +- **`bookingUser`** — `{ name, surname, phoneNumber, identityNumber?, email?, birthdate?, nationality?, price? }`. + Used by **writes**. Creates the patient in your company if absent. + +### 6.1 `doctors` `[scope:apiouther]` + +| Canonical | Method | Path | Body / params | +|-------------|--------|-------------------------------|---------------| +| `search` | POST | `/outher/search` | `searchParams{}` (req), `orderParams[]` (`name`\|`order`\|`slot`), `currentPage` (≥1, req) | +| `branches` | GET | `/outher/branches` | — | +| `detail` | GET | `/outher/doctorInfos/{doctorId}` | path `doctorId` (req, numeric) | +| `locations` | GET | `/outher/locations` | — | + +- Results are filtered to the doctors enabled for your integration (the server + applies your partner slug), so anything returned here is bookable by you. + `locations` is the exception: a global city catalogue, not company-scoped. +- `search.searchParams` accepts the same keys as the patient-side filtered search + (`withFreeText`, `withDoctorName`, `withBranchName`, `withBranchId`, + `withLocationName`, `withLocationId`, `withCompanyName`, `withCompanyId`, + `withGivenTreatments`, `withExpertyId`, `withInstitutionId`, + `withNearestSlotDayRange`). Response: `{ foundDoctorsCount, foundDoctors: [ { doctor_id, name, surname, branch_name, … } ] }`. + Note `orderParams` here is narrower than the patient surface — `point` is not accepted. +- **`searchParams` must contain at least one key.** Its rule is `required|array`, + and PHP's `required` rejects an empty array — so `{}` is a guaranteed `422`, + not an unfiltered search. SDKs must therefore make `searchParams` a required + argument and must not default it to an empty map. +- `detail` `doctor_id` feeds `slots.schedule` and the booking calls. + +### 6.2 `slots` `[scope:apiouther]` + +| Canonical | Method | Path | Body | +|------------|--------|-------------------------|------| +| `schedule` | POST | `/outher/doctorSlots` | `doctorId` (numeric, req); `scheduleDate` (`Y-m-d`, today..+21, optional); `scheduleStep` + `schedulePage` (window paging — both required when `scheduleDate` omitted) | + +Response: `data` = date-keyed map → for each date +`[ { slotId, slotStart "HH:mm:ss", slotEnd "HH:mm:ss", available: true } ]`. +Empty days are `[]`. `slotId` feeds `appointments.reserve`; an +`appointmentDate` elsewhere is `"Y-m-d H:i"` (date key + `slotStart`, **seconds dropped**). + +Unlike the patient surface there is no `listType` — the partner channel is online +interviews. + +### 6.3 `appointments` `[scope:apiouther]` + +| Canonical | Method | Path | Body / params | +|--------------------------|--------|-----------------------------------|---------------| +| `reserve` | POST | `/outher/reservation` | `slotId` (req), `doctorId` (req), `user{}` = bookingUser | +| `reserveWithoutAgreement`| POST | `/outher/reservationWithoutAgreement` | same as `reserve` | +| `instantReserve` | POST | `/outher/instantReservation` | `user{}` = bookingUser | +| `create` | POST | `/outher/appointment` | `hash` (req), `outherProcessId` (req, numeric) | +| `createWithoutSlot` | POST | `/outher/appointmentWithoutSlot` | `doctorId` (req), `startDate` (`Y-m-d H:i`, ≥ today, req), `finishDate` (`Y-m-d H:i`, after `startDate`, req), `isOutherDoctor` (0\|1), `user{}` = bookingUser | +| `cancelWithoutSlot` | DELETE | `/outher/appointmentWithoutSlot` | appointment lookup (below) | +| `list` | POST | `/outher/appointments` | `phoneNumber` (req), `page` (≥1), `type` (`normal`\|`instant`) | +| `info` | POST | `/outher/appointmentInfo` | appointment lookup (below) | +| `checkDoctor` | POST | `/outher/checkDoctor` | `doctorId` (req, numeric), `isOutherDoctor` (req, 0\|1) | + +**Appointment lookup** (`info`, `cancelWithoutSlot`) addresses one appointment +either **by process** — `hash` + `outherProcessId` — or **by coordinates** — +`doctorId` + `appointmentDate` (`Y-m-d H:i`) + `isOutherDoctor`. Send one pair or +the other; the server validates them as mutually `required_without`. + +**The two booking flows:** + +``` +(A) hand off to the patient reserve ──▶ data.url ──▶ patient opens it in a + browser: agreements + payment +(B) you collected the agreements reserveWithoutAgreement ──▶ data.hash + └─▶ create(hash, outherProcessId) ──▶ appointment +``` + +- `reserve` → `{ url, hash }`. `url` is a short link to the Bulutklinik agreement + and payment page; hand it to the patient. The SDK returns it verbatim and never + opens or follows it. +- `reserveWithoutAgreement` → `{ hash, doctorId, slotId, phoneNumber, reservationExpired }`. + `reservationExpired` (`Y-m-d H:i:s`) is the hold deadline — `create` after it + passes fails with `501`. +- `instantReserve` → `{ url }`. No slot: the server picks an available doctor. +- `create` → the appointment record plus `last_delete_time` (the cancellation + deadline). +- `createWithoutSlot` books a free-form range outside the slot grid, for + integrations running their own calendar. `cancelWithoutSlot` reverses it — and + **only** it; appointments created through `create` are not cancellable here. +- `list` returns the appointments **you** created for that phone number, not the + patient's full history across the platform. +- `checkDoctor` → `{ title, name, surname, branch_name, state: "1" }` when the + doctor is bookable through your integration; `501` when not. Call it before + showing a doctor as reservable. + +> Completing a payment (`outherProcess`) is **not** on the partner surface: those +> routes require a `patients`/`bulutweb` scope. Flow (A) exists precisely because +> the browser hand-off is where payment happens. + +### 6.4 `measures` `[scope:apiouther]` (`healthInformation`: `[scope:teusan]`) + +Reads resolve the patient inside your company and never create one. Writes create +the patient if needed. **Measurements are written to your own company** — a value +you write does not appear in the patient's Bulutklinik mobile app. That is the +intended consequence of tenant isolation, not a bug. + +| Canonical | Method | Path | Body / params | +|---------------------|--------|-----------------------------------------------|---------------| +| `last` | POST | `/outher/lastMeasures` | `patient{}` = patientRef | +| `list` | POST | `/outher/measuresList/{type}` | path `type`; `patient{}` = patientRef, `currentPage` (≥1), `glucoseType` (0\|1, glucose only) | +| `graph` | POST | `/outher/measuresGraph/{type}/{period}` | path `type`, `period` (1=day,2=week,3=month,4=year); `patient{}` = patientRef, `currentPage`, `glucoseType` | +| `addList` | POST | `/outher/measures` | `patient{}` = bookingUser, `data[]` (1–200 items) — each item `type` + that type's fields + `date_time` | +| `add` | POST | `/outher/measure/{type}` | path `type`; `patient{}` = bookingUser, `date_time` + type fields | +| `update` | PUT | `/outher/measure/{type}` | path `type`; `patient{}` = patientRef, `id` (req) + fields + `date_time` | +| `delete` | DELETE | `/outher/measure/{type}` | path `type`; `patient{}` = patientRef, `id` (req) | +| `healthInformation` | POST | `/outher/healthInformation` | `identity`, `phoneNumber`, `data[]` — legacy flat contract, **not** `patient{}` | + +- `addList` writes every row in **one transaction**, capped at **200 items** — + without a cap a single request would hold a transaction open across thousands of + rows and block on the `med_monitor_*` tables. +- `update`/`delete` take the read-side `patient{}` (if there is a row to change, + the patient already exists) and bound the write to `id` + patient + company. +- `id` for `update`/`delete` comes from `list`. **Measure type schema** (every record also requires `date_time` = `"Y-m-d H:i"`): @@ -451,166 +396,114 @@ Records are written to the authenticated patient (`bas_com_company_id` from toke | `step` | `step` | | `sleep` | `sleep` (hours; stored to `sleep_time`) | -Value rules: numeric; `tension`/`pulse` digits 1–10; `glucose` 0–99999.99 + `glucose_type` 0\|1; `weight`/`length` 0–99999.99; etc. - -> **Known API bug (document, don't replicate):** for the partner endpoint, -> `AddNewUserMeasuresListRequest::prepareForValidation` reads `identity` from -> `$this->message` instead of `$this->identity`, nulling it during validation; in -> practice matching falls back to `phoneNumber`. The SDK sends the correct -> contract (`identity` + `phoneNumber`) and notes this in the README. - -### 6.7 `skin` `[bearer] [scope:patients]` - -"Cildimde Neyim Var" — AI skin-lesion analysis. Submit one or more skin photos; each is -classified (lesion `label`), given a patient-friendly Turkish AI `comment`, image-quality -flags, a `confidence`, possible ICD hints and an opaque `case_detail` blob. - -| Canonical | Method | Path | Body | -|-----------|--------|------------------------|------| -| `analyze` | POST | `/patients/imageCheck` | `images[]` — each item `{ image (base64, req), branch_id? }` | - -Request: `{ "images": [ { "image": "", "branch_id"?: } ] }`. `image` is a -base64-encoded JPEG/PNG/WebP/HEIC (a `data:…;base64,` prefix is accepted). `branch_id` -optionally tags the stored media with a clinic branch. Mirrors `measures.addList` — a -loose array of records. - -Response `data`: `{ status: [ { id, isClear, isBright, label, comment, confidence, image, error, possible_icd, case_detail } ] }` — one entry per submitted image, `id` = 1-based index: -- `label` — lesion class from the classifier (may be empty). -- `comment` — patient-friendly Turkish AI summary. -- `isClear` / `isBright` — image-quality flags. -- `confidence` — classifier confidence (0–1) or null. -- `image` — stored media relative path. -- `possible_icd` — candidate ICD code(s) or null. -- `case_detail` — opaque base64-encrypted blob identifying the saved case (§8.2); can be forwarded verbatim as a payment's `caseDetail`. -- `error` — per-image error message or null. - -The SDK returns `data` verbatim (a mostly-untyped map) and never decrypts `case_detail`. -On a gateway failure the API still returns `status` entries with empty label/comment, so -callers should treat all fields as optional. - -### 6.8 `meals` `[bearer] [scope:patients]` - -AI meal-photo calorie/nutrition estimation — sibling of `skin` (same controller, different -domain). - -| Canonical | Method | Path | Body | -|-----------|--------|------------------------------|------| -| `analyze` | POST | `/patients/imageAnalyzeMeal` | `image` (base64, req), `portionSize` (req), `portionGrams?`, `mealType` (req), `note?` | - -The SDK input names map to the API's snake_case body -`{ image, portion_size, portion_grams?, meal_type, note? }` (like `payments.pay`, a typed -single input): -- `portion_size` ∈ `small | medium | large | custom` (required). -- `portion_grams` — required only when `portion_size` is `custom`. -- `meal_type` ∈ `breakfast | lunch | dinner | snack` (required). -- `note` — optional free text (≤1000 chars); the model reads Turkish preparation/portion modifiers. - -Response `data`: `{ status: { comment: "" } }` — `comment` is the model's -nutrition breakdown (a JSON-object string, per the server prompt); the SDK returns it -verbatim. - -### 6.9 `laboratory` `[bearer] [scope:patients]` - -The patient's own laboratory results, the orderable test catalog, and test pre-ordering. -The controller (`v3\General\Laboratory`) is `@hideFromAPIDocumentation`, so this group is -hand-written from the API source, not the auto-docs. - -| Canonical | Method | Path | Body / params | -|-----------------|--------|-----------------------------------------------|---------------| -| `results` | GET | `/patients/userLabTestList/{page?}` | optional `page` (default 1). The patient's completed/in-progress lab results. | -| `resultDetail` | GET | `/patients/userLabTestDetail/{testId}` | path `testId` (**string**) — pass the id from a `results` item verbatim. | -| `catalog` | GET | `/patients/allLaboratoryTests` | — (orderable test-group catalog). | -| `catalogDetail` | GET | `/patients/laboratoryTestDetail/{id}` | path `id` (numeric) — one catalog group. | -| `order` | POST | `/patients/addNewLaboratoryTest` | `testId` (numeric, req), `addressId` (numeric, req), `laboratoryId` (numeric, req). | - -- `results` `data`: `{ foundTestsCount, foundTests: [ { id, created_at, company_name, test_name, test_state, test_state_text, test_type, test_type_text } ] }`. `test_state` 0=Numune Alınıyor, 1=Çalışıyor, 2=Onaylandı; `test_type` 1=Normal, 2=Grup, 3=Alt Parametre. The list also unions in TMC-lab-ordered tests whose `id` carries a `-lab` suffix (e.g. `"4821-lab"`). -- `resultDetail`: `{testId}` may be a plain id (`"123"`, DB path) or `"-lab"` (TMC-lab path). `data` fields: `test_name, protocol_no, id, created_at, company_name, result, result_unit, test_state, test_state_text, test_type, test_type_text, result_type_text, sub_tests`. For `test_type == 1` the payload adds `normal_lower_limit, normal_upper_limit, panic_lower_limit, panic_upper_limit` (age/gender-aware). For `test_type == 2`, `sub_tests[]` carries per-parameter results plus those four limit fields. -- `catalog` returns `test_groups[]`: each `{ id, name, image, background, desc (HTML), tests[]{id,name}, laboratories[]{ company_id, name, doctor_id, branch_id, prices{ real_price, discount_rate, discounted_price, discount_code, discount_title, discount_id }, cities[]{id,name} } }`. Served from `config/laboratory.php`, not the DB. -- `catalogDetail` returns the single matching group; for `pat` users the per-laboratory `prices` are recomputed through the discount provider. -- `order` success → `data: { preOrderId }`. Validated against the catalog (`test_not_found`, `laboratory_not_found`), the user address (`user_address_not_found`, `invalid_user_address_for_lab` — the address city must be served by the lab), and duplicate open orders. Business failures return HTTP 501. -- **Not exposed:** the deprecated `POST /patients/addNewLabTest` (superseded by `order`), and the `results` endpoint's optional `companyId` query filter (SDKs use path params only, and that server-side filter is known-buggy). - -### 6.10 `diets` `[bearer] [scope:patients]` - -The patient's diet lists (a dietitian's "Diyet Listesi"). Controller `v3\General\Diets`, -`@hideFromAPIDocumentation`. JSON only — the server's PDF export (`dietFile`) is out of SDK scope -(it returns a binary `application/pdf`, not the envelope). - -| Canonical | Method | Path | Body / params | -|-----------|--------|-----------------------------------|---------------| -| `list` | GET | `/patients/dietLists/{page?}` | optional `page` (default 1). Page size is fixed to 10 server-side. | -| `detail` | GET | `/patients/diet/{listId}` | path `listId` (numeric) = a `list_id` from a `list` item. | - -- `list` `data`: `{ foundDietsCount, foundDiets: [ { list_id, diet_date, protocol_no, patient_name, patient_surname, patient_birthdate, patient_identity_no, doctor_company_name, doctor_name, doctor_surname, doctor_title, doctor_branch_name, doctor_image } ] }`. One entry per diet-program group; `list_id` feeds `detail`. -- `detail` `data`: an **array of meal-time groups** `[ { time, meals: [ { meal_time, total_calories, protocol_no, patient_*, doctor_company_name, diet_date, doctor_name, doctor_surname, doctor_title, doctor_image, doctor_certified_number, doctor_branch_name, meal_details: [ { quantity, explanation, meal_name, kcal, unit } ] } ] } ]`. An empty diet returns HTTP 501. - -### 6.11 `addresses` `[bearer] [scope:patients]` - -The patient's saved addresses. **Required by `laboratory.order`**, whose `addressId` -must reference one of these (and whose `city_id` must be in the lab's served cities). -All four verbs are the same path `/patients/userAddress` (distinguished by method). - -| Canonical | Method | Path | Body / params | -|-----------|--------|--------------------------|---------------| -| `list` | GET | `/patients/userAddress` | — | -| `add` | POST | `/patients/userAddress` | `title` (req), `cityId` (req, numeric), `districtId` (req, numeric), `address` (req), `locationLat` (req), `locationLng` (req), `description?`, `isDefault?` (0\|1) | -| `update` | PUT | `/patients/userAddress` | `id` (req); `title`/`cityId`/`districtId`/`address`/`locationLat`/`locationLng` are `required_without:isDefault`; `description?`, `isDefault?` | -| `delete` | DELETE | `/patients/userAddress` | `id` (req, in the **body** — not a path segment) | - -- `list` → a bare array (default first): `{ id, title, description, city_id, district_id, address, location_lat, location_lng, is_default, distinct_name }`. **`id` is the `addressId`** used by `update`/`delete`/`laboratory.order`. Returns 501 when the patient has no addresses (treat as "empty"). Only the district name is joined — map `city_id`→name via `doctors.locations`. -- `add` → `data: { addressId }`. The first address is forced default; setting `isDefault: 1` demotes the previous default. -- `update`/`delete` → message only (no `data`). The **default address cannot be deleted** (reassign via `update` first), nor can an address already used on an order. -- `cityId` comes from `doctors.locations` (`location_id`); `districtId` comes from `GET /getConfig` (`cities[].districts[].district_id`), reachable via the §7.2 escape hatch. +Value rules: numeric; `tension`/`pulse` digits 1–10; `glucose` 0–99999.99 + +`glucose_type` 0\|1; `weight`/`length` 0–99999.99; etc. + +`last` returns the most recent of each type (tension splits into +hypertension/hypotension; glucose into `hunger_glucose`/`postprandial_glucose`), +each with a `*Date`. + +> **`healthInformation` is the odd one out.** It predates the `patient{}` +> contract, needs the `teusan` scope instead of `apiouther`, and takes a flat +> `identity` + `phoneNumber`. Prefer `addList` for new integrations. +> +> **Its patient matching is an OR, and it is loose.** The lookup is +> `WHERE identity = … OR phone_number = …` against the **global** user table, +> taking the first row (`PatientUsersModel::patientUserFindWithOr`). A phone +> number alone can therefore resolve a person whose TCKN differs from the one you +> sent. Send both fields, but do not assume they are checked as a pair. This is +> the exact opposite of the `apiouther` reads in this group, which scope to your +> own company and fail closed on ambiguity (§8.1) — another reason to prefer +> `addList`. +> +> (Spec 0.x documented a defect here that nulled `identity` outright before it +> reached the lookup. That was fixed API-side on 2026-07-21; the OR remains.) + +### 6.5 `laboratory` `[scope:apiouther]` + +| Canonical | Method | Path | Body / params | +|-----------------|--------|---------------------------------------|---------------| +| `catalog` | GET | `/outher/laboratoryCatalog` | — | +| `catalogDetail` | GET | `/outher/laboratoryCatalog/{testId}` | path `testId` (req, numeric) | +| `results` | POST | `/outher/laboratoryResults` | `patient{}` = patientRef, `currentPage` (≥1) | +| `resultDetail` | POST | `/outher/laboratoryResult` | `patient{}` = patientRef, `testId` (req) | + +- `catalog` / `catalogDetail` are the **global orderable-test catalogue** — static + data, no patient and no company scoping. +- `resultDetail.testId` must be passed back **exactly** as `results` returned it: + a plain number is an HBYS lab request, a `-lab` suffix marks a TmcLab order + group (server pattern: `/^\d+(-lab)?$/`). The SDK does not parse or normalise it. +- Ordering a test is **not** available to partners (it creates a financial record). + +### 6.6 `diets` `[scope:apiouther]` + +| Canonical | Method | Path | Body | +|-----------|--------|----------------------|------| +| `list` | POST | `/outher/dietLists` | `patient{}` = patientRef, `currentPage` (≥1) | +| `detail` | POST | `/outher/diet` | `patient{}` = patientRef, `listId` (req, numeric) | + +- `list` → `{ foundDietsCount, foundDiets: [ { list_id, diet_date, protocol_no, patient_*, doctor_* } ] }`. + Page size is fixed to 20 server-side. `list_id` feeds `detail`. +- `detail` → an **array of meal-time groups** + `[ { time, meals: [ { meal_time, total_calories, …, meal_details: [ { quantity, explanation, meal_name, kcal, unit } ] } ] } ]`. + A `listId` that is not this patient's returns `501` with the generic message. --- ## 7. Naming conventions & API shape The client is a single root object exposing one accessor per service group; each -group exposes the canonical methods above. +group exposes the canonical methods above. **There is no namespace prefix** — the +partner surface *is* the surface. ``` -client.auth.connect(...) client.payments.pay(...) client.doctors.search(...) client.measures.addList(...) -client.slots.schedule(...) client.appointments.reserveInterview(...) -client.skin.analyze(...) client.meals.analyze(...) -client.laboratory.results(...) client.diets.list(...) +client.slots.schedule(...) client.laboratory.results(...) +client.appointments.reserve(...) client.diets.list(...) ``` Per-language casing & idioms: | Language | Method case | Notes | |----------|-------------|-------| -| JS/TS | `camelCase` | `client.doctors.quickSearch()`. Promise-based. | -| PHP | `camelCase` | `$client->doctors->quickSearch()`. Namespace `Bulutklinik\Sdk`. | -| Python | `snake_case`| `client.doctors.quick_search()`. Sync **and** async (`AsyncClient`). | -| Go | `PascalCase`| `client.Doctors.QuickSearch(ctx, …)`. Context-first, `(T, error)` returns. | -| Java | `camelCase` | `client.doctors().quickSearch(…)`. Builder for config; checked vs unchecked TBD in Faz 3. | -| C# | `PascalCase`+`Async` | `client.Doctors.QuickSearchAsync(…)`. `Task`, `CancellationToken`. | -| C++ | `snake_case`| `client.doctors().quick_search(…)`. Namespace `bulutklinik`. cpr + nlohmann/json. | +| JS/TS | `camelCase` | `client.doctors.search()`. Promise-based. | +| PHP | `camelCase` | `$client->doctors->search()`. Namespace `Bulutklinik\Sdk`. | +| Python | `snake_case`| `client.doctors.search()`. Sync **and** async (`AsyncClient`). | +| Go | `PascalCase`| `client.Doctors.Search(ctx, …)`. Context-first, `(T, error)` returns. | +| Java | `camelCase` | `client.doctors().search(…)`. Builder for config. | +| C# | `PascalCase`+`Async` | `client.Doctors.SearchAsync(…)`. `Task`, `CancellationToken`. | +| C++ | `snake_case`| `client.doctors().search(…)`. Namespace `bulutklinik`. cpr + nlohmann/json. | Request inputs are typed structures (objects/records/structs) per language; responses are typed where practical, otherwise a typed envelope + parsed `data`. +Where a method name would collide with a language keyword, the language's own +escape applies — e.g. C++ `measures.delete_measure(...)`, since `delete` is +reserved. + ### 7.1 Client configuration | Option | Default | Purpose | |---------------|----------------|----------------------------------------------------| -| `environment` / `baseUrl` | `production` | Named preset or explicit URL. | +| `environment` | `production` | Named preset (`production` \| `test` \| `local`). | +| `apiVersion` | `v3` | `v3` \| `v4`. Combined with `environment` to build the base URL. | +| `baseUrl` | — | Explicit URL; overrides `environment` + `apiVersion`. | | `lang` | `tr` | Default `lang` header; overridable per request. | -| `clientId` / `clientSecret` | — | Needed for `refresh` (and passed by `connect`). | -| `tokenStore` | in-memory | Pluggable persistence. | +| `partnerToken`| — | The partner token. Seeds the default in-memory store. | +| `tokenStore` | in-memory | Pluggable token source (§5.2). Mutually exclusive with `partnerToken`. | | `timeout` | sane default | Request timeout. | | `httpClient` | platform default | Injectable transport (PSR-18, http.Client, HttpClient, etc.). | +`clientId` / `clientSecret` are **gone** — they existed only for the patient +password and refresh grants. + ### 7.2 Escape hatch — arbitrary requests Not every endpoint has a typed resource method, and the API grows faster than the SDK surface. Every SDK therefore exposes **one generic request method on the root client** for calling any Bulutklinik API endpoint directly. It is not a separate HTTP client: it reuses the same transport, so default headers, the chosen auth -mode, silent token refresh + retry (§5.4), envelope unwrapping (§3) and the typed -error hierarchy (§4) all still apply. +mode, envelope unwrapping (§3) and the typed error hierarchy (§4) all still apply. Concept: @@ -621,65 +514,69 @@ client.request(method, path, { auth, body, lang }) -> data | Param | Notes | |----------|-------| | `method` | `GET` \| `POST` \| `PUT` \| `DELETE`. | -| `path` | Relative to the configured base URL, e.g. `/patients/allBranches`. Leading slash included. | -| `auth` | `public` \| `bearer` (**default**) \| `partner`. Accepted as a string or an existing public enum/const per language. | +| `path` | Relative to the configured base URL, e.g. `/outher/branches`. Leading slash included. | +| `auth` | `partner` (**default**) \| `public`. Accepted as a string or an existing public enum/const per language. | | `body` | Optional JSON payload (object/map/dict). Omitted on `GET`. | | `lang` | Optional per-request `lang` override, where the SDK's transport supports one (JS, PHP, Go, C++). Python / Java / C# apply the client-level `lang`. | -Returns the unwrapped `data` payload as the language's raw JSON value (the same -type a future typed resource method would parse from), and raises the same typed -errors on failure. Representative per-language signatures (idiomatic, return the -raw `data`): +Returns the unwrapped `data` payload as the language's raw JSON value, and raises +the same typed errors on failure. Representative per-language signatures: | Language | Signature | |----------|-----------| | JS/TS | `client.request({ method, path, auth?, body?, lang? }): Promise` | -| Python | `client.request(method, path, *, auth="bearer", body=None)` — plus the async client | -| PHP | `$client->request(string $method, string $path, string $auth = 'bearer', ?array $body = null, ?string $lang = null): mixed` | -| Go | `client.Do(ctx, method, path, *bk.RequestOptions) (json.RawMessage, error)` (nil options ⇒ bearer) | +| Python | `client.request(method, path, *, auth="partner", body=None)` — plus the async client | +| PHP | `$client->request(string $method, string $path, string $auth = 'partner', ?array $body = null, ?string $lang = null): mixed` | +| Go | `client.Do(ctx, method, path, *bk.RequestOptions) (json.RawMessage, error)` (nil options ⇒ partner) | | Java | `client.request(String method, String path, String auth, Object body)` → `JsonNode` | -| C# | `client.RequestAsync(HttpMethod method, string path, string auth = "bearer", object? body = null, CancellationToken = default)` → `JsonElement` | +| C# | `client.RequestAsync(HttpMethod method, string path, string auth = "partner", object? body = null, CancellationToken = default)` → `JsonElement` | | C++ | `client.request(method, path, bulutklinik::RequestOptions{})` → `nlohmann::json` | -This is the supported extension point for endpoints outside the 29 in §6. Prefer a -typed resource method when one exists; reach for `request` only for the gaps. +`auth: "public"` exists for the handful of unauthenticated endpoints outside §6 +that an integration may still need — e.g. `GET /general/getConfig` for the +`cities[].districts[]` list. Prefer a typed resource method when one exists. --- ## 8. Special cases -### 8.1 `payment3DUrl` (3-D Secure) — passthrough +### 8.1 How a patient reference resolves + +This is the single most important behaviour on the partner surface, and the two +paths are deliberately asymmetric. + +**Read path** (`patientRef` — `measures.last`/`list`/`graph`/`update`/`delete`, +`laboratory.results`/`resultDetail`, `diets.list`/`detail`): -`pay` success response: `{ resultType: 0, data: { payment3DUrl: "" } }`. -`payment3DUrl` is a **browser URL** the SDK returns verbatim — it is one of: - (A) the bank's direct `URL_3DS`, or - (B) `{APP_URL}/api/v3/payments/threeDUrl/` (our endpoint serving the 3DS HTML form). +1. Searches **only** `pat_patients` rows belonging to your company. It never + consults the global user table and **never creates** anything. +2. `identityNumber` (TCKN) is the primary selector. +3. `phoneNumber` is the fallback — used when no TCKN was sent, **or** when the + TCKN missed (a patient you created without a TCKN is findable once you learn + it). It is accepted only when it matches **exactly one** row; the column is not + unique, since family members share numbers. Two matches fail closed. +4. When the fallback fires and the matched row has a *different* non-empty + `identity_number`, the request is rejected — that is a different person. +5. Not found → `501` with the same generic message as "not yours" (§3.2). -The SDK **does not** open, follow, or parse it. 3DS completion ("provizyon -kapatma" / capture) happens browser↔bank↔server via the -`POST /api/v3/threeD/appointmentPaymentComplete/{trxId}/{driver}` callback -(`trxId = "{orderId}.{transactionUuid}.{processId}"`) — outside SDK scope. -If `is3D = false`, `data` is the inline-completed order result (no `payment3DUrl`). +**Write path** (`bookingUser` — every booking call, `measures.addList`/`add`): -### 8.2 Encrypted blobs — passthrough +1. Looks the person up globally by TCKN/phone and creates a password-less shadow + user if absent. +2. Find-or-creates the `pat_patients` row **in your company**. +3. This is why the descriptive fields (`name`, `surname`, `phoneNumber`) are + required here and absent from the read shape. -`connect`'s `data.response` (2FA), `register`'s `response`, `caseDetail`, and the -`case_detail` returned by `skin.analyze` are opaque encrypted blobs. The SDK passes -them through verbatim and never encrypts or decrypts — a `skin.analyze` `case_detail` -may be forwarded unchanged as a payment's `caseDetail`. The clinic/API encryption keys -are never embedded in the SDK. +The company boundary always comes from the authenticated token, never from +request input. There is no parameter that lets a partner read another company's +data — including no `companyId` field to send. -### 8.3 Public vs bearer vs partner +### 8.2 Opaque values — passthrough -- Public (no `Authorization`): `connect`, `connectWithTwoFactor`, `refresh`, `register`, - `confirmRegistrationEmail`, `verifyRegistrationSocial`, `registerSocial`, - `forgotPassword`, `resetPassword`. (`forgotPassword` still requires a browser CAPTCHA - token; the social pair does not.) -- Bearer (access token): everything else (incl. `appointments.*` and the `addresses` group). -- Partner: `partnerHealthInformation` (`scope:teusan`) and `verifyRegistration` - (`auth:apiusers`, no specific scope) use the separately-configured partner token, - not the patient access token. **Only these two** need the partner token — the social - registration verify step is public, unlike the non-social `verifyRegistration`. +`reserve`'s `hash`, the `url` it returns, and `outherProcessId` are server-issued +values. The SDK passes them through verbatim: it never decodes, re-encodes, +shortens or follows them. The clinic/API encryption keys are never embedded in +the SDK. --- @@ -689,13 +586,13 @@ are never embedded in the SDK. 2. **Minimal dependencies** — prefer the platform HTTP client; pin the documented stack per language (see §7 / PLAN.md). 3. **Typed** — public API and `data` payloads typed where the language supports it. -4. **Auto-refresh + retry** per §5.4, concurrency-safe. +4. **Fail fast on a missing token** — raise before dispatching (§5.2). 5. **Pluggable** token store and HTTP client. 6. **Errors** per §4 with full context. -7. **Tested** — unit tests for envelope/error/refresh logic + at least one live - smoke path against `test` env (Faz 1–2). -8. **Examples** — `examples/` with the end-to-end flow: login → search → slot → - reserve → (pay) and a measures example. +7. **Tested** — unit tests for envelope/error/auth/config logic + at least one live + smoke path against `test`. +8. **Examples** — `examples/` with the end-to-end partner flow: check doctor → + slots → reserve → create, and a measures read/write example. 9. **Self-contained repo** — README, LICENSE (MIT), DESIGN.md copy, CI. 10. **Versioning** — semver; tag `vX.Y.Z` per repo. @@ -703,22 +600,15 @@ are never embedded in the SDK. ## 10. Live validation reference (test env) -- Base: `https://apitest.bulutklinik.com/api/v3` -- OAuth client: `Patients_Web_Mobile` — id `96b630b3-f62a-4e67-b33c-b58802dca5af` (secret in the collection / env file). -- Test patient: `hackathon@bulutklinik.test` (`loginMode: email`). -- Bookable `doctorId` examples: `8282` (interview + physical), `168896` (interview). -- Known env limits (request is correct, server/env is the cause): - - `quickSearch` returns HTTP 404 / `resultType 1` on `test` — the search driver - (Elasticsearch) is unavailable there; the controller catches only - `QueryException` so other exceptions surface as a generic 404. `filteredSearch` - (`doctors.search`) works and is the production search path. - - `interviewPayment` may 404 if POS isn't configured for the company; 3DS capture - can't run from a non-browser client. SDK validation asserts the request shape + - `payment3DUrl` return, not the bank capture. -- TS reference live result (2026-06-17, `test`): 8/9 steps OK — `auth.connect`, - `doctors.branches` (136), `doctors.locations` (81), `doctors.search`, - `doctors.detail`, `slots.schedule`, `measures.last`, `auth.disconnect` all pass; - only `quickSearch` fails for the env reason above. +- Base: `https://apitest.bulutklinik.com/api/v3` (or `/v4`). +- Auth: a partner token issued for a test company with the `apiouther` scope. + Unlike the patient surface there is no shared test credential in the Postman + collection — the token is per-integration. +- Smoke path that needs no patient data: `doctors.branches` → `doctors.locations` + → `laboratory.catalog`. All three are `GET`, scope-gated only, and prove the + token and base URL are right. +- Patient-scoped reads need a patient that exists **in the token's company**; + a TCKN that works on the patient surface will not necessarily resolve here. --- @@ -731,3 +621,43 @@ This file is canonical. When it changes: If an SDK must diverge from this spec, fix the spec first (or record the divergence here) — code and SSOT must never silently disagree. + +--- + +## 12. Migration from 0.6.x + +0.6.x shipped two personas: a patient surface at the client root and a partner +surface under `client.partner.*`. 1.0.0 keeps **only** the partner one and lifts +it to the root. + +### 12.1 Mechanical rename + +| 0.6.x | 1.0.0 | +|-------|-------| +| `client.partner.doctors.*` | `client.doctors.*` | +| `client.partner.slots.*` | `client.slots.*` | +| `client.partner.appointments.*` | `client.appointments.*` | +| `client.partner.measures.*` | `client.measures.*` | +| `client.partner.laboratory.*` | `client.laboratory.*` | +| `client.partner.diets.*` | `client.diets.*` | + +Behaviour, paths and payloads of these 28 methods are unchanged. + +### 12.2 Removed with no replacement + +`auth` (all 11 methods) · `payments` (5) · `skin` · `meals` · `addresses` (4) · +and the patient-persona `doctors`/`slots`/`appointments`/`measures`/`laboratory`/`diets` +that lived at the root in 0.6.x. §1.2 explains why each has no partner +equivalent. An application that needs a patient session must talk to the API +directly; the SDK no longer models it. + +### 12.3 Configuration + +| 0.6.x | 1.0.0 | +|-------|-------| +| `clientId`, `clientSecret` | removed | +| `partnerToken` (optional, for 2 endpoints) | **required** credential for the whole client | +| token store held `accessToken` + `refreshToken` | holds one partner token | +| silent refresh + retry on 401/`resultType 4` | removed — `AuthenticationError`, no retry (§5.3) | +| base URL fixed at `/api/v3` | `/api/v3` or `/api/v4` via `apiVersion` | +| escape hatch `auth` default `bearer` | default `partner`; `bearer` no longer exists | diff --git a/README.md b/README.md index 604a794..0a5e26a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ # bulutklinik-sdk -Official Bulutklinik API SDK for Python. Sync **and** async (httpx), fully typed -(`py.typed`), Python 3.9+. +Official Bulutklinik **partner** API SDK for Python. Sync **and** async (httpx), +fully typed (`py.typed`), Python 3.10+. -Covers the patient flow: **auth, doctor search, slots, appointments, payments, -and health measures**. See [`DESIGN.md`](./DESIGN.md) for the full wire contract. +This is a single-persona SDK: every call runs on the company-scoped `/outher` +surface with the partner token issued for your integration. You act on the +patients of **your own company**, and the patient is named inline on each +request — there is no login and no session. See [`DESIGN.md`](./DESIGN.md) for +the full wire contract. + +> **1.0.0 is a breaking release.** The patient persona (login, registration, +> payments, AI analysis, address book) has been removed and the former +> `client.partner.*` namespace was lifted to the client root. See +> [CHANGELOG.md](./CHANGELOG.md) and DESIGN.md §12 for the migration. ## Install @@ -19,26 +27,30 @@ from bulutklinik import BulutklinikClient with BulutklinikClient( environment="production", # "production" | "test" | "local" - client_id="…", - client_secret="…", + api_version="v3", # "v3" (default) | "v4" + partner_token="…", ) as client: - # 1) Log in (tokens are stored automatically) - login = client.auth.connect("patient@example.com", "•••••••", "email") - - if login.two_factor_required: - client.auth.connect_with_two_factor("123456", login.two_factor_response) - - # 2) Find a doctor + # 1) Find a doctor you can book result = client.doctors.search( search_params={"withFreeText": "kardiyoloji"}, order_params=["slot"], - other_params=["isInterviewable"], ) - - # 3) Slots, then 4) reserve ("YYYY-MM-DD HH:mm") doctor_id = result["foundDoctors"][0]["doctor_id"] - slots = client.slots.schedule(doctor_id, "interview") - client.appointments.reserve_interview(doctor_id, "2026-06-20 14:30") + + # 2) Free slots + schedule = client.slots.schedule(doctor_id, schedule_date="2026-08-01") + slot = next(iter(schedule.values()))[0] + + # 3) Hold it for a patient — named inline, no session + held = client.appointments.reserve( + slot["slotId"], + doctor_id, + {"name": "Ada", "surname": "Lovelace", "phoneNumber": "+905551112233"}, + without_agreement=True, + ) + + # 4) Confirm before held["reservationExpired"] passes + client.appointments.create(held["hash"], held["outherProcessId"]) ``` ## Quick start (async) @@ -46,96 +58,183 @@ with BulutklinikClient( ```python from bulutklinik import AsyncBulutklinikClient -async with AsyncBulutklinikClient(environment="production", client_id="…", client_secret="…") as client: - await client.auth.connect("patient@example.com", "•••••••", "email") +async with AsyncBulutklinikClient(environment="production", partner_token="…") as client: result = await client.doctors.search(search_params={"withFreeText": "kardiyoloji"}) ``` ## Services -| Group | Methods | -|-------------------------|---------| -| `client.auth` | `connect`, `connect_with_two_factor`, `verify_registration`, `confirm_registration_email`, `register`, `verify_registration_social`, `register_social`, `forgot_password`, `reset_password`, `refresh`, `disconnect` | -| `client.doctors` | `branches`, `locations`, `quick_search`, `search`, `detail` | -| `client.slots` | `schedule` | -| `client.appointments` | `reserve_interview`, `add_physical`, `cancel`, `list`, `reservations` | -| `client.payments` | `check_discount_code`, `get_cards`, `save_card`, `pay`, `delete_card` | -| `client.measures` | `add_list`, `add`, `update`, `delete`, `last`, `list`, `graph`, `partner_health_information` | -| `client.skin` | `analyze` | -| `client.meals` | `analyze` | -| `client.laboratory` | `results`, `result_detail`, `catalog`, `catalog_detail`, `order` | -| `client.diets` | `list`, `detail` | -| `client.addresses` | `list`, `add`, `update`, `delete` | +28 endpoints across six groups. The async client exposes the same methods +(awaitable) under the same names. + +| Group | Methods | +|-----------------------|---------| +| `client.doctors` | `search`, `branches`, `detail`, `locations` | +| `client.slots` | `schedule` | +| `client.appointments` | `reserve`, `instant_reserve`, `create`, `create_without_slot`, `cancel_without_slot`, `list`, `info`, `check_doctor` | +| `client.measures` | `last`, `list`, `graph`, `add_list`, `add`, `update`, `delete`, `health_information` | +| `client.laboratory` | `catalog`, `catalog_detail`, `results`, `result_detail` | +| `client.diets` | `list`, `detail` | + +`appointments.reserve(..., without_agreement=True)` covers the second reservation +endpoint, so the nine documented appointment endpoints map to eight methods. + +## Naming a patient + +There is no session, so every patient-scoped call carries the patient in its +body — never in the URL, since a TCKN in a path segment would land in access +logs, proxy logs and error breadcrumbs. + +**Reads** take a light reference. The server looks only inside your own company +and never creates anything: + +```python +client.measures.last({"identityNumber": "12345678901"}) +client.diets.list({"phoneNumber": "+905551112233"}) +``` + +`identityNumber` is primary; `phoneNumber` is a fallback accepted only when it +matches exactly one patient (the column is not unique — family members share +numbers). A patient you have never treated resolves to "not found", with the same +message as "not yours" so the endpoint cannot be used to probe for TCKNs. + +**Writes** take the descriptive shape, because the patient is created inside your +company if absent: + +```python +client.measures.add_list( + {"name": "Ada", "surname": "Lovelace", "phoneNumber": "+905551112233"}, + [{"type": "pulse", "date_time": "2026-06-17 09:31", "pulse": 72}], +) +``` + +## Booking -The async client exposes the same methods (awaitable) under the same names. +Two flows, depending on who collects the agreements and the payment: -## Authentication & tokens +```python +# (A) Hand off to the patient — returns a browser `url` for agreements + payment. +held = client.appointments.reserve(slot_id, doctor_id, user) +print(held["url"]) -- `connect` / `connect_with_two_factor` / `register` store the access + refresh - tokens automatically. -- On a `401` (or `resultType 4`), the SDK silently refreshes once and retries. -- Provide a custom token store by implementing `bulutklinik.TokenStore` and - passing it via `token_store=…`. +# (B) You already collected them — returns a `hash` to confirm yourself. +held = client.appointments.reserve(slot_id, doctor_id, user, without_agreement=True) +client.appointments.create(held["hash"], outher_process_id) +``` -## Payments (3-D Secure) +**Payment is never taken through the API.** No partner endpoint produces a +financial record; the browser hand-off in (A) is where payment happens. The SDK +returns `url` verbatim and never opens or follows it. -`payments.pay(...)` returns a dict with `payment3DUrl` on a 3DS flow. Open that URL -in a browser; the bank → server callback completes the capture. The SDK never -opens or parses the URL. +`create_without_slot` books a free-form range outside the slot grid, for +integrations running their own calendar; `cancel_without_slot` reverses it — and +only it. + +## Authentication + +The partner token is **issued out of band** through the Bulutklinik Developer +Platform. It behaves like an API key: there is no login method, and the SDK +cannot renew it. + +```python +client = BulutklinikClient(partner_token="…") +``` + +The token is read from a token store on **every** request, so a long-running +process can pick up a newly issued one without being rebuilt. Implement +`bulutklinik.TokenStore` and pass it via `token_store=…`: + +```python +class VaultTokenStore: + def get_token(self) -> str | None: ... + def set_token(self, token: str | None) -> None: ... + def clear(self) -> None: ... + +client = BulutklinikClient(token_store=VaultTokenStore()) + +# …or rotate the default in-memory store in place: +client.token_store.set_token(newly_issued_token) +``` + +Pass `partner_token` **or** `token_store`, not both — the constructor raises +`ValueError` rather than guessing which one you meant. + +### When the token expires + +Tokens last about 30 days. An expired one comes back as `401` / `resultType 4`; +the SDK raises `AuthenticationError` and does **not** retry — there is nothing to +refresh. Recovery is operational: obtain a newly issued token and write it into +the store. + +> This is the one behaviour that changed meaning in 1.0.0. On the patient SDK +> `resultType 4` meant "the SDK will fix this silently". Here it means the opposite. + +An `AuthorizationError` (403) means the credential itself is wrong — either the +token lacks the `apiouther` scope, or it resolves to a user with no company. The +company boundary comes from the token, never from request input, so retrying with +different body parameters will not help. ## Health measures ```python -client.measures.add_list([ +ref = {"identityNumber": "12345678901"} + +# Write several measurements at once (max 200 per call, one transaction) +client.measures.add_list(patient, [ {"type": "tension", "date_time": "2026-06-17 09:30", "hypertension": 120, "hypotension": 80}, {"type": "glucose", "date_time": "2026-06-17 09:35", "glucose": 95, "glucose_type": 0}, ]) -client.measures.last() -client.measures.list("glucose", 1, 0) # glucose_type 0=fasting, 1=postprandial -client.measures.graph("tension", 2, 1) # period 2 = weekly +client.measures.last(ref) +client.measures.list(ref, "glucose", 1, 0) # glucose_type 0=fasting, 1=postprandial +client.measures.graph(ref, "tension", 2) # period 2 = weekly ``` -> The partner endpoint (`partner_health_information`) uses `partner_token` from -> the client config. The API currently matches the patient by `phone_number`; -> pass both `identity` and `phone_number` for forward compatibility. +> Measurements are written to **your own company**. A value you write does not +> appear in the patient's Bulutklinik mobile app, and values they entered there +> are not visible to you. That is tenant isolation working as intended. -## AI image analysis - -```python -# "Cildimde Neyim Var" — analyze one or more skin photos (base64) -result = client.skin.analyze([{"image": b64}]) -for s in result["status"]: - print(s["label"], s["comment"], s["possible_icd"]) - # s["case_detail"] can be forwarded verbatim as a payment's case_detail - -# Meal photo → calorie/nutrition estimate -meal = client.meals.analyze( - image=b64, - portion_size="medium", # small | medium | large | custom - meal_type="lunch", # breakfast | lunch | dinner | snack - # portion_grams=300, # required when portion_size is "custom" - # note="az yağlı", -) -print(meal["status"]["comment"]) -``` +`measures.health_information` is the legacy `teusan` bulk endpoint, kept for +existing integrations: it needs the `teusan` scope instead of `apiouther`, takes +a flat `identity` + `phone_number` instead of `patient`, and writes into the +shared consumer tenant. Its patient matching is an **OR**, and it is loose: the lookup is +`identity OR phoneNumber` against the *global* user table and takes the first +row, so a phone number alone can resolve someone whose TCKN differs from the one +you sent. Send both, but do not assume they are checked as a pair — the +`apiouther` reads above do the opposite, scoping to your company and failing +closed on ambiguity. Prefer `add_list` for anything new. ## Laboratory & diets ```python -# Lab results (page optional; omit for page 1). Result ids may carry a "-lab" suffix. -results = client.laboratory.results() # or .results(2) -detail = client.laboratory.result_detail("4821-lab") +ref = {"identityNumber": "12345678901"} -# Orderable test catalog, then pre-order (all three ids required) +# Global, static catalogue — no patient context catalog = client.laboratory.catalog() group = client.laboratory.catalog_detail(7) -order = client.laboratory.order(test_id=12, address_id=34, laboratory_id=56) -print(order["preOrderId"]) -# Diet lists written by the dietitian (JSON only) -diets = client.diets.list() # or .list(2) -plan = client.diets.detail(diets["foundDiets"][0]["list_id"]) +# Results for a patient in your company. Ids may carry a "-lab" suffix; pass them back verbatim. +results = client.laboratory.results(ref) # or .results(ref, 2) +detail = client.laboratory.result_detail(ref, "4821-lab") + +# Diet lists written by a dietitian. Page size is fixed to 20 server-side. +diets = client.diets.list(ref) +plan = client.diets.detail(ref, diets["foundDiets"][0]["list_id"]) +``` + +Ordering a lab test is not available to partners — it creates a financial record. + +## Escape hatch + +Not every endpoint has a typed method. `client.request` reuses the same +transport, so headers, envelope unwrapping and typed errors all still apply: + +```python +data = client.request("GET", "/outher/somethingNew") + +# "public" reaches unauthenticated endpoints outside the partner surface, +# e.g. the city/district catalogue that feeds address forms. +config = client.request("GET", "/general/getConfig", auth="public") ``` ## Errors @@ -143,7 +242,7 @@ plan = client.diets.detail(diets["foundDiets"][0]["list_id"]) All errors subclass `bulutklinik.BulutklinikError`: `TransportError` (network) · `ApiError` → `ValidationError` (422), -`AuthenticationError` (401 / logout), `AuthorizationError` (403), +`AuthenticationError` (401 / revoked / expired), `AuthorizationError` (403), `NotFoundError` (404), `RateLimitError` (429, `.retry_after`). Attributes: `http_status`, `result_type`, `error_type`, `data`, `method`, `path`, `retry_after`. @@ -152,13 +251,18 @@ Attributes: `http_status`, `result_type`, `error_type`, `data`, `method`, `path` from bulutklinik import RateLimitError, ValidationError try: - client.payments.pay(doctor_id, "2026-06-20 14:30", is_3d=True, terms_accept=True, card_id=5) + client.measures.last(ref) except RateLimitError as exc: print("retry after", exc.retry_after) except ValidationError as exc: print("invalid:", exc.data) ``` +Note that `/outher` reports most business-rule failures as HTTP **`501`** with +`resultType 1` — "patient not found in your company", "slot no longer free", +"doctor not bookable through your integration". It is not a server crash; read +the message. + ## Development ```bash diff --git a/examples/flow.py b/examples/flow.py index 7bc1edf..c2bad73 100644 --- a/examples/flow.py +++ b/examples/flow.py @@ -1,7 +1,8 @@ -"""End-to-end example: login -> search -> slots, plus health measures. +"""End-to-end partner example: check a doctor -> slots -> reserve, plus a +health-measures read/write round trip. -Provide credentials via env: BK_CLIENT_ID, BK_CLIENT_SECRET, BK_USERNAME, -BK_PASSWORD, BK_DOCTOR_ID. Run: python examples/flow.py +Provide credentials via env: BK_PARTNER_TOKEN, BK_DOCTOR_ID, BK_PATIENT_PHONE, +BK_PATIENT_TCKN. Run: python examples/flow.py """ from __future__ import annotations @@ -14,26 +15,45 @@ def main() -> None: with BulutklinikClient( environment="test", - client_id=os.environ.get("BK_CLIENT_ID", ""), - client_secret=os.environ.get("BK_CLIENT_SECRET", ""), + partner_token=os.environ.get("BK_PARTNER_TOKEN", ""), ) as client: - login = client.auth.connect( - os.environ.get("BK_USERNAME", ""), - os.environ.get("BK_PASSWORD", ""), - "email", - ) + # 1. Discovery. Needs no patient data, so it is the fastest way to prove + # the token and base URL are right. + branches = client.doctors.branches() + print("branches:", len(branches)) + + doctor_id = int(os.environ.get("BK_DOCTOR_ID", "8282")) + print("bookable through this integration:", client.appointments.check_doctor(doctor_id, 0)) - if login.two_factor_required: - print("2FA required; call connect_with_two_factor with the SMS code") - print("response =", login.two_factor_response) - return + # 2. Availability. `slotId` from here feeds the reservation. + schedule = client.slots.schedule(doctor_id, schedule_date="2026-08-01") + print("slots:", schedule) - print("quickSearch:", client.doctors.quick_search("kardiyo", "interview")) + # 3. Booking. The patient is named inline — there is no session. + # + # `reserve` alone returns a `url` to hand to the patient for + # agreements and payment. `without_agreement=True` returns a `hash` + # for you to confirm yourself, as below. + user = { + "name": "Ada", + "surname": "Lovelace", + "phoneNumber": os.environ.get("BK_PATIENT_PHONE", "+905551112233"), + "identityNumber": os.environ.get("BK_PATIENT_TCKN"), + } - doctor_id = int(os.environ.get("BK_DOCTOR_ID", "8282")) - print("slots:", client.slots.schedule(doctor_id, "interview")) + first_day = next(iter(schedule.values()), []) + if first_day: + held = client.appointments.reserve( + first_day[0]["slotId"], doctor_id, user, without_agreement=True + ) + print("held until", held["reservationExpired"]) + # `outherProcessId` arrives alongside `hash` in the same response: + # client.appointments.create(held["hash"], outher_process_id) + # 4. Measurements. Writes create the patient in your company if absent; + # reads only ever look inside your company. client.measures.add_list( + user, [ { "type": "tension", @@ -42,11 +62,11 @@ def main() -> None: "hypotension": 80, }, {"type": "pulse", "date_time": "2026-06-17 09:31", "pulse": 72}, - ] + ], ) print("measures submitted") - client.auth.disconnect() + print("latest:", client.measures.last({"phoneNumber": user["phoneNumber"]})) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 3d2849f..05f3737 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "bulutklinik-sdk" -version = "0.6.0" -description = "Official Bulutklinik API SDK for Python" +version = "1.0.1" +description = "Official Bulutklinik partner API SDK for Python" readme = "README.md" requires-python = ">=3.10" license = "MIT" diff --git a/scripts/live_check.py b/scripts/live_check.py index 293d751..318c595 100644 --- a/scripts/live_check.py +++ b/scripts/live_check.py @@ -1,12 +1,19 @@ """Live smoke test against the Bulutklinik test environment (apitest). -Read-only flow; each step is independent. Credentials default to the repo's -Postman collection (test account). Run: python scripts/live_check.py +Read-only flow; each step is independent. Needs a partner token issued for a test +company with the `apiouther` scope: + + BK_PARTNER_TOKEN=... python scripts/live_check.py + +Unlike the patient surface there is no shared test credential — the token is +per-integration. Steps that touch a patient need one that exists inside the +token's own company; set BK_PATIENT_TCKN or BK_PATIENT_PHONE to run them. """ from __future__ import annotations import os +import sys from collections.abc import Callable from typing import Any @@ -14,12 +21,15 @@ def main() -> None: + partner_token = os.environ.get("BK_PARTNER_TOKEN") + if not partner_token: + print("BK_PARTNER_TOKEN is required.", file=sys.stderr) + raise SystemExit(2) + client = BulutklinikClient( environment="test", - client_id=os.environ.get("BK_CLIENT_ID", "96b630b3-f62a-4e67-b33c-b58802dca5af"), - client_secret=os.environ.get( - "BK_CLIENT_SECRET", "KPgmEavOSomEl8mQu1ZZMoyZaVXBSuuKxrrzMAkX" - ), + api_version=os.environ.get("BK_API_VERSION", "v3"), + partner_token=partner_token, ) results: list[tuple[str, bool]] = [] @@ -40,34 +50,22 @@ def step(name: str, fn: Callable[[], Any]) -> Any: results.append((name, False)) return None - login = step( - "auth.connect", - lambda: client.auth.connect( - os.environ.get("BK_USERNAME", "hackathon@bulutklinik.test"), - os.environ.get("BK_PASSWORD", "Hackathon2026"), - "email", - ), - ) - stored = client.token_store.get_access_token() is not None - two_factor = getattr(login, "two_factor_required", None) - print(f" twoFactorRequired={two_factor} accessTokenStored={stored}") - + # --- Scope-only steps: prove the token and base URL without any patient. branches = step("doctors.branches", client.doctors.branches) print(f" branches={len(branches) if isinstance(branches, list) else 'n/a'}") locations = step("doctors.locations", client.doctors.locations) print(f" locations={len(locations) if isinstance(locations, list) else 'n/a'}") - step("doctors.quickSearch", lambda: client.doctors.quick_search("kardiyo", "interview")) + catalog = step("laboratory.catalog", client.laboratory.catalog) + print(f" catalog={len(catalog) if isinstance(catalog, list) else 'n/a'}") found = step( "doctors.search", lambda: client.doctors.search( search_params={"withFreeText": "kardiyoloji"}, - order_params=["slot"], - other_params=["isInterviewable"], current_page=1, - per_page_limit=10, + order_params=["slot"], ), ) count = found.get("foundDoctorsCount") if isinstance(found, dict) else "n/a" @@ -77,13 +75,28 @@ def step(name: str, fn: Callable[[], Any]) -> Any: detail = step("doctors.detail", lambda: client.doctors.detail(doctor_id)) print(f" detailKeys={len(detail) if isinstance(detail, dict) else 'n/a'}") - slots = step("slots.schedule", lambda: client.slots.schedule(doctor_id, "interview")) + step("appointments.checkDoctor", lambda: client.appointments.check_doctor(doctor_id, 0)) + + slots = step("slots.schedule", lambda: client.slots.schedule(doctor_id)) print(f" slotDays={len(slots) if isinstance(slots, dict) else 'n/a'}") - last = step("measures.last", client.measures.last) - print(f" measuresLastKeys={len(last) if isinstance(last, dict) else 'n/a'}") + # --- Patient-scoped steps. A TCKN that works on the patient surface will not + # necessarily resolve here: the patient must exist in the token's company. + patient: dict[str, str] | None = None + if tckn := os.environ.get("BK_PATIENT_TCKN"): + patient = {"identityNumber": tckn} + elif phone := os.environ.get("BK_PATIENT_PHONE"): + patient = {"phoneNumber": phone} + + if patient is not None: + last = step("measures.last", lambda: client.measures.last(patient)) + print(f" measuresLastKeys={len(last) if isinstance(last, dict) else 'n/a'}") + + step("diets.list", lambda: client.diets.list(patient)) + step("laboratory.results", lambda: client.laboratory.results(patient)) + else: + print("-- skipped patient-scoped steps (set BK_PATIENT_TCKN or BK_PATIENT_PHONE)") - step("auth.disconnect", client.auth.disconnect) client.close() passed = sum(1 for _, ok in results if ok) diff --git a/src/bulutklinik/__init__.py b/src/bulutklinik/__init__.py index 3bd3fc1..496d62f 100644 --- a/src/bulutklinik/__init__.py +++ b/src/bulutklinik/__init__.py @@ -1,9 +1,9 @@ -"""Official Bulutklinik API SDK for Python.""" +"""Official Bulutklinik partner API SDK for Python.""" from __future__ import annotations from .client import AsyncBulutklinikClient, BulutklinikClient -from .config import Environment +from .config import ApiVersion, Environment from .errors import ( ApiError, AuthenticationError, @@ -14,13 +14,13 @@ TransportError, ValidationError, ) -from .models import LoginResult from .tokens import InMemoryTokenStore, TokenStore -__version__ = "0.6.0" +__version__ = "1.0.1" __all__ = [ "ApiError", + "ApiVersion", "AsyncBulutklinikClient", "AuthenticationError", "AuthorizationError", @@ -28,7 +28,6 @@ "BulutklinikError", "Environment", "InMemoryTokenStore", - "LoginResult", "NotFoundError", "RateLimitError", "TokenStore", diff --git a/src/bulutklinik/_auth.py b/src/bulutklinik/_auth.py deleted file mode 100644 index 1035950..0000000 --- a/src/bulutklinik/_auth.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Login helpers shared by the sync and async auth resources.""" - -from __future__ import annotations - -from typing import Any - -from .errors import BulutklinikError -from .models import LoginResult -from .tokens import TokenStore - - -def connect_body( - api_user_name: str, - api_user_password: str | None, - api_client_id: str | None, - api_secret_key: str | None, - login_mode: str, - with_phone_number: str | None, -) -> dict[str, Any]: - body: dict[str, Any] = { - "apiUserName": api_user_name, - "apiUserPassword": api_user_password, - "apiClientId": api_client_id, - "apiSecretKey": api_secret_key, - "loginMode": login_mode, - } - if with_phone_number is not None: - body["withPhoneNumber"] = with_phone_number - return body - - -def register_body( - name: str, - surname: str, - api_user_name: str, - phone_number: str, - password: str, - sms_verification_code: str, - response: str, - accept_user_agreement: int, - api_client_id: str | None, - api_secret_key: str | None, -) -> dict[str, Any]: - return { - "name": name, - "surname": surname, - "apiUserName": api_user_name, - "phoneNumber": phone_number, - "password": password, - "smsVerificationCode": sms_verification_code, - "response": response, - "acceptUserAgreement": accept_user_agreement, - "apiClientId": api_client_id, - "apiSecretKey": api_secret_key, - } - - -def verify_registration_body( - name: str, - surname: str, - phone_number: str, - phone_code: str, - email: str, - password: str, - accept_user_agreement: int, - recaptcha_v2: str | None, - captcha: str | None, - user_agreements: list[Any] | None, -) -> dict[str, Any]: - body: dict[str, Any] = { - "name": name, - "surname": surname, - "phoneNumber": phone_number, - "phone_code": phone_code, - "email": email, - "password": password, - "passwordAgain": password, - "acceptUserAgreement": accept_user_agreement, - } - if recaptcha_v2 is not None: - body["g-recaptcha-response-v2"] = recaptcha_v2 - if captcha is not None: - body["captcha"] = captcha - if user_agreements is not None: - body["userAgreements"] = user_agreements - return body - - -def finish_login(data: Any, token_store: TokenStore) -> LoginResult: - if isinstance(data, dict) and isinstance(data.get("access_token"), str): - store_tokens(data, token_store) - return LoginResult(two_factor_required=False) - if isinstance(data, dict) and isinstance(data.get("response"), str): - return LoginResult(two_factor_required=True, two_factor_response=data["response"]) - return LoginResult(two_factor_required=False) - - -def store_tokens(data: Any, token_store: TokenStore) -> None: - if not isinstance(data, dict) or not isinstance(data.get("access_token"), str): - raise BulutklinikError("Login response did not contain an access token") - refresh = data.get("refresh_token") - token_store.set_tokens(data["access_token"], refresh if isinstance(refresh, str) else None) diff --git a/src/bulutklinik/_http.py b/src/bulutklinik/_http.py index b3da2ce..57ab2a9 100644 --- a/src/bulutklinik/_http.py +++ b/src/bulutklinik/_http.py @@ -1,5 +1,10 @@ """Transport core. Pure helpers are shared; the sync and async clients differ -only in how they perform I/O and the refresh call.""" +only in how they perform I/O. + +There is no silent refresh: a partner token is issued out of band and cannot be +renewed from here, so an expired one (``401`` / ``resultType 4``) surfaces as an +``AuthenticationError`` instead of being retried. +""" from __future__ import annotations @@ -13,14 +18,10 @@ from .tokens import TokenStore -def _build_headers( - spec: RequestSpec, lang: str, access_token: str | None, partner_token: str | None -) -> dict[str, str]: +def _build_headers(spec: RequestSpec, lang: str, token: str | None) -> dict[str, str]: headers = {"Accept": "application/json", "lang": lang} - if spec.auth == "bearer" and access_token: - headers["Authorization"] = f"Bearer {access_token}" - elif spec.auth == "partner" and partner_token: - headers["Authorization"] = f"Bearer {partner_token}" + if spec.auth == "partner" and token: + headers["Authorization"] = f"Bearer {token}" return headers @@ -42,8 +43,14 @@ def _is_success(status: int, envelope: dict[str, Any]) -> bool: return 200 <= status < 300 and envelope.get("resultType") == 0 -def _is_expired(status: int, envelope: dict[str, Any]) -> bool: - return status == 401 or envelope.get("resultType") == 4 +def _require_token(spec: RequestSpec, token: str | None) -> str | None: + """Fail before dispatch when a partner call has no credential — sending it + anyway would only come back as an opaque 401.""" + if spec.auth == "partner" and not token: + raise AuthenticationError( + "No partner token configured.", http_status=0, method=spec.method, path=spec.path + ) + return token def _to_error( @@ -70,54 +77,37 @@ def _to_error( class HttpClient: - """Synchronous transport: unwraps the envelope, maps errors, and silently - refreshes + retries once on a 401 / ``resultType 4``.""" + """Synchronous transport: unwraps the envelope and maps errors.""" def __init__( self, *, base_url: str, lang: str, - client_id: str | None, - client_secret: str | None, - partner_token: str | None, token_store: TokenStore, client: httpx.Client, ) -> None: self.base_url = base_url self.lang = lang - self.client_id = client_id - self.client_secret = client_secret - self.partner_token = partner_token self.token_store = token_store self._client = client - def send(self, spec: RequestSpec, *, _is_retry: bool = False) -> Any: + def send(self, spec: RequestSpec) -> Any: status, envelope, retry_after = self._dispatch(spec) if _is_success(status, envelope): return envelope.get("data") - if ( - spec.auth == "bearer" - and _is_expired(status, envelope) - and not _is_retry - and self._try_refresh() - ): - return self.send(spec, _is_retry=True) + # A revoked token is worth forgetting; an expired one is not, since the + # caller may want to inspect it while installing a replacement. if envelope.get("resultType") == 2: self.token_store.clear() raise _to_error(spec, status, envelope, retry_after) - def refresh(self) -> None: - if not self._try_refresh(): - raise AuthenticationError("Token refresh failed", http_status=401) - def close(self) -> None: self._client.close() def _dispatch(self, spec: RequestSpec) -> tuple[int, dict[str, Any], str | None]: - headers = _build_headers( - spec, self.lang, self.token_store.get_access_token(), self.partner_token - ) + token = _require_token(spec, self.token_store.get_token()) + headers = _build_headers(spec, self.lang, token) try: response = self._client.request( spec.method, self.base_url + spec.path, headers=headers, json=_json_body(spec) @@ -130,38 +120,6 @@ def _dispatch(self, spec: RequestSpec) -> tuple[int, dict[str, Any], str | None] response.headers.get("retry-after"), ) - def _try_refresh(self) -> bool: - refresh_token = self.token_store.get_refresh_token() - if not refresh_token or not self.client_id or not self.client_secret: - return False - spec = RequestSpec( - "POST", - "/general/refreshApi", - "public", - { - "refreshToken": refresh_token, - "clientId": self.client_id, - "clientSecretKey": self.client_secret, - }, - ) - try: - status, envelope, _ = self._dispatch(spec) - except TransportError: - return False - data = envelope.get("data") - if ( - not _is_success(status, envelope) - or not isinstance(data, dict) - or not isinstance(data.get("access_token"), str) - ): - self.token_store.clear() - return False - new_refresh = ( - data["refresh_token"] if isinstance(data.get("refresh_token"), str) else refresh_token - ) - self.token_store.set_tokens(data["access_token"], new_refresh) - return True - class AsyncHttpClient: """Asynchronous transport — same behavior as :class:`HttpClient`.""" @@ -171,46 +129,28 @@ def __init__( *, base_url: str, lang: str, - client_id: str | None, - client_secret: str | None, - partner_token: str | None, token_store: TokenStore, client: httpx.AsyncClient, ) -> None: self.base_url = base_url self.lang = lang - self.client_id = client_id - self.client_secret = client_secret - self.partner_token = partner_token self.token_store = token_store self._client = client - async def send(self, spec: RequestSpec, *, _is_retry: bool = False) -> Any: + async def send(self, spec: RequestSpec) -> Any: status, envelope, retry_after = await self._dispatch(spec) if _is_success(status, envelope): return envelope.get("data") - if ( - spec.auth == "bearer" - and _is_expired(status, envelope) - and not _is_retry - and await self._try_refresh() - ): - return await self.send(spec, _is_retry=True) if envelope.get("resultType") == 2: self.token_store.clear() raise _to_error(spec, status, envelope, retry_after) - async def refresh(self) -> None: - if not await self._try_refresh(): - raise AuthenticationError("Token refresh failed", http_status=401) - async def aclose(self) -> None: await self._client.aclose() async def _dispatch(self, spec: RequestSpec) -> tuple[int, dict[str, Any], str | None]: - headers = _build_headers( - spec, self.lang, self.token_store.get_access_token(), self.partner_token - ) + token = _require_token(spec, self.token_store.get_token()) + headers = _build_headers(spec, self.lang, token) try: response = await self._client.request( spec.method, self.base_url + spec.path, headers=headers, json=_json_body(spec) @@ -222,35 +162,3 @@ async def _dispatch(self, spec: RequestSpec) -> tuple[int, dict[str, Any], str | _parse_envelope(response.text), response.headers.get("retry-after"), ) - - async def _try_refresh(self) -> bool: - refresh_token = self.token_store.get_refresh_token() - if not refresh_token or not self.client_id or not self.client_secret: - return False - spec = RequestSpec( - "POST", - "/general/refreshApi", - "public", - { - "refreshToken": refresh_token, - "clientId": self.client_id, - "clientSecretKey": self.client_secret, - }, - ) - try: - status, envelope, _ = await self._dispatch(spec) - except TransportError: - return False - data = envelope.get("data") - if ( - not _is_success(status, envelope) - or not isinstance(data, dict) - or not isinstance(data.get("access_token"), str) - ): - self.token_store.clear() - return False - new_refresh = ( - data["refresh_token"] if isinstance(data.get("refresh_token"), str) else refresh_token - ) - self.token_store.set_tokens(data["access_token"], new_refresh) - return True diff --git a/src/bulutklinik/_spec.py b/src/bulutklinik/_spec.py index 2154e23..3766d2c 100644 --- a/src/bulutklinik/_spec.py +++ b/src/bulutklinik/_spec.py @@ -2,6 +2,15 @@ Both the sync and async resources call these builders, so request shapes never drift between the two surfaces. + +Every builder sends ``auth="partner"``: the partner token issued for your +integration. The patient is named inline per request and the server resolves it +strictly inside your own company. + +``patient`` shapes: + read -> {"identityNumber": str | None, "phoneNumber": str | None} + write -> {"name": str, "surname": str, "phoneNumber": str, + "identityNumber"/"email"/"birthdate"/"nationality": optional} """ from __future__ import annotations @@ -9,7 +18,10 @@ from dataclasses import dataclass from typing import Any, Literal -AuthMode = Literal["public", "bearer", "partner"] +#: ``partner`` sends the configured token; ``public`` sends no ``Authorization`` +#: header. Every endpoint below is ``partner`` — ``public`` is only reachable +#: through the escape hatch (``client.request``). +AuthMode = Literal["partner", "public"] @dataclass(frozen=True) @@ -20,155 +32,54 @@ class RequestSpec: body: dict[str, Any] | None = None -# --- auth (registration/password steps that don't mint tokens) --- - - -def confirm_registration_email( - verification_code: str, response: str, user_agreements: list[Any] | None -) -> RequestSpec: - body: dict[str, Any] = {"verificationCode": verification_code, "response": response} - if user_agreements is not None: - body["userAgreements"] = user_agreements - return RequestSpec("POST", "/patients/emailConfirmationRegister", "public", body) - - -def verify_registration_social( - name: str, - surname: str, - phone_number: str, - password: str, - social_type: str, - key: str, - email: str | None, - accept_user_agreement: int, - user_agreements: list[Any] | None, -) -> RequestSpec: - body: dict[str, Any] = { - "name": name, - "surname": surname, - "phoneNumber": phone_number, - "password": password, - "passwordAgain": password, - "socialType": social_type, - "key": key, - "acceptUserAgreement": accept_user_agreement, - } - if email is not None: - body["email"] = email - if user_agreements is not None: - body["userAgreements"] = user_agreements - return RequestSpec("POST", "/patients/verifyAddingNewPatientSocial", "public", body) - - -def register_social( - sms_verification_code: str, response: str, user_agreements: list[Any] | None -) -> RequestSpec: - body: dict[str, Any] = {"smsVerificationCode": sms_verification_code, "response": response} - if user_agreements is not None: - body["userAgreements"] = user_agreements - return RequestSpec("POST", "/patients/addNewPatientWithSocial", "public", body) - - -def forgot_password( - phone_number: str, - birthdate: str | None, - recaptcha_v2: str | None, - captcha: str | None, -) -> RequestSpec: - body: dict[str, Any] = {"phoneNumber": phone_number} - if birthdate is not None: - body["birthdate"] = birthdate - if recaptcha_v2 is not None: - body["g-recaptcha-response-v2"] = recaptcha_v2 - if captcha is not None: - body["captcha"] = captcha - return RequestSpec("POST", "/patients/forgotPassword", "public", body) - - -def reset_password(sms_confirm_code: str, response: str, password: str) -> RequestSpec: - return RequestSpec( - "PUT", - "/patients/forgotPassword", - "public", - { - "smsConfirmCode": sms_confirm_code, - "response": response, - "password": password, - "passwordAgain": password, - }, - ) - - # --- doctors --- -def branches() -> RequestSpec: - return RequestSpec("GET", "/patients/allBranches", "bearer") - - -def locations() -> RequestSpec: - return RequestSpec("GET", "/patients/allLocations", "bearer") - - -def quick_search(search_text: str, list_type: str | None, location: str | None) -> RequestSpec: - return RequestSpec( - "POST", - "/patients/quickSearch", - "bearer", - {"searchText": search_text, "listType": list_type, "location": location}, - ) - - def doctor_search( - search_params: dict[str, Any], - order_params: list[str], - other_params: list[str], - current_page: int, - per_page_limit: int, + search_params: dict[str, Any], current_page: int, order_params: list[str] | None ) -> RequestSpec: return RequestSpec( "POST", - "/patients/filteredSearch", - "bearer", + "/outher/search", + "partner", { "searchParams": search_params, - "orderParams": order_params, - "otherParams": other_params, + "orderParams": order_params or [], "currentPage": current_page, - "perPageLimit": per_page_limit, }, ) -def doctor_detail(doctor_id: int | str, corporate: int | str | None) -> RequestSpec: - path = ( - f"/patients/doctorDetail/{doctor_id}/{corporate}" - if corporate is not None - else f"/patients/doctorDetail/{doctor_id}" - ) - return RequestSpec("GET", path, "bearer") +def branches() -> RequestSpec: + return RequestSpec("GET", "/outher/branches", "partner") + + +def doctor_detail(doctor_id: int | str) -> RequestSpec: + return RequestSpec("GET", f"/outher/doctorInfos/{doctor_id}", "partner") + + +def locations() -> RequestSpec: + return RequestSpec("GET", "/outher/locations", "partner") # --- slots --- -def schedule( +def slot_schedule( doctor_id: int | str, - list_type: str, schedule_date: str | None, - schedule_step: int | str, - schedule_page: int | str, + schedule_step: int | None, + schedule_page: int | None, ) -> RequestSpec: return RequestSpec( "POST", - "/patients/doctorScheduler", - "bearer", + "/outher/doctorSlots", + "partner", { "doctorId": doctor_id, "scheduleDate": schedule_date, "scheduleStep": schedule_step, "schedulePage": schedule_page, - "listType": list_type, }, ) @@ -176,318 +87,180 @@ def schedule( # --- appointments --- -def reserve_interview( - doctor_id: int | str, appointment_date: str, appointment_type: str +def reserve( + slot_id: int | str, doctor_id: int | str, user: dict[str, Any], without_agreement: bool ) -> RequestSpec: + path = "/outher/reservationWithoutAgreement" if without_agreement else "/outher/reservation" + return RequestSpec( + "POST", path, "partner", {"slotId": slot_id, "doctorId": doctor_id, "user": user} + ) + + +def instant_reserve(user: dict[str, Any]) -> RequestSpec: + return RequestSpec("POST", "/outher/instantReservation", "partner", {"user": user}) + + +def create_appointment(hash_: str, outher_process_id: int | str) -> RequestSpec: return RequestSpec( "POST", - "/patients/addInterviewDateReservation", - "bearer", - { - "doctorId": doctor_id, - "appointmentDate": appointment_date, - "appointmentType": appointment_type, - }, + "/outher/appointment", + "partner", + {"hash": hash_, "outherProcessId": outher_process_id}, ) -def add_physical(doctor_id: int | str, appointment_date: str) -> RequestSpec: +def appointment_without_slot( + doctor_id: int | str, + start_date: str, + finish_date: str, + user: dict[str, Any], + is_outher_doctor: int | None, +) -> RequestSpec: return RequestSpec( "POST", - "/patients/addNewAppointment", - "bearer", - {"doctorId": doctor_id, "appointmentDate": appointment_date}, + "/outher/appointmentWithoutSlot", + "partner", + { + "doctorId": doctor_id, + "startDate": start_date, + "finishDate": finish_date, + "isOutherDoctor": is_outher_doctor, + "user": user, + }, ) -def cancel_appointment(event_id: int | str) -> RequestSpec: - return RequestSpec("DELETE", f"/patients/deleteUserAppointment/{event_id}", "bearer") +def cancel_without_slot(lookup: dict[str, Any]) -> RequestSpec: + return RequestSpec("DELETE", "/outher/appointmentWithoutSlot", "partner", dict(lookup)) -def user_appointments(page: int | str | None) -> RequestSpec: - path = ( - f"/patients/userAppointments/{page}" if page is not None else "/patients/userAppointments" +def appointment_list(phone_number: str, page: int | str | None, type_: str | None) -> RequestSpec: + return RequestSpec( + "POST", + "/outher/appointments", + "partner", + {"phoneNumber": phone_number, "page": page, "type": type_}, ) - return RequestSpec("GET", path, "bearer") -def user_reservations() -> RequestSpec: - return RequestSpec("GET", "/patients/userReservations", "bearer") +def appointment_info(lookup: dict[str, Any]) -> RequestSpec: + return RequestSpec("POST", "/outher/appointmentInfo", "partner", dict(lookup)) -# --- addresses --- +def check_doctor(doctor_id: int | str, is_outher_doctor: int) -> RequestSpec: + return RequestSpec( + "POST", + "/outher/checkDoctor", + "partner", + {"doctorId": doctor_id, "isOutherDoctor": is_outher_doctor}, + ) -def address_list() -> RequestSpec: - return RequestSpec("GET", "/patients/userAddress", "bearer") +# --- diets --- -def address_add( - title: str, - city_id: int | str, - district_id: int | str, - address: str, - location_lat: str, - location_lng: str, - description: str | None, - is_default: int | None, -) -> RequestSpec: - body: dict[str, Any] = { - "title": title, - "cityId": city_id, - "districtId": district_id, - "address": address, - "locationLat": location_lat, - "locationLng": location_lng, - } - if description is not None: - body["description"] = description - if is_default is not None: - body["isDefault"] = is_default - return RequestSpec("POST", "/patients/userAddress", "bearer", body) - - -def address_update( - address_id: int | str, - *, - title: str | None = None, - description: str | None = None, - city_id: int | str | None = None, - district_id: int | str | None = None, - address: str | None = None, - location_lat: str | None = None, - location_lng: str | None = None, - is_default: int | None = None, -) -> RequestSpec: - body: dict[str, Any] = {"id": address_id} - if title is not None: - body["title"] = title - if description is not None: - body["description"] = description - if city_id is not None: - body["cityId"] = city_id - if district_id is not None: - body["districtId"] = district_id - if address is not None: - body["address"] = address - if location_lat is not None: - body["locationLat"] = location_lat - if location_lng is not None: - body["locationLng"] = location_lng - if is_default is not None: - body["isDefault"] = is_default - return RequestSpec("PUT", "/patients/userAddress", "bearer", body) - - -def address_delete(address_id: int | str) -> RequestSpec: - return RequestSpec("DELETE", "/patients/userAddress", "bearer", {"id": address_id}) - - -# --- payments --- - - -def check_discount_code( - check_type: str, - discount_code: str, - doctor_id: int | str | None, - order_id: int | str | None, - special_service_id: int | str | None, - program_slug: str | None, -) -> RequestSpec: - body: dict[str, Any] = {"checkType": check_type, "discountCode": discount_code} - if doctor_id is not None: - body["doctorId"] = doctor_id - if order_id is not None: - body["orderId"] = order_id - if special_service_id is not None: - body["specialServiceId"] = special_service_id - if program_slug is not None: - body["programSlug"] = program_slug - return RequestSpec("POST", "/patients/checkDiscountCode", "bearer", body) - - -def get_cards() -> RequestSpec: - return RequestSpec("GET", "/payments/getCards", "bearer") - - -def save_card( - card_holder: str, - card_number: str, - card_exp_month: str, - card_exp_year: str, - card_cvv: str, -) -> RequestSpec: +def diet_list(patient: dict[str, Any], page: int | str | None) -> RequestSpec: return RequestSpec( - "POST", - "/payments/saveCard", - "bearer", - { - "cardHolder": card_holder, - "cardNumber": card_number, - "cardExpMonth": card_exp_month, - "cardExpYear": card_exp_year, - "cardCvv": card_cvv, - }, + "POST", "/outher/dietLists", "partner", {"patient": patient, "currentPage": page} ) -def pay( - doctor_id: int | str, - appointment_date: str, - is_3d: bool, - terms_accept: bool, - appointment_type: str, - card_info: dict[str, str] | None, - card_id: int | str | None, - save_card: int, - discount_code: str, - case_detail: str | None, -) -> RequestSpec: - body: dict[str, Any] = { - "doctorId": doctor_id, - "appointmentDate": appointment_date, - "appointmentType": appointment_type, - "is3D": is_3d, - "termsAccept": terms_accept, - "saveCard": save_card, - "discountCode": discount_code, - } - if card_id is not None: - body["cardId"] = card_id - if card_info is not None: - body["cardInfo"] = card_info - if case_detail is not None: - body["caseDetail"] = case_detail - return RequestSpec("POST", "/payments/interviewPayment", "bearer", body) - - -def delete_card(card_id: int | str) -> RequestSpec: - return RequestSpec("DELETE", f"/payments/deleteCard/{card_id}", "bearer") +def diet_detail(patient: dict[str, Any], list_id: int | str) -> RequestSpec: + return RequestSpec("POST", "/outher/diet", "partner", {"patient": patient, "listId": list_id}) -# --- measures --- +# --- laboratory --- -def add_measures(records: list[dict[str, Any]]) -> RequestSpec: - return RequestSpec("POST", "/patients/addNewUserMeasures", "bearer", {"data": records}) +def lab_catalog() -> RequestSpec: + return RequestSpec("GET", "/outher/laboratoryCatalog", "partner") -def add_measure(measure_type: str, fields: dict[str, Any]) -> RequestSpec: - return RequestSpec("POST", f"/patients/addNewUserMeasures/{measure_type}", "bearer", fields) +def lab_catalog_detail(test_id: int | str) -> RequestSpec: + return RequestSpec("GET", f"/outher/laboratoryCatalog/{test_id}", "partner") -def update_measure(measure_type: str, fields: dict[str, Any]) -> RequestSpec: - return RequestSpec("PUT", f"/patients/updateUserMeasures/{measure_type}", "bearer", fields) +def lab_results(patient: dict[str, Any], page: int | str | None) -> RequestSpec: + return RequestSpec( + "POST", "/outher/laboratoryResults", "partner", {"patient": patient, "currentPage": page} + ) -def delete_measure(measure_type: str, measure_id: int | str) -> RequestSpec: +def lab_result_detail(patient: dict[str, Any], test_id: int | str) -> RequestSpec: + # Sent as a string on purpose: ids from the list endpoint may carry a "-lab" + # suffix marking a TmcLab order group, and it must survive round-tripping. return RequestSpec( - "DELETE", f"/patients/deleteUserMeasures/{measure_type}", "bearer", {"id": measure_id} + "POST", "/outher/laboratoryResult", "partner", {"patient": patient, "testId": str(test_id)} ) -def measures_last() -> RequestSpec: - return RequestSpec("GET", "/patients/measuresList", "bearer") +# --- measures --- -def measures_list(measure_type: str, page: int | str, glucose_type: int | None) -> RequestSpec: - path = ( - f"/patients/userMeasuresList/{measure_type}/{page}/{glucose_type}" - if glucose_type is not None - else f"/patients/userMeasuresList/{measure_type}/{page}" - ) - return RequestSpec("GET", path, "bearer") +def last_measures(patient: dict[str, Any]) -> RequestSpec: + return RequestSpec("POST", "/outher/lastMeasures", "partner", {"patient": patient}) -def measures_graph( - measure_type: str, period: int, page: int | str, glucose_type: int | None +def measures_list( + patient: dict[str, Any], type_: str, page: int | str | None, glucose_type: int | None ) -> RequestSpec: - path = ( - f"/patients/userMeasuresGraph/{measure_type}/{period}/{page}/{glucose_type}" - if glucose_type is not None - else f"/patients/userMeasuresGraph/{measure_type}/{period}/{page}" + return RequestSpec( + "POST", + f"/outher/measuresList/{type_}", + "partner", + {"patient": patient, "currentPage": page, "glucoseType": glucose_type}, ) - return RequestSpec("GET", path, "bearer") -def partner_health_information( - identity: str | None, phone_number: str | None, data: list[dict[str, Any]] +def measures_graph( + patient: dict[str, Any], + type_: str, + period: int, + page: int | str | None, + glucose_type: int | None, ) -> RequestSpec: return RequestSpec( "POST", - "/outher/healthInformation", + f"/outher/measuresGraph/{type_}/{period}", "partner", - {"identity": identity, "phoneNumber": phone_number, "data": data}, + {"patient": patient, "currentPage": page, "glucoseType": glucose_type}, ) -# --- skin --- - - -def image_check(images: list[dict[str, Any]]) -> RequestSpec: - return RequestSpec("POST", "/patients/imageCheck", "bearer", {"images": images}) +def add_measures(patient: dict[str, Any], data: list[dict[str, Any]]) -> RequestSpec: + return RequestSpec("POST", "/outher/measures", "partner", {"patient": patient, "data": data}) -# --- meals --- +def add_measure(patient: dict[str, Any], type_: str, fields: dict[str, Any]) -> RequestSpec: + return RequestSpec( + "POST", f"/outher/measure/{type_}", "partner", {"patient": patient, **fields} + ) -def analyze_meal( - image: str, - portion_size: str, - meal_type: str, - portion_grams: int | str | None, - note: str | None, +def update_measure( + patient: dict[str, Any], type_: str, measure_id: int | str, fields: dict[str, Any] ) -> RequestSpec: - body: dict[str, Any] = { - "image": image, - "portion_size": portion_size, - "meal_type": meal_type, - } - if portion_grams is not None: - body["portion_grams"] = portion_grams - if note is not None: - body["note"] = note - return RequestSpec("POST", "/patients/imageAnalyzeMeal", "bearer", body) - - -# --- laboratory --- - - -def lab_results(page: int | str | None) -> RequestSpec: - path = f"/patients/userLabTestList/{page}" if page is not None else "/patients/userLabTestList" - return RequestSpec("GET", path, "bearer") - - -def lab_result_detail(test_id: str) -> RequestSpec: - return RequestSpec("GET", f"/patients/userLabTestDetail/{test_id}", "bearer") - - -def lab_catalog() -> RequestSpec: - return RequestSpec("GET", "/patients/allLaboratoryTests", "bearer") + return RequestSpec( + "PUT", + f"/outher/measure/{type_}", + "partner", + {"patient": patient, "id": measure_id, **fields}, + ) -def lab_catalog_detail(id_: int | str) -> RequestSpec: - return RequestSpec("GET", f"/patients/laboratoryTestDetail/{id_}", "bearer") +def delete_measure(patient: dict[str, Any], type_: str, measure_id: int | str) -> RequestSpec: + return RequestSpec( + "DELETE", f"/outher/measure/{type_}", "partner", {"patient": patient, "id": measure_id} + ) -def add_laboratory_test( - test_id: int | str, address_id: int | str, laboratory_id: int | str +def health_information( + identity: str | None, phone_number: str | None, data: list[dict[str, Any]] ) -> RequestSpec: + # Legacy `teusan` contract: flat, no `patient` wrapper. Kept verbatim. return RequestSpec( "POST", - "/patients/addNewLaboratoryTest", - "bearer", - {"testId": test_id, "addressId": address_id, "laboratoryId": laboratory_id}, + "/outher/healthInformation", + "partner", + {"identity": identity, "phoneNumber": phone_number, "data": data}, ) - - -# --- diets --- - - -def diet_list(page: int | str | None) -> RequestSpec: - path = f"/patients/dietLists/{page}" if page is not None else "/patients/dietLists" - return RequestSpec("GET", path, "bearer") - - -def diet_detail(list_id: int | str) -> RequestSpec: - return RequestSpec("GET", f"/patients/diet/{list_id}", "bearer") diff --git a/src/bulutklinik/aresources.py b/src/bulutklinik/aresources.py index d04640a..680583f 100644 --- a/src/bulutklinik/aresources.py +++ b/src/bulutklinik/aresources.py @@ -1,570 +1,207 @@ -"""Asynchronous service resources — mirror of :mod:`bulutklinik.resources`.""" +"""Asynchronous resource groups — mirror of :mod:`bulutklinik.resources`. + +Same builders, same request shapes; only the await boundary differs. See the sync +counterparts for the behavioural documentation. +""" from __future__ import annotations from typing import Any from . import _spec -from ._auth import ( - connect_body, - finish_login, - register_body, - store_tokens, - verify_registration_body, -) from ._http import AsyncHttpClient -from ._spec import RequestSpec -from .models import LoginResult +from .resources import MeasureRows, Patient + +__all__ = [ + "AsyncAppointmentsResource", + "AsyncDietsResource", + "AsyncDoctorsResource", + "AsyncLaboratoryResource", + "AsyncMeasuresResource", + "AsyncSlotsResource", + "MeasureRows", + "Patient", +] -# See resources.py: the `list` method shadows builtin `list` inside the measures -# resource, so `list[...]` annotations there use this module-level alias. -_MeasureRecords = list[dict[str, Any]] +class AsyncDoctorsResource: + """Async counterpart of :class:`bulutklinik.resources.DoctorsResource`.""" -class AsyncAuthResource: def __init__(self, http: AsyncHttpClient) -> None: self._http = http - async def connect( - self, - api_user_name: str, - api_user_password: str | None, - login_mode: str, - *, - client_id: str | None = None, - client_secret: str | None = None, - with_phone_number: str | None = None, - ) -> LoginResult: - body = connect_body( - api_user_name, - api_user_password, - client_id or self._http.client_id, - client_secret or self._http.client_secret, - login_mode, - with_phone_number, - ) - data = await self._http.send(RequestSpec("POST", "/general/connectApi", "public", body)) - return finish_login(data, self._http.token_store) - - async def connect_with_two_factor(self, sms_verification_code: str, response: str) -> None: - data = await self._http.send( - RequestSpec( - "POST", - "/general/connectApiWithTwoFactor", - "public", - {"smsVerificationCode": sms_verification_code, "response": response}, - ) - ) - store_tokens(data, self._http.token_store) - - async def verify_registration( - self, - *, - name: str, - surname: str, - phone_number: str, - phone_code: str, - email: str, - password: str, - accept_user_agreement: int = 1, - recaptcha_v2: str | None = None, - captcha: str | None = None, - user_agreements: list[Any] | None = None, - ) -> Any: - """Registration step 1: send the verification code, return the ``response`` blob. - - Uses the configured **partner** token (the endpoint is behind ``auth:apiusers``, - not public). A CAPTCHA token (``recaptcha_v2`` or ``captcha``), minted by a - browser/human, is required. Feed the returned ``response`` (and the code the - user receives) into :meth:`register`. - """ - body = verify_registration_body( - name, - surname, - phone_number, - phone_code, - email, - password, - accept_user_agreement, - recaptcha_v2, - captcha, - user_agreements, - ) - return await self._http.send( - RequestSpec("POST", "/patients/verifyAddingNewPatient", "partner", body) - ) - - async def register( - self, - *, - name: str, - surname: str, - api_user_name: str, - phone_number: str, - password: str, - sms_verification_code: str, - response: str, - accept_user_agreement: int = 1, - client_id: str | None = None, - client_secret: str | None = None, - ) -> None: - body = register_body( - name, - surname, - api_user_name, - phone_number, - password, - sms_verification_code, - response, - accept_user_agreement, - client_id or self._http.client_id, - client_secret or self._http.client_secret, - ) - data = await self._http.send(RequestSpec("POST", "/patients/addNewPatient", "public", body)) - store_tokens(data, self._http.token_store) - - async def confirm_registration_email( - self, - *, - verification_code: str, - response: str, - user_agreements: list[Any] | None = None, - ) -> Any: - """Registration step 2 (e-mail branch): confirm the e-mailed code, get the SMS blob. - - When :meth:`verify_registration` returned ``confirmationType == "email"``, confirm - the code here with the same ``response`` blob. Returns a fresh ``response`` blob + - ``confirmationType: "sms"`` to feed into :meth:`register`. Public. - """ - return await self._http.send( - _spec.confirm_registration_email(verification_code, response, user_agreements) - ) - - async def verify_registration_social( - self, - *, - name: str, - surname: str, - phone_number: str, - password: str, - social_type: str, - key: str, - email: str | None = None, - accept_user_agreement: int = 1, - user_agreements: list[Any] | None = None, - ) -> Any: - """Social sign-up step 1: send the SMS code, return a ``response`` blob. Public.""" - return await self._http.send( - _spec.verify_registration_social( - name, - surname, - phone_number, - password, - social_type, - key, - email, - accept_user_agreement, - user_agreements, - ) - ) - - async def register_social( - self, - *, - sms_verification_code: str, - response: str, - user_agreements: list[Any] | None = None, - ) -> Any: - """Social sign-up step 2: create the social patient. Does NOT log in — call - :meth:`connect` with ``login_mode="social"`` afterwards. Public. - """ - return await self._http.send( - _spec.register_social(sms_verification_code, response, user_agreements) - ) - - async def forgot_password( - self, - *, - phone_number: str, - birthdate: str | None = None, - recaptcha_v2: str | None = None, - captcha: str | None = None, - ) -> Any: - """Password reset step 1: send the SMS confirm code, return a ``response`` blob. - - A CAPTCHA token (``recaptcha_v2`` or ``captcha``) is required outside local env. Public. - """ - return await self._http.send( - _spec.forgot_password(phone_number, birthdate, recaptcha_v2, captcha) - ) - - async def reset_password( + async def search( self, - *, - sms_confirm_code: str, - response: str, - password: str, + search_params: dict[str, Any], + current_page: int = 1, + order_params: list[str] | None = None, ) -> Any: - """Password reset step 2: set the new password with the SMS confirm code + blob. Public.""" - return await self._http.send(_spec.reset_password(sms_confirm_code, response, password)) - - async def refresh(self) -> None: - await self._http.refresh() - - async def disconnect(self) -> None: - try: - await self._http.send(RequestSpec("POST", "/general/disconnectApi", "bearer", {})) - finally: - self._http.token_store.clear() - - -class AsyncDoctorsResource: - def __init__(self, http: AsyncHttpClient) -> None: - self._http = http + return await self._http.send(_spec.doctor_search(search_params, current_page, order_params)) async def branches(self) -> Any: return await self._http.send(_spec.branches()) + async def detail(self, doctor_id: int | str) -> Any: + return await self._http.send(_spec.doctor_detail(doctor_id)) + async def locations(self) -> Any: + """City list. Global catalogue — not scoped to your company.""" return await self._http.send(_spec.locations()) - async def quick_search( - self, search_text: str, list_type: str | None = None, location: str | None = None - ) -> Any: - return await self._http.send(_spec.quick_search(search_text, list_type, location)) - - async def search( - self, - *, - search_params: dict[str, Any] | None = None, - order_params: list[str] | None = None, - other_params: list[str] | None = None, - current_page: int = 1, - per_page_limit: int = 20, - ) -> Any: - return await self._http.send( - _spec.doctor_search( - search_params or {}, - order_params or [], - other_params or [], - current_page, - per_page_limit, - ) - ) - - async def detail(self, doctor_id: int | str, corporate: int | str | None = None) -> Any: - return await self._http.send(_spec.doctor_detail(doctor_id, corporate)) - class AsyncSlotsResource: + """Async counterpart of :class:`bulutklinik.resources.SlotsResource`.""" + def __init__(self, http: AsyncHttpClient) -> None: self._http = http async def schedule( self, doctor_id: int | str, - list_type: str, *, schedule_date: str | None = None, - schedule_step: int | str = 7, - schedule_page: int | str = 1, + schedule_step: int | None = None, + schedule_page: int | None = None, ) -> Any: return await self._http.send( - _spec.schedule(doctor_id, list_type, schedule_date, schedule_step, schedule_page) + _spec.slot_schedule(doctor_id, schedule_date, schedule_step, schedule_page) ) class AsyncAppointmentsResource: - def __init__(self, http: AsyncHttpClient) -> None: - self._http = http - - async def reserve_interview( - self, doctor_id: int | str, appointment_date: str, appointment_type: str = "interview" - ) -> Any: - return await self._http.send( - _spec.reserve_interview(doctor_id, appointment_date, appointment_type) - ) - - async def add_physical(self, doctor_id: int | str, appointment_date: str) -> Any: - return await self._http.send(_spec.add_physical(doctor_id, appointment_date)) - - async def cancel(self, event_id: int | str) -> Any: - return await self._http.send(_spec.cancel_appointment(event_id)) - - async def list(self, page: int | str | None = None) -> Any: - """The patient's appointments (``{foundAppointmentsCount, foundAppointments}``). - - Each item's ``event_id`` is the id for :meth:`cancel`; rows with ``event_id`` ``"0"`` - are paid-order/refund entries (not cancellable). Server paging is disabled — page 1 - (the default) returns the full list. - """ - return await self._http.send(_spec.user_appointments(page)) - - async def reservations(self) -> Any: - """The patient's active online-slot reservation holds (with a ``minute_diff`` countdown).""" - return await self._http.send(_spec.user_reservations()) - - -class AsyncAddressesResource: - """The patient's saved addresses. Required by ``laboratory.order`` (needs an ``addressId``).""" + """Async counterpart of :class:`bulutklinik.resources.AppointmentsResource`.""" def __init__(self, http: AsyncHttpClient) -> None: self._http = http - async def list(self) -> Any: - """List saved addresses (default first). Each item's ``id`` is the ``addressId``.""" - return await self._http.send(_spec.address_list()) - - async def add( - self, - *, - title: str, - city_id: int | str, - district_id: int | str, - address: str, - location_lat: str, - location_lng: str, - description: str | None = None, - is_default: int | None = None, - ) -> Any: - """Add an address. Success → ``{"addressId": ...}``. ``city_id`` comes from - ``doctors.locations()``; ``district_id`` from ``GET /getConfig`` (``cities[].districts[]``). - """ - return await self._http.send( - _spec.address_add( - title, - city_id, - district_id, - address, - location_lat, - location_lng, - description, - is_default, - ) - ) - - async def update( + async def reserve( self, - address_id: int | str, + slot_id: int | str, + doctor_id: int | str, + user: Patient, *, - title: str | None = None, - description: str | None = None, - city_id: int | str | None = None, - district_id: int | str | None = None, - address: str | None = None, - location_lat: str | None = None, - location_lng: str | None = None, - is_default: int | None = None, + without_agreement: bool = False, ) -> Any: - """Update an address by id. Pass ``is_default=1`` alone to flip the default flag, - or any other field to edit it. - """ - return await self._http.send( - _spec.address_update( - address_id, - title=title, - description=description, - city_id=city_id, - district_id=district_id, - address=address, - location_lat=location_lat, - location_lng=location_lng, - is_default=is_default, - ) - ) + return await self._http.send(_spec.reserve(slot_id, doctor_id, user, without_agreement)) - async def delete(self, address_id: int | str) -> Any: - """Delete an address by id (default/used addresses cannot be deleted).""" - return await self._http.send(_spec.address_delete(address_id)) + async def instant_reserve(self, user: Patient) -> Any: + return await self._http.send(_spec.instant_reserve(user)) + async def create(self, hash_: str, outher_process_id: int | str) -> Any: + return await self._http.send(_spec.create_appointment(hash_, outher_process_id)) -class AsyncPaymentsResource: - def __init__(self, http: AsyncHttpClient) -> None: - self._http = http - - async def check_discount_code( + async def create_without_slot( self, - check_type: str, - discount_code: str, + doctor_id: int | str, + start_date: str, + finish_date: str, + user: Patient, *, - doctor_id: int | str | None = None, - order_id: int | str | None = None, - special_service_id: int | str | None = None, - program_slug: str | None = None, + is_outher_doctor: int | None = None, ) -> Any: return await self._http.send( - _spec.check_discount_code( - check_type, discount_code, doctor_id, order_id, special_service_id, program_slug + _spec.appointment_without_slot( + doctor_id, start_date, finish_date, user, is_outher_doctor ) ) - async def get_cards(self) -> Any: - return await self._http.send(_spec.get_cards()) + async def cancel_without_slot(self, lookup: dict[str, Any]) -> Any: + return await self._http.send(_spec.cancel_without_slot(lookup)) - async def save_card( - self, - card_holder: str, - card_number: str, - card_exp_month: str, - card_exp_year: str, - card_cvv: str, + async def list( + self, phone_number: str, page: int | str | None = None, type_: str | None = None ) -> Any: - return await self._http.send( - _spec.save_card(card_holder, card_number, card_exp_month, card_exp_year, card_cvv) - ) + return await self._http.send(_spec.appointment_list(phone_number, page, type_)) - async def pay( - self, - doctor_id: int | str, - appointment_date: str, - is_3d: bool, - terms_accept: bool, - *, - appointment_type: str = "interview", - card_info: dict[str, str] | None = None, - card_id: int | str | None = None, - save_card: int = 0, - discount_code: str = "", - case_detail: str | None = None, - ) -> Any: - return await self._http.send( - _spec.pay( - doctor_id, - appointment_date, - is_3d, - terms_accept, - appointment_type, - card_info, - card_id, - save_card, - discount_code, - case_detail, - ) - ) + async def info(self, lookup: dict[str, Any]) -> Any: + return await self._http.send(_spec.appointment_info(lookup)) - async def delete_card(self, card_id: int | str) -> Any: - return await self._http.send(_spec.delete_card(card_id)) + async def check_doctor(self, doctor_id: int | str, is_outher_doctor: int) -> Any: + return await self._http.send(_spec.check_doctor(doctor_id, is_outher_doctor)) -class AsyncMeasuresResource: +class AsyncDietsResource: + """Async counterpart of :class:`bulutklinik.resources.DietsResource`.""" + def __init__(self, http: AsyncHttpClient) -> None: self._http = http - async def add_list(self, records: _MeasureRecords) -> Any: - return await self._http.send(_spec.add_measures(records)) + async def list(self, patient: Patient, page: int | str | None = None) -> Any: + return await self._http.send(_spec.diet_list(patient, page)) - async def add(self, measure_type: str, fields: dict[str, Any]) -> Any: - return await self._http.send(_spec.add_measure(measure_type, fields)) + async def detail(self, patient: Patient, list_id: int | str) -> Any: + return await self._http.send(_spec.diet_detail(patient, list_id)) - async def update(self, measure_type: str, fields: dict[str, Any]) -> Any: - return await self._http.send(_spec.update_measure(measure_type, fields)) - async def delete(self, measure_type: str, measure_id: int | str) -> Any: - return await self._http.send(_spec.delete_measure(measure_type, measure_id)) +class AsyncLaboratoryResource: + """Async counterpart of :class:`bulutklinik.resources.LaboratoryResource`.""" - async def last(self) -> Any: - return await self._http.send(_spec.measures_last()) + def __init__(self, http: AsyncHttpClient) -> None: + self._http = http - async def list( - self, measure_type: str, page: int | str, glucose_type: int | None = None - ) -> Any: - return await self._http.send(_spec.measures_list(measure_type, page, glucose_type)) + async def catalog(self) -> Any: + return await self._http.send(_spec.lab_catalog()) - async def graph( - self, measure_type: str, period: int, page: int | str, glucose_type: int | None = None - ) -> Any: - return await self._http.send(_spec.measures_graph(measure_type, period, page, glucose_type)) + async def catalog_detail(self, test_id: int | str) -> Any: + return await self._http.send(_spec.lab_catalog_detail(test_id)) - async def partner_health_information( - self, *, identity: str | None = None, phone_number: str | None = None, data: _MeasureRecords - ) -> Any: - return await self._http.send(_spec.partner_health_information(identity, phone_number, data)) + async def results(self, patient: Patient, page: int | str | None = None) -> Any: + return await self._http.send(_spec.lab_results(patient, page)) + async def result_detail(self, patient: Patient, test_id: int | str) -> Any: + return await self._http.send(_spec.lab_result_detail(patient, test_id)) -class AsyncSkinResource: - """AI skin-lesion analysis ("Cildimde Neyim Var").""" + +class AsyncMeasuresResource: + """Async counterpart of :class:`bulutklinik.resources.MeasuresResource`.""" def __init__(self, http: AsyncHttpClient) -> None: self._http = http - async def analyze(self, images: list[dict[str, Any]]) -> Any: - """Analyze one or more skin photos. Each image is classified (lesion - ``label``), summarized in Turkish (``comment``), and returned with quality - flags, a ``confidence``, possible ICD hints and an opaque ``case_detail`` - blob. The ``case_detail`` may be forwarded verbatim as a payment's - ``caseDetail``. - """ - return await self._http.send(_spec.image_check(images)) + async def last(self, patient: Patient) -> Any: + return await self._http.send(_spec.last_measures(patient)) + async def list( + self, + patient: Patient, + type_: str, + page: int | str | None = None, + glucose_type: int | None = None, + ) -> Any: + return await self._http.send(_spec.measures_list(patient, type_, page, glucose_type)) -class AsyncMealsResource: - """AI meal-photo calorie/nutrition estimation (sibling of ``skin``).""" - - def __init__(self, http: AsyncHttpClient) -> None: - self._http = http - - async def analyze( + async def graph( self, - image: str, - portion_size: str, - meal_type: str, - *, - portion_grams: int | str | None = None, - note: str | None = None, + patient: Patient, + type_: str, + period: int, + page: int | str | None = None, + glucose_type: int | None = None, ) -> Any: - """Estimate calories and nutrition from a meal photo. The input names map - to the API's snake_case body (``image``, ``portion_size``, ``meal_type``); - ``portion_grams`` and ``note`` are sent only when provided. - """ return await self._http.send( - _spec.analyze_meal(image, portion_size, meal_type, portion_grams, note) + _spec.measures_graph(patient, type_, period, page, glucose_type) ) + async def add_list(self, patient: Patient, data: MeasureRows) -> Any: + return await self._http.send(_spec.add_measures(patient, data)) -class AsyncLaboratoryResource: - """Patient laboratory results, the orderable test catalog, and test pre-ordering.""" + async def add(self, patient: Patient, type_: str, fields: dict[str, Any]) -> Any: + return await self._http.send(_spec.add_measure(patient, type_, fields)) - def __init__(self, http: AsyncHttpClient) -> None: - self._http = http - - async def results(self, page: int | str | None = None) -> Any: - """The patient's completed/in-progress lab results. ``page`` defaults to 1 - server-side; the ``/{page}`` segment is omitted when ``page`` is None. Some - result ids carry a ``-lab`` suffix (TMC-lab-ordered tests).""" - return await self._http.send(_spec.lab_results(page)) - - async def result_detail(self, test_id: str) -> Any: - """Detail of a single result. ``test_id`` is a **string** (a plain id such as - ``"123"`` or a ``"-lab"`` TMC id) and is interpolated verbatim.""" - return await self._http.send(_spec.lab_result_detail(test_id)) - - async def catalog(self) -> Any: - """The orderable test-group catalog.""" - return await self._http.send(_spec.lab_catalog()) - - async def catalog_detail(self, id: int | str) -> Any: - """A single catalog group by id.""" - return await self._http.send(_spec.lab_catalog_detail(id)) - - async def order( - self, test_id: int | str, address_id: int | str, laboratory_id: int | str + async def update( + self, patient: Patient, type_: str, measure_id: int | str, fields: dict[str, Any] ) -> Any: - """Pre-order a lab test. All three ids are required; success returns - ``{"preOrderId": ...}``.""" - return await self._http.send(_spec.add_laboratory_test(test_id, address_id, laboratory_id)) + return await self._http.send(_spec.update_measure(patient, type_, measure_id, fields)) + async def delete(self, patient: Patient, type_: str, measure_id: int | str) -> Any: + return await self._http.send(_spec.delete_measure(patient, type_, measure_id)) -class AsyncDietsResource: - """The patient's diet lists (a dietitian's "Diyet Listesi"). JSON only.""" - - def __init__(self, http: AsyncHttpClient) -> None: - self._http = http - - async def list(self, page: int | str | None = None) -> Any: - """The patient's diet lists. ``page`` defaults to 1 server-side (page size is - fixed to 10); the ``/{page}`` segment is omitted when ``page`` is None.""" - return await self._http.send(_spec.diet_list(page)) - - async def detail(self, list_id: int | str) -> Any: - """A single diet list (array of meal-time groups) by ``list_id``.""" - return await self._http.send(_spec.diet_detail(list_id)) + async def health_information( + self, + *, + identity: str | None = None, + phone_number: str | None = None, + data: MeasureRows, + ) -> Any: + """.. deprecated:: Prefer :meth:`add_list`; see the sync counterpart.""" + return await self._http.send(_spec.health_information(identity, phone_number, data)) diff --git a/src/bulutklinik/client.py b/src/bulutklinik/client.py index 0448722..b06f6c1 100644 --- a/src/bulutklinik/client.py +++ b/src/bulutklinik/client.py @@ -8,84 +8,95 @@ from . import aresources, resources from ._http import AsyncHttpClient, HttpClient from ._spec import AuthMode, RequestSpec -from .config import Environment, resolve_base_url +from .config import ApiVersion, Environment, resolve_base_url from .tokens import InMemoryTokenStore, TokenStore +_BOTH_CREDENTIALS = ( + "Pass either partner_token or token_store, not both. Seed your own store " + "with the token if you need custom persistence." +) + + +def _resolve_store(partner_token: str | None, token_store: TokenStore | None) -> TokenStore: + # Either the literal or the store is the source of truth for the credential. + # Guessing which one the caller meant is how credential bugs get shipped. + if partner_token is not None and token_store is not None: + raise ValueError(_BOTH_CREDENTIALS) + return token_store if token_store is not None else InMemoryTokenStore(partner_token) + class BulutklinikClient: - """Synchronous Bulutklinik API client. Construct once and reuse; service - groups are exposed as attributes. Usable as a context manager. + """Synchronous Bulutklinik partner API client. Construct once and reuse; + service groups are exposed as attributes. Usable as a context manager. + + Every call runs on the company-scoped ``/outher`` surface with the partner + token issued for your integration: you act on the patients of **your own + company**, and the patient is named inline on each request — there is no + login and no session. Example:: - with BulutklinikClient(environment="test", client_id="…", client_secret="…") as client: - client.auth.connect("patient@example.com", "•••", "email") - result = client.doctors.quick_search("kardiyo") + with BulutklinikClient(environment="test", partner_token="…") as client: + branches = client.doctors.branches() + latest = client.measures.last({"identityNumber": "12345678901"}) """ def __init__( self, *, environment: Environment | str = Environment.PRODUCTION, + api_version: ApiVersion | str = ApiVersion.V3, base_url: str | None = None, lang: str = "tr", - client_id: str | None = None, - client_secret: str | None = None, partner_token: str | None = None, token_store: TokenStore | None = None, timeout: float = 30.0, transport: httpx.BaseTransport | None = None, ) -> None: - store: TokenStore = token_store or InMemoryTokenStore() + store = _resolve_store(partner_token, token_store) client = httpx.Client(timeout=timeout, transport=transport) self._http = HttpClient( - base_url=resolve_base_url(environment, base_url), + base_url=resolve_base_url(environment, base_url, api_version), lang=lang, - client_id=client_id, - client_secret=client_secret, - partner_token=partner_token, token_store=store, client=client, ) + #: Write a newly issued partner token here to rotate the credential + #: without rebuilding the client. self.token_store = store - self.auth = resources.AuthResource(self._http) self.doctors = resources.DoctorsResource(self._http) self.slots = resources.SlotsResource(self._http) self.appointments = resources.AppointmentsResource(self._http) - self.payments = resources.PaymentsResource(self._http) self.measures = resources.MeasuresResource(self._http) - self.skin = resources.SkinResource(self._http) - self.meals = resources.MealsResource(self._http) self.laboratory = resources.LaboratoryResource(self._http) self.diets = resources.DietsResource(self._http) - self.addresses = resources.AddressesResource(self._http) def request( self, method: str, path: str, *, - auth: AuthMode = "bearer", + auth: AuthMode = "partner", body: dict[str, Any] | None = None, ) -> Any: """Escape hatch: call any Bulutklinik API endpoint that does not yet have a typed resource method. The request still goes through the shared transport, so default headers, - the chosen ``auth`` mode (``"bearer"`` by default), silent token refresh + - retry, envelope unwrapping and the typed error hierarchy all apply. Returns - the unwrapped ``data`` payload. Prefer a typed resource method when one - exists; reach for ``request`` only for the gaps. + the chosen ``auth`` mode (``"partner"`` by default), envelope unwrapping + and the typed error hierarchy all apply. Returns the unwrapped ``data`` + payload. Prefer a typed resource method when one exists. :param method: HTTP method (``"GET"`` / ``"POST"`` / ``"PUT"`` / ``"DELETE"``). - :param path: Path relative to the configured base URL, e.g. ``"/patients/allBranches"``. - :param auth: Auth mode — ``"public"`` / ``"bearer"`` (default) / ``"partner"``. + :param path: Path relative to the configured base URL, e.g. ``"/outher/branches"``. + :param auth: Auth mode — ``"partner"`` (default) / ``"public"``. :param body: Optional JSON payload (a dict); omitted on ``GET``. Example:: - branches = client.request("GET", "/patients/allBranches") - created = client.request("POST", "/patients/someNewEndpoint", body={"foo": "bar"}) + branches = client.request("GET", "/outher/branches") + # "public" reaches unauthenticated endpoints outside the partner surface + config = client.request("GET", "/general/getConfig", auth="public") """ return self._http.send(RequestSpec(method, path, auth, body)) @@ -105,71 +116,50 @@ def __exit__( class AsyncBulutklinikClient: - """Asynchronous Bulutklinik API client. Usable as an async context manager.""" + """Asynchronous Bulutklinik partner API client. Usable as an async context + manager. Same surface and semantics as :class:`BulutklinikClient`.""" def __init__( self, *, environment: Environment | str = Environment.PRODUCTION, + api_version: ApiVersion | str = ApiVersion.V3, base_url: str | None = None, lang: str = "tr", - client_id: str | None = None, - client_secret: str | None = None, partner_token: str | None = None, token_store: TokenStore | None = None, timeout: float = 30.0, transport: httpx.AsyncBaseTransport | None = None, ) -> None: - store: TokenStore = token_store or InMemoryTokenStore() + store = _resolve_store(partner_token, token_store) client = httpx.AsyncClient(timeout=timeout, transport=transport) self._http = AsyncHttpClient( - base_url=resolve_base_url(environment, base_url), + base_url=resolve_base_url(environment, base_url, api_version), lang=lang, - client_id=client_id, - client_secret=client_secret, - partner_token=partner_token, token_store=store, client=client, ) self.token_store = store - self.auth = aresources.AsyncAuthResource(self._http) self.doctors = aresources.AsyncDoctorsResource(self._http) self.slots = aresources.AsyncSlotsResource(self._http) self.appointments = aresources.AsyncAppointmentsResource(self._http) - self.payments = aresources.AsyncPaymentsResource(self._http) self.measures = aresources.AsyncMeasuresResource(self._http) - self.skin = aresources.AsyncSkinResource(self._http) - self.meals = aresources.AsyncMealsResource(self._http) self.laboratory = aresources.AsyncLaboratoryResource(self._http) self.diets = aresources.AsyncDietsResource(self._http) - self.addresses = aresources.AsyncAddressesResource(self._http) async def request( self, method: str, path: str, *, - auth: AuthMode = "bearer", + auth: AuthMode = "partner", body: dict[str, Any] | None = None, ) -> Any: - """Escape hatch: call any Bulutklinik API endpoint that does not yet have - a typed resource method. - - The request still goes through the shared transport, so default headers, - the chosen ``auth`` mode (``"bearer"`` by default), silent token refresh + - retry, envelope unwrapping and the typed error hierarchy all apply. Returns - the unwrapped ``data`` payload. Prefer a typed resource method when one - exists; reach for ``request`` only for the gaps. - - :param method: HTTP method (``"GET"`` / ``"POST"`` / ``"PUT"`` / ``"DELETE"``). - :param path: Path relative to the configured base URL, e.g. ``"/patients/allBranches"``. - :param auth: Auth mode — ``"public"`` / ``"bearer"`` (default) / ``"partner"``. - :param body: Optional JSON payload (a dict); omitted on ``GET``. + """Escape hatch — see :meth:`BulutklinikClient.request`. Example:: - branches = await client.request("GET", "/patients/allBranches") - created = await client.request("POST", "/patients/someNewEndpoint", body={"foo": "bar"}) + branches = await client.request("GET", "/outher/branches") """ return await self._http.send(RequestSpec(method, path, auth, body)) diff --git a/src/bulutklinik/config.py b/src/bulutklinik/config.py index 8340bab..42aa819 100644 --- a/src/bulutklinik/config.py +++ b/src/bulutklinik/config.py @@ -9,15 +9,29 @@ class Environment(str, Enum): LOCAL = "local" -_BASE_URLS: dict[Environment, str] = { - Environment.PRODUCTION: "https://api.bulutklinik.com/api/v3", - Environment.TEST: "https://apitest.bulutklinik.com/api/v3", - Environment.LOCAL: "https://api-bulutklinik.test/api/v3", +class ApiVersion(str, Enum): + """API version segment. The ``/outher`` surface is route-for-route identical + on both, so switching is configuration rather than a code change.""" + + V3 = "v3" + V4 = "v4" + + +#: API roots per environment. The base URL is ``/``. +API_ROOTS: dict[Environment, str] = { + Environment.PRODUCTION: "https://api.bulutklinik.com/api", + Environment.TEST: "https://apitest.bulutklinik.com/api", + Environment.LOCAL: "https://api-bulutklinik.test/api", } -def resolve_base_url(environment: Environment | str, base_url: str | None) -> str: +def resolve_base_url( + environment: Environment | str, + base_url: str | None, + api_version: ApiVersion | str = ApiVersion.V3, +) -> str: if base_url is not None: return base_url.rstrip("/") env = Environment(environment) if isinstance(environment, str) else environment - return _BASE_URLS[env].rstrip("/") + version = ApiVersion(api_version) if isinstance(api_version, str) else api_version + return f"{API_ROOTS[env].rstrip('/')}/{version.value}" diff --git a/src/bulutklinik/errors.py b/src/bulutklinik/errors.py index 2dae7d8..eec0ef0 100644 --- a/src/bulutklinik/errors.py +++ b/src/bulutklinik/errors.py @@ -41,11 +41,14 @@ class ValidationError(ApiError): class AuthenticationError(ApiError): - """401, a logout (result_type 2), or a failed token refresh.""" + """401, a revoked token (result_type 2), or an expired one (result_type 4).""" class AuthorizationError(ApiError): - """403 — authenticated but not permitted / out of scope.""" + """403 — the token authenticated but is not permitted. Either it lacks the + ``apiouther`` scope or it resolves to a user with no company. The company + boundary comes from the token, never from request input, so retrying with + different body parameters will not help.""" class NotFoundError(ApiError): @@ -80,6 +83,14 @@ def create_api_error( if result_type == 2: return AuthenticationError(message, **kwargs) + # `result_type 4` used to trigger a silent refresh. On the partner surface + # there is nothing to refresh, so say what the caller actually has to do. + if result_type == 4: + return AuthenticationError( + f"{message} The partner token is expired or invalid — install a newly " + "issued token; the SDK cannot refresh it.", + **kwargs, + ) if (isinstance(error_type, str) and error_type.lower() == "validation") or http_status == 422: return ValidationError(message, **kwargs) diff --git a/src/bulutklinik/models.py b/src/bulutklinik/models.py deleted file mode 100644 index e590b76..0000000 --- a/src/bulutklinik/models.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class LoginResult: - """Result of ``auth.connect``. When ``two_factor_required`` is True, pass - ``two_factor_response`` (with the SMS code) to ``connect_with_two_factor``.""" - - two_factor_required: bool - two_factor_response: str | None = None diff --git a/src/bulutklinik/resources.py b/src/bulutklinik/resources.py index 3d14690..e0d0cf7 100644 --- a/src/bulutklinik/resources.py +++ b/src/bulutklinik/resources.py @@ -1,566 +1,288 @@ -"""Synchronous service resources.""" +"""Synchronous resource groups. + +Every call runs on the company-scoped ``/outher`` surface with the partner token +issued for your integration: you act on the patients of **your own company**, and +the patient is named inline on each request — there is no login and no session. + +Sync and async resources share the same :mod:`._spec` builders, so request shapes +can never drift between them. +""" from __future__ import annotations from typing import Any from . import _spec -from ._auth import ( - connect_body, - finish_login, - register_body, - store_tokens, - verify_registration_body, -) from ._http import HttpClient -from ._spec import RequestSpec -from .models import LoginResult - -# Module-level alias: inside MeasuresResource the `list` method shadows the -# builtin `list`, so `list[...]` annotations there must use this instead. -_MeasureRecords = list[dict[str, Any]] - - -class AuthResource: - def __init__(self, http: HttpClient) -> None: - self._http = http - - def connect( - self, - api_user_name: str, - api_user_password: str | None, - login_mode: str, - *, - client_id: str | None = None, - client_secret: str | None = None, - with_phone_number: str | None = None, - ) -> LoginResult: - body = connect_body( - api_user_name, - api_user_password, - client_id or self._http.client_id, - client_secret or self._http.client_secret, - login_mode, - with_phone_number, - ) - data = self._http.send(RequestSpec("POST", "/general/connectApi", "public", body)) - return finish_login(data, self._http.token_store) - - def connect_with_two_factor(self, sms_verification_code: str, response: str) -> None: - data = self._http.send( - RequestSpec( - "POST", - "/general/connectApiWithTwoFactor", - "public", - {"smsVerificationCode": sms_verification_code, "response": response}, - ) - ) - store_tokens(data, self._http.token_store) - - def verify_registration( - self, - *, - name: str, - surname: str, - phone_number: str, - phone_code: str, - email: str, - password: str, - accept_user_agreement: int = 1, - recaptcha_v2: str | None = None, - captcha: str | None = None, - user_agreements: list[Any] | None = None, - ) -> Any: - """Registration step 1: send the verification code, return the ``response`` blob. - - Uses the configured **partner** token (the endpoint is behind ``auth:apiusers``, - not public). A CAPTCHA token (``recaptcha_v2`` or ``captcha``), minted by a - browser/human, is required. Feed the returned ``response`` (and the code the - user receives) into :meth:`register`. - """ - body = verify_registration_body( - name, - surname, - phone_number, - phone_code, - email, - password, - accept_user_agreement, - recaptcha_v2, - captcha, - user_agreements, - ) - return self._http.send( - RequestSpec("POST", "/patients/verifyAddingNewPatient", "partner", body) - ) - def register( - self, - *, - name: str, - surname: str, - api_user_name: str, - phone_number: str, - password: str, - sms_verification_code: str, - response: str, - accept_user_agreement: int = 1, - client_id: str | None = None, - client_secret: str | None = None, - ) -> None: - body = register_body( - name, - surname, - api_user_name, - phone_number, - password, - sms_verification_code, - response, - accept_user_agreement, - client_id or self._http.client_id, - client_secret or self._http.client_secret, - ) - data = self._http.send(RequestSpec("POST", "/patients/addNewPatient", "public", body)) - store_tokens(data, self._http.token_store) +#: ``patient`` shapes accepted by the endpoints below: +#: read -> {"identityNumber": str | None, "phoneNumber": str | None} +#: identityNumber is primary; phoneNumber is accepted only when it +#: matches exactly one patient in your company. +#: write -> {"name": str, "surname": str, "phoneNumber": str, ...optional} +#: the patient is created inside your company when absent. +Patient = dict[str, Any] - def confirm_registration_email( - self, - *, - verification_code: str, - response: str, - user_agreements: list[Any] | None = None, - ) -> Any: - """Registration step 2 (e-mail branch): confirm the e-mailed code, get the SMS blob. +# Module-level alias on purpose: the measures resource defines a method called +# ``list``, which shadows the builtin inside the class body, so ``list[...]`` +# cannot be spelled in those signatures. +MeasureRows = list[dict[str, Any]] - When :meth:`verify_registration` returned ``confirmationType == "email"``, confirm - the code here with the same ``response`` blob. Returns a fresh ``response`` blob + - ``confirmationType: "sms"`` to feed into :meth:`register`. Public. - """ - return self._http.send( - _spec.confirm_registration_email(verification_code, response, user_agreements) - ) - def verify_registration_social( - self, - *, - name: str, - surname: str, - phone_number: str, - password: str, - social_type: str, - key: str, - email: str | None = None, - accept_user_agreement: int = 1, - user_agreements: list[Any] | None = None, - ) -> Any: - """Social sign-up step 1: send the SMS code, return a ``response`` blob. Public.""" - return self._http.send( - _spec.verify_registration_social( - name, - surname, - phone_number, - password, - social_type, - key, - email, - accept_user_agreement, - user_agreements, - ) - ) +class DoctorsResource: + """Doctor discovery. Scoped to the doctors enabled for your integration, so a + doctor returned here is one you can actually book.""" - def register_social( - self, - *, - sms_verification_code: str, - response: str, - user_agreements: list[Any] | None = None, - ) -> Any: - """Social sign-up step 2: create the social patient. Does NOT log in — call - :meth:`connect` with ``login_mode="social"`` afterwards. Public. - """ - return self._http.send( - _spec.register_social(sms_verification_code, response, user_agreements) - ) + def __init__(self, http: HttpClient) -> None: + self._http = http - def forgot_password( + def search( self, - *, - phone_number: str, - birthdate: str | None = None, - recaptcha_v2: str | None = None, - captcha: str | None = None, + search_params: dict[str, Any], + current_page: int = 1, + order_params: list[str] | None = None, ) -> Any: - """Password reset step 1: send the SMS confirm code, return a ``response`` blob. + """``order_params``: ``name`` / ``order`` / ``slot``. - A CAPTCHA token (``recaptcha_v2`` or ``captcha``) is required outside local env. Public. + ``search_params`` must carry at least one key: the server rule is + ``required|array`` and PHP's ``required`` rejects an empty array, so ``{}`` + is a validation error rather than an unfiltered search. """ - return self._http.send( - _spec.forgot_password(phone_number, birthdate, recaptcha_v2, captcha) - ) - - def reset_password( - self, - *, - sms_confirm_code: str, - response: str, - password: str, - ) -> Any: - """Password reset step 2: set the new password with the SMS confirm code + blob. Public.""" - return self._http.send(_spec.reset_password(sms_confirm_code, response, password)) - - def refresh(self) -> None: - self._http.refresh() - - def disconnect(self) -> None: - try: - self._http.send(RequestSpec("POST", "/general/disconnectApi", "bearer", {})) - finally: - self._http.token_store.clear() - - -class DoctorsResource: - def __init__(self, http: HttpClient) -> None: - self._http = http + return self._http.send(_spec.doctor_search(search_params, current_page, order_params)) def branches(self) -> Any: return self._http.send(_spec.branches()) + def detail(self, doctor_id: int | str) -> Any: + """The ``doctor_id`` here feeds :meth:`SlotsResource.schedule`.""" + return self._http.send(_spec.doctor_detail(doctor_id)) + def locations(self) -> Any: + """City list. Global catalogue — not scoped to your company.""" return self._http.send(_spec.locations()) - def quick_search( - self, search_text: str, list_type: str | None = None, location: str | None = None - ) -> Any: - return self._http.send(_spec.quick_search(search_text, list_type, location)) - - def search( - self, - *, - search_params: dict[str, Any] | None = None, - order_params: list[str] | None = None, - other_params: list[str] | None = None, - current_page: int = 1, - per_page_limit: int = 20, - ) -> Any: - return self._http.send( - _spec.doctor_search( - search_params or {}, - order_params or [], - other_params or [], - current_page, - per_page_limit, - ) - ) - - def detail(self, doctor_id: int | str, corporate: int | str | None = None) -> Any: - return self._http.send(_spec.doctor_detail(doctor_id, corporate)) - class SlotsResource: + """Doctor availability.""" + def __init__(self, http: HttpClient) -> None: self._http = http def schedule( self, doctor_id: int | str, - list_type: str, *, schedule_date: str | None = None, - schedule_step: int | str = 7, - schedule_page: int | str = 1, + schedule_step: int | None = None, + schedule_page: int | None = None, ) -> Any: - return self._http.send( - _spec.schedule(doctor_id, list_type, schedule_date, schedule_step, schedule_page) - ) - + """Either pass ``schedule_date`` (``Y-m-d``), or page through with + ``schedule_step`` + ``schedule_page``; the server requires one form. -class AppointmentsResource: - def __init__(self, http: HttpClient) -> None: - self._http = http - - def reserve_interview( - self, doctor_id: int | str, appointment_date: str, appointment_type: str = "interview" - ) -> Any: + Returns a date-keyed map; ``slotId`` feeds + :meth:`AppointmentsResource.reserve`. + """ return self._http.send( - _spec.reserve_interview(doctor_id, appointment_date, appointment_type) + _spec.slot_schedule(doctor_id, schedule_date, schedule_step, schedule_page) ) - def add_physical(self, doctor_id: int | str, appointment_date: str) -> Any: - return self._http.send(_spec.add_physical(doctor_id, appointment_date)) - - def cancel(self, event_id: int | str) -> Any: - return self._http.send(_spec.cancel_appointment(event_id)) - - def list(self, page: int | str | None = None) -> Any: - """The patient's appointments (``{foundAppointmentsCount, foundAppointments}``). - Each item's ``event_id`` is the id for :meth:`cancel`; rows with ``event_id`` ``"0"`` - are paid-order/refund entries (not cancellable). Server paging is disabled — page 1 - (the default) returns the full list. - """ - return self._http.send(_spec.user_appointments(page)) - - def reservations(self) -> Any: - """The patient's active online-slot reservation holds (with a ``minute_diff`` countdown).""" - return self._http.send(_spec.user_reservations()) +class AppointmentsResource: + """Appointment lifecycle. + The patient is supplied inline as ``user`` — there is no session in this mode. + On write the server materialises the patient inside your company. -class AddressesResource: - """The patient's saved addresses. Required by ``laboratory.order`` (needs an ``addressId``).""" + **Payment is never taken through the API.** ``reserve`` returns a ``url`` for + the patient to complete agreements and payment in a browser; use + ``without_agreement=True`` plus :meth:`create` when your own flow already + collected them. + """ def __init__(self, http: HttpClient) -> None: self._http = http - def list(self) -> Any: - """List saved addresses (default first). Each item's ``id`` is the ``addressId``.""" - return self._http.send(_spec.address_list()) - - def add( + def reserve( self, + slot_id: int | str, + doctor_id: int | str, + user: Patient, *, - title: str, - city_id: int | str, - district_id: int | str, - address: str, - location_lat: str, - location_lng: str, - description: str | None = None, - is_default: int | None = None, + without_agreement: bool = False, ) -> Any: - """Add an address. Success → ``{"addressId": ...}``. ``city_id`` comes from - ``doctors.locations()``; ``district_id`` from ``GET /getConfig`` (``cities[].districts[]``). - """ - return self._http.send( - _spec.address_add( - title, - city_id, - district_id, - address, - location_lat, - location_lng, - description, - is_default, - ) - ) + """Hold an online slot. - def update( - self, - address_id: int | str, - *, - title: str | None = None, - description: str | None = None, - city_id: int | str | None = None, - district_id: int | str | None = None, - address: str | None = None, - location_lat: str | None = None, - location_lng: str | None = None, - is_default: int | None = None, - ) -> Any: - """Update an address by id. Pass ``is_default=1`` alone to flip the default flag, - or any other field to edit it. + Default: returns ``{"url", "hash"}`` — hand ``url`` to the patient. + With ``without_agreement=True``: returns ``{"hash", …, "reservationExpired"}`` + for you to confirm with :meth:`create` before the hold expires. """ - return self._http.send( - _spec.address_update( - address_id, - title=title, - description=description, - city_id=city_id, - district_id=district_id, - address=address, - location_lat=location_lat, - location_lng=location_lng, - is_default=is_default, - ) - ) - - def delete(self, address_id: int | str) -> Any: - """Delete an address by id (default/used addresses cannot be deleted).""" - return self._http.send(_spec.address_delete(address_id)) - - -class PaymentsResource: - def __init__(self, http: HttpClient) -> None: - self._http = http - - def check_discount_code( - self, - check_type: str, - discount_code: str, - *, - doctor_id: int | str | None = None, - order_id: int | str | None = None, - special_service_id: int | str | None = None, - program_slug: str | None = None, - ) -> Any: - return self._http.send( - _spec.check_discount_code( - check_type, discount_code, doctor_id, order_id, special_service_id, program_slug - ) - ) + return self._http.send(_spec.reserve(slot_id, doctor_id, user, without_agreement)) - def get_cards(self) -> Any: - return self._http.send(_spec.get_cards()) + def instant_reserve(self, user: Patient) -> Any: + """Instant reservation — no slot; the server picks an available doctor.""" + return self._http.send(_spec.instant_reserve(user)) - def save_card( - self, - card_holder: str, - card_number: str, - card_exp_month: str, - card_exp_year: str, - card_cvv: str, - ) -> Any: - return self._http.send( - _spec.save_card(card_holder, card_number, card_exp_month, card_exp_year, card_cvv) - ) + def create(self, hash_: str, outher_process_id: int | str) -> Any: + """Turn a reservation into a confirmed appointment. Both arguments come + from the reservation response.""" + return self._http.send(_spec.create_appointment(hash_, outher_process_id)) - def pay( + def create_without_slot( self, doctor_id: int | str, - appointment_date: str, - is_3d: bool, - terms_accept: bool, + start_date: str, + finish_date: str, + user: Patient, *, - appointment_type: str = "interview", - card_info: dict[str, str] | None = None, - card_id: int | str | None = None, - save_card: int = 0, - discount_code: str = "", - case_detail: str | None = None, + is_outher_doctor: int | None = None, ) -> Any: + """Book a free-form range outside the slot grid, for integrations running + their own calendar.""" return self._http.send( - _spec.pay( - doctor_id, - appointment_date, - is_3d, - terms_accept, - appointment_type, - card_info, - card_id, - save_card, - discount_code, - case_detail, + _spec.appointment_without_slot( + doctor_id, start_date, finish_date, user, is_outher_doctor ) ) - def delete_card(self, card_id: int | str) -> Any: - return self._http.send(_spec.delete_card(card_id)) + def cancel_without_slot(self, lookup: dict[str, Any]) -> Any: + """Cancel an appointment created with :meth:`create_without_slot` — and + only those; ones confirmed through :meth:`create` are not cancellable here. + Address it either by process (``hash`` + ``outherProcessId``) or by + coordinates (``doctorId`` + ``appointmentDate`` + ``isOutherDoctor``). + """ + return self._http.send(_spec.cancel_without_slot(lookup)) -class MeasuresResource: - def __init__(self, http: HttpClient) -> None: - self._http = http + def list( + self, phone_number: str, page: int | str | None = None, type_: str | None = None + ) -> Any: + """The appointments **you** created for that phone number, not the + patient's history across the platform. ``type_``: ``normal`` / ``instant``.""" + return self._http.send(_spec.appointment_list(phone_number, page, type_)) - def add_list(self, records: _MeasureRecords) -> Any: - return self._http.send(_spec.add_measures(records)) + def info(self, lookup: dict[str, Any]) -> Any: + """One appointment; same lookup shape as :meth:`cancel_without_slot`.""" + return self._http.send(_spec.appointment_info(lookup)) - def add(self, measure_type: str, fields: dict[str, Any]) -> Any: - return self._http.send(_spec.add_measure(measure_type, fields)) + def check_doctor(self, doctor_id: int | str, is_outher_doctor: int) -> Any: + """Whether a doctor is bookable through your integration. Fails with 501 + when they are not — call it before offering a doctor.""" + return self._http.send(_spec.check_doctor(doctor_id, is_outher_doctor)) - def update(self, measure_type: str, fields: dict[str, Any]) -> Any: - return self._http.send(_spec.update_measure(measure_type, fields)) - def delete(self, measure_type: str, measure_id: int | str) -> Any: - return self._http.send(_spec.delete_measure(measure_type, measure_id)) +class DietsResource: + """Diet lists recorded for a patient **inside your own company**. + Lists written by other clinics are not visible here.""" - def last(self) -> Any: - return self._http.send(_spec.measures_last()) + def __init__(self, http: HttpClient) -> None: + self._http = http - def list(self, measure_type: str, page: int | str, glucose_type: int | None = None) -> Any: - return self._http.send(_spec.measures_list(measure_type, page, glucose_type)) + def list(self, patient: Patient, page: int | str | None = None) -> Any: + """Page size is fixed to 20 server-side.""" + return self._http.send(_spec.diet_list(patient, page)) - def graph( - self, measure_type: str, period: int, page: int | str, glucose_type: int | None = None - ) -> Any: - return self._http.send(_spec.measures_graph(measure_type, period, page, glucose_type)) + def detail(self, patient: Patient, list_id: int | str) -> Any: + """``list_id`` comes from :meth:`list`.""" + return self._http.send(_spec.diet_detail(patient, list_id)) - def partner_health_information( - self, *, identity: str | None = None, phone_number: str | None = None, data: _MeasureRecords - ) -> Any: - return self._http.send(_spec.partner_health_information(identity, phone_number, data)) +class LaboratoryResource: + """Laboratory catalogue (global, static) and results (your company only). -class SkinResource: - """AI skin-lesion analysis ("Cildimde Neyim Var").""" + Ordering a test is not available here — it creates a financial record. + """ def __init__(self, http: HttpClient) -> None: self._http = http - def analyze(self, images: list[dict[str, Any]]) -> Any: - """Analyze one or more skin photos. Each image is classified (lesion - ``label``), summarized in Turkish (``comment``), and returned with quality - flags, a ``confidence``, possible ICD hints and an opaque ``case_detail`` - blob. The ``case_detail`` may be forwarded verbatim as a payment's - ``caseDetail``. - """ - return self._http.send(_spec.image_check(images)) + def catalog(self) -> Any: + return self._http.send(_spec.lab_catalog()) + def catalog_detail(self, test_id: int | str) -> Any: + """Prices are the plain list prices — the patient-side discount pass does + not apply here.""" + return self._http.send(_spec.lab_catalog_detail(test_id)) -class MealsResource: - """AI meal-photo calorie/nutrition estimation (sibling of ``skin``).""" + def results(self, patient: Patient, page: int | str | None = None) -> Any: + """Ids returned here are accepted verbatim by :meth:`result_detail`; a + ``-lab`` suffix marks a TmcLab order group, a plain number an HBYS lab + request.""" + return self._http.send(_spec.lab_results(patient, page)) - def __init__(self, http: HttpClient) -> None: - self._http = http + def result_detail(self, patient: Patient, test_id: int | str) -> Any: + return self._http.send(_spec.lab_result_detail(patient, test_id)) - def analyze( - self, - image: str, - portion_size: str, - meal_type: str, - *, - portion_grams: int | str | None = None, - note: str | None = None, - ) -> Any: - """Estimate calories and nutrition from a meal photo. The input names map - to the API's snake_case body (``image``, ``portion_size``, ``meal_type``); - ``portion_grams`` and ``note`` are sent only when provided. - """ - return self._http.send( - _spec.analyze_meal(image, portion_size, meal_type, portion_grams, note) - ) +class MeasuresResource: + """Health measurements. -class LaboratoryResource: - """Patient laboratory results, the orderable test catalog, and test pre-ordering.""" + **Scope:** written into and read from **your own company**. Values the patient + entered in the Bulutklinik mobile app are not visible here, and a value you + write does not appear in their app — a consequence of tenant isolation, not a + bug. + + Writes take the descriptive ``patient`` shape (created if absent); reads and + edits take the lighter reference. + """ def __init__(self, http: HttpClient) -> None: self._http = http - def results(self, page: int | str | None = None) -> Any: - """The patient's completed/in-progress lab results. ``page`` defaults to 1 - server-side; the ``/{page}`` segment is omitted when ``page`` is None. Some - result ids carry a ``-lab`` suffix (TMC-lab-ordered tests).""" - return self._http.send(_spec.lab_results(page)) - - def result_detail(self, test_id: str) -> Any: - """Detail of a single result. ``test_id`` is a **string** (a plain id such as - ``"123"`` or a ``"-lab"`` TMC id) and is interpolated verbatim.""" - return self._http.send(_spec.lab_result_detail(test_id)) - - def catalog(self) -> Any: - """The orderable test-group catalog.""" - return self._http.send(_spec.lab_catalog()) + def last(self, patient: Patient) -> Any: + """Most recent value of every measurement type.""" + return self._http.send(_spec.last_measures(patient)) - def catalog_detail(self, id: int | str) -> Any: - """A single catalog group by id.""" - return self._http.send(_spec.lab_catalog_detail(id)) + def list( + self, + patient: Patient, + type_: str, + page: int | str | None = None, + glucose_type: int | None = None, + ) -> Any: + """``glucose_type`` applies to ``glucose`` only (0=fasting, 1=postprandial).""" + return self._http.send(_spec.measures_list(patient, type_, page, glucose_type)) - def order(self, test_id: int | str, address_id: int | str, laboratory_id: int | str) -> Any: - """Pre-order a lab test. All three ids are required; success returns - ``{"preOrderId": ...}``.""" - return self._http.send(_spec.add_laboratory_test(test_id, address_id, laboratory_id)) + def graph( + self, + patient: Patient, + type_: str, + period: int, + page: int | str | None = None, + glucose_type: int | None = None, + ) -> Any: + """``period``: 1=day, 2=week, 3=month, 4=year.""" + return self._http.send(_spec.measures_graph(patient, type_, period, page, glucose_type)) + def add_list(self, patient: Patient, data: MeasureRows) -> Any: + """Write several measurements of mixed types in one transaction. Max 200 rows.""" + return self._http.send(_spec.add_measures(patient, data)) -class DietsResource: - """The patient's diet lists (a dietitian's "Diyet Listesi"). JSON only.""" + def add(self, patient: Patient, type_: str, fields: dict[str, Any]) -> Any: + return self._http.send(_spec.add_measure(patient, type_, fields)) - def __init__(self, http: HttpClient) -> None: - self._http = http + def update( + self, patient: Patient, type_: str, measure_id: int | str, fields: dict[str, Any] + ) -> Any: + """``measure_id`` comes from :meth:`list`.""" + return self._http.send(_spec.update_measure(patient, type_, measure_id, fields)) - def list(self, page: int | str | None = None) -> Any: - """The patient's diet lists. ``page`` defaults to 1 server-side (page size is - fixed to 10); the ``/{page}`` segment is omitted when ``page`` is None.""" - return self._http.send(_spec.diet_list(page)) + def delete(self, patient: Patient, type_: str, measure_id: int | str) -> Any: + return self._http.send(_spec.delete_measure(patient, type_, measure_id)) - def detail(self, list_id: int | str) -> Any: - """A single diet list (array of meal-time groups) by ``list_id``.""" - return self._http.send(_spec.diet_detail(list_id)) + def health_information( + self, + *, + identity: str | None = None, + phone_number: str | None = None, + data: MeasureRows, + ) -> Any: + """Legacy bulk submission for ``teusan`` integrations. + + .. deprecated:: + Requires the ``teusan`` scope instead of ``apiouther``, takes a flat + ``identity`` + ``phone_number`` instead of ``patient``, and writes into + the shared consumer tenant rather than your own company — so the + values are not readable through :meth:`last` / :meth:`list`. Prefer + :meth:`add_list`. + """ + return self._http.send(_spec.health_information(identity, phone_number, data)) diff --git a/src/bulutklinik/tokens.py b/src/bulutklinik/tokens.py index 1cddc10..202ba8e 100644 --- a/src/bulutklinik/tokens.py +++ b/src/bulutklinik/tokens.py @@ -5,35 +5,32 @@ @runtime_checkable class TokenStore(Protocol): - """Pluggable token persistence. The default is in-memory; provide your own to - persist tokens to a file, cache, database or session.""" + """Pluggable source for the partner token. - def get_access_token(self) -> str | None: ... + The token is read on **every** request, so pointing this at a file, cache, + database or secret manager lets a long-running process pick up a newly issued + token without being rebuilt. + """ - def get_refresh_token(self) -> str | None: ... + def get_token(self) -> str | None: ... - def set_tokens(self, access_token: str, refresh_token: str | None) -> None: ... + def set_token(self, token: str | None) -> None: ... def clear(self) -> None: ... class InMemoryTokenStore: - """In-memory token store (default). Tokens live for the lifetime of the object.""" + """In-memory token store (default). The token lives for the lifetime of the + object.""" - def __init__(self, access_token: str | None = None, refresh_token: str | None = None) -> None: - self._access = access_token - self._refresh = refresh_token + def __init__(self, token: str | None = None) -> None: + self._token = token - def get_access_token(self) -> str | None: - return self._access + def get_token(self) -> str | None: + return self._token - def get_refresh_token(self) -> str | None: - return self._refresh - - def set_tokens(self, access_token: str, refresh_token: str | None) -> None: - self._access = access_token - self._refresh = refresh_token + def set_token(self, token: str | None) -> None: + self._token = token def clear(self) -> None: - self._access = None - self._refresh = None + self._token = None diff --git a/tests/test_ai.py b/tests/test_ai.py deleted file mode 100644 index b37f04f..0000000 --- a/tests/test_ai.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import httpx - -from bulutklinik import AsyncBulutklinikClient, BulutklinikClient, InMemoryTokenStore -from helpers import body_of, recording_transport - - -def test_skin_analyze_posts_images_with_bearer() -> None: - transport, requests = recording_transport( - lambda req: httpx.Response( - 200, - json={ - "resultType": 0, - "data": {"status": [{"id": 1, "label": "nevus", "case_detail": "blob"}]}, - }, - ) - ) - client = BulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) - - res = client.skin.analyze([{"image": "BASE64", "branch_id": 42}]) - - assert res == {"status": [{"id": 1, "label": "nevus", "case_detail": "blob"}]} - req = requests[0] - assert str(req.url) == "https://apitest.bulutklinik.com/api/v3/patients/imageCheck" - assert req.method == "POST" - assert req.headers["Authorization"] == "Bearer abc" - assert body_of(req) == {"images": [{"image": "BASE64", "branch_id": 42}]} - - -def test_meals_analyze_maps_snake_case_body_with_optionals() -> None: - transport, requests = recording_transport( - lambda req: httpx.Response( - 200, json={"resultType": 0, "data": {"status": {"comment": "{}"}}} - ) - ) - client = BulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) - - client.meals.analyze("BASE64", "custom", "lunch", portion_grams=300, note="az yağlı") - - req = requests[0] - assert str(req.url) == "https://apitest.bulutklinik.com/api/v3/patients/imageAnalyzeMeal" - assert req.method == "POST" - assert req.headers["Authorization"] == "Bearer abc" - assert body_of(req) == { - "image": "BASE64", - "portion_size": "custom", - "meal_type": "lunch", - "portion_grams": 300, - "note": "az yağlı", - } - - -def test_meals_analyze_omits_optionals_when_not_provided() -> None: - transport, requests = recording_transport( - lambda req: httpx.Response( - 200, json={"resultType": 0, "data": {"status": {"comment": "{}"}}} - ) - ) - client = BulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) - - client.meals.analyze("BASE64", "medium", "snack") - - assert body_of(requests[0]) == { - "image": "BASE64", - "portion_size": "medium", - "meal_type": "snack", - } - - -async def test_async_skin_analyze_posts_images() -> None: - transport, requests = recording_transport( - lambda req: httpx.Response( - 200, json={"resultType": 0, "data": {"status": [{"id": 1, "label": "nevus"}]}} - ) - ) - async with AsyncBulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) as client: - res = await client.skin.analyze([{"image": "BASE64"}]) - - assert res == {"status": [{"id": 1, "label": "nevus"}]} - req = requests[0] - assert str(req.url) == "https://apitest.bulutklinik.com/api/v3/patients/imageCheck" - assert body_of(req) == {"images": [{"image": "BASE64"}]} diff --git a/tests/test_async.py b/tests/test_async.py index b2e580c..aac6efa 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -3,68 +3,75 @@ import httpx import pytest -from bulutklinik import AsyncBulutklinikClient, InMemoryTokenStore, NotFoundError +from bulutklinik import ( + AsyncBulutklinikClient, + AuthenticationError, + InMemoryTokenStore, + NotFoundError, +) from helpers import body_of, recording_transport +BASE = "https://apitest.bulutklinik.com/api/v3" + +REF = {"identityNumber": "12345678901"} + + +def _ok(_: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"resultType": 0, "data": {"ok": True}}) + async def test_async_unwraps_and_sends_headers() -> None: - transport, requests = recording_transport( - lambda req: httpx.Response(200, json={"resultType": 0, "data": {"ok": True}}) - ) + transport, requests = recording_transport(_ok) async with AsyncBulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") + environment="test", transport=transport, partner_token="PT" ) as client: - res = await client.measures.last() + res = await client.measures.last(REF) assert res == {"ok": True} - assert requests[0].headers["Authorization"] == "Bearer abc" + assert requests[0].headers["Authorization"] == "Bearer PT" + assert str(requests[0].url) == f"{BASE}/outher/lastMeasures" -async def test_async_refresh_and_retry() -> None: - state = {"n": 0} +async def test_async_expired_token_is_not_retried() -> None: + attempts = {"n": 0} - def responder(req: httpx.Request) -> httpx.Response: - if req.url.path.endswith("/general/refreshApi"): - return httpx.Response( - 200, json={"resultType": 0, "data": {"access_token": "new", "refresh_token": "r2"}} - ) - state["n"] += 1 - if state["n"] == 1: - return httpx.Response(401, json={"resultType": 4}) - return httpx.Response(200, json={"resultType": 0, "data": {"ok": True}}) + def responder(_: httpx.Request) -> httpx.Response: + attempts["n"] += 1 + return httpx.Response(401, json={"resultType": 4}) - transport, _ = recording_transport(responder) - store = InMemoryTokenStore("old", "r") + transport, _requests = recording_transport(responder) + store = InMemoryTokenStore("expired") async with AsyncBulutklinikClient( - environment="test", client_id="c", client_secret="s", transport=transport, token_store=store + environment="test", transport=transport, token_store=store ) as client: - res = await client.measures.last() + with pytest.raises(AuthenticationError, match="cannot refresh it"): + await client.measures.last(REF) - assert res == {"ok": True} - assert store.get_access_token() == "new" + assert attempts["n"] == 1 + assert store.get_token() == "expired" -async def test_async_request_escape_hatch_bearer_get() -> None: - transport, requests = recording_transport( - lambda req: httpx.Response(200, json={"resultType": 0, "data": {"ok": True}}) - ) +async def test_async_request_escape_hatch_defaults_to_partner() -> None: + transport, requests = recording_transport(_ok) async with AsyncBulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") + environment="test", transport=transport, partner_token="PT" ) as client: - res = await client.request("GET", "/patients/customEndpoint") + res = await client.request("GET", "/outher/customEndpoint") assert res == {"ok": True} req = requests[0] - assert str(req.url) == "https://apitest.bulutklinik.com/api/v3/patients/customEndpoint" + assert str(req.url) == f"{BASE}/outher/customEndpoint" assert req.method == "GET" - assert req.headers["Authorization"] == "Bearer abc" + assert req.headers["Authorization"] == "Bearer PT" async def test_async_request_escape_hatch_public_post_body() -> None: transport, requests = recording_transport( lambda req: httpx.Response(200, json={"resultType": 0, "data": {"id": 7}}) ) - async with AsyncBulutklinikClient(environment="test", transport=transport) as client: + async with AsyncBulutklinikClient( + environment="test", transport=transport, partner_token="PT" + ) as client: res = await client.request( "POST", "/general/somePublicEndpoint", auth="public", body={"foo": "bar"} ) @@ -81,7 +88,30 @@ async def test_async_error_mapping() -> None: lambda req: httpx.Response(404, json={"resultType": 1, "errorType": 1}) ) async with AsyncBulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("a") + environment="test", transport=transport, partner_token="PT" ) as client: with pytest.raises(NotFoundError): - await client.doctors.quick_search("x") + await client.doctors.branches() + + +async def test_async_surface_matches_the_sync_one() -> None: + """The async mirror must not drift: same groups, same method names, same paths.""" + transport, requests = recording_transport(_ok) + async with AsyncBulutklinikClient( + environment="test", transport=transport, partner_token="PT" + ) as client: + await client.doctors.locations() + await client.slots.schedule(7, schedule_date="2026-08-01") + await client.appointments.check_doctor(2, 0) + await client.laboratory.catalog() + await client.diets.list(REF) + await client.measures.graph(REF, "weight", 3) + + assert [str(r.url) for r in requests] == [ + f"{BASE}/outher/locations", + f"{BASE}/outher/doctorSlots", + f"{BASE}/outher/checkDoctor", + f"{BASE}/outher/laboratoryCatalog", + f"{BASE}/outher/dietLists", + f"{BASE}/outher/measuresGraph/weight/3", + ] diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index bc367dd..0000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest - -from bulutklinik import ApiError, BulutklinikClient, InMemoryTokenStore -from helpers import body_of, recording_transport - - -def test_connect_stores_tokens_and_fills_credentials() -> None: - transport, requests = recording_transport( - lambda req: httpx.Response( - 200, - json={ - "resultType": 0, - "data": {"access_token": "t", "refresh_token": "r", "password_policy": {}}, - }, - ) - ) - store = InMemoryTokenStore() - client = BulutklinikClient( - environment="test", client_id="c", client_secret="s", transport=transport, token_store=store - ) - - result = client.auth.connect("u", "p", "email") - - assert result.two_factor_required is False - assert store.get_access_token() == "t" - assert store.get_refresh_token() == "r" - body = body_of(requests[0]) - assert body["apiClientId"] == "c" - assert body["apiSecretKey"] == "s" - assert body["loginMode"] == "email" - - -def test_connect_two_factor_challenge() -> None: - transport, _ = recording_transport( - lambda req: httpx.Response(200, json={"resultType": 0, "data": {"response": "BLOB"}}) - ) - client = BulutklinikClient( - environment="test", client_id="c", client_secret="s", transport=transport - ) - - result = client.auth.connect("u", "p", "email") - - assert result.two_factor_required is True - assert result.two_factor_response == "BLOB" - - -def test_disconnect_clears_store_on_error() -> None: - transport, _ = recording_transport( - lambda req: httpx.Response(500, json={"resultType": 1, "errorMessage": "fail"}) - ) - store = InMemoryTokenStore("a", "r") - client = BulutklinikClient(environment="test", transport=transport, token_store=store) - - with pytest.raises(ApiError): - client.auth.disconnect() - assert store.get_access_token() is None diff --git a/tests/test_lab_diets.py b/tests/test_lab_diets.py deleted file mode 100644 index 9573b02..0000000 --- a/tests/test_lab_diets.py +++ /dev/null @@ -1,206 +0,0 @@ -from __future__ import annotations - -import httpx - -from bulutklinik import AsyncBulutklinikClient, BulutklinikClient, InMemoryTokenStore -from helpers import body_of, recording_transport - -BASE = "https://apitest.bulutklinik.com/api/v3" - - -def _ok(data: object) -> tuple[httpx.MockTransport, list[httpx.Request]]: - return recording_transport( - lambda req: httpx.Response(200, json={"resultType": 0, "data": data}) - ) - - -def _sync_client(transport: httpx.MockTransport) -> BulutklinikClient: - return BulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) - - -# --- laboratory (sync) --- - - -def test_lab_results_omits_page_segment_when_none() -> None: - transport, requests = _ok({"foundTestsCount": 0, "foundTests": []}) - client = _sync_client(transport) - - client.laboratory.results() - - req = requests[0] - assert str(req.url) == f"{BASE}/patients/userLabTestList" - assert req.method == "GET" - assert req.headers["Authorization"] == "Bearer abc" - - -def test_lab_results_includes_page_segment() -> None: - transport, requests = _ok({"foundTestsCount": 0, "foundTests": []}) - client = _sync_client(transport) - - client.laboratory.results(3) - - assert str(requests[0].url) == f"{BASE}/patients/userLabTestList/3" - assert requests[0].method == "GET" - - -def test_lab_result_detail_interpolates_string_id_verbatim() -> None: - transport, requests = _ok({"id": "4821-lab", "test_name": "Hemogram"}) - client = _sync_client(transport) - - client.laboratory.result_detail("4821-lab") - - req = requests[0] - assert str(req.url) == f"{BASE}/patients/userLabTestDetail/4821-lab" - assert req.method == "GET" - assert req.headers["Authorization"] == "Bearer abc" - - -def test_lab_catalog() -> None: - transport, requests = _ok({"test_groups": []}) - client = _sync_client(transport) - - client.laboratory.catalog() - - assert str(requests[0].url) == f"{BASE}/patients/allLaboratoryTests" - assert requests[0].method == "GET" - - -def test_lab_catalog_detail() -> None: - transport, requests = _ok({"id": 7, "name": "Grup"}) - client = _sync_client(transport) - - client.laboratory.catalog_detail(7) - - assert str(requests[0].url) == f"{BASE}/patients/laboratoryTestDetail/7" - assert requests[0].method == "GET" - - -def test_lab_order_posts_required_body() -> None: - transport, requests = _ok({"preOrderId": 99}) - client = _sync_client(transport) - - res = client.laboratory.order(12, 34, 56) - - assert res == {"preOrderId": 99} - req = requests[0] - assert str(req.url) == f"{BASE}/patients/addNewLaboratoryTest" - assert req.method == "POST" - assert req.headers["Authorization"] == "Bearer abc" - assert body_of(req) == {"testId": 12, "addressId": 34, "laboratoryId": 56} - - -# --- diets (sync) --- - - -def test_diet_list_omits_page_segment_when_none() -> None: - transport, requests = _ok({"foundDietsCount": 0, "foundDiets": []}) - client = _sync_client(transport) - - client.diets.list() - - req = requests[0] - assert str(req.url) == f"{BASE}/patients/dietLists" - assert req.method == "GET" - assert req.headers["Authorization"] == "Bearer abc" - - -def test_diet_list_includes_page_segment() -> None: - transport, requests = _ok({"foundDietsCount": 0, "foundDiets": []}) - client = _sync_client(transport) - - client.diets.list(2) - - assert str(requests[0].url) == f"{BASE}/patients/dietLists/2" - assert requests[0].method == "GET" - - -def test_diet_detail() -> None: - transport, requests = _ok([{"time": "Sabah", "meals": []}]) - client = _sync_client(transport) - - client.diets.detail(55) - - req = requests[0] - assert str(req.url) == f"{BASE}/patients/diet/55" - assert req.method == "GET" - assert req.headers["Authorization"] == "Bearer abc" - - -# --- laboratory (async) --- - - -async def test_async_lab_results_omits_page_segment_when_none() -> None: - transport, requests = _ok({"foundTestsCount": 0, "foundTests": []}) - async with AsyncBulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) as client: - await client.laboratory.results() - - req = requests[0] - assert str(req.url) == f"{BASE}/patients/userLabTestList" - assert req.method == "GET" - assert req.headers["Authorization"] == "Bearer abc" - - -async def test_async_lab_results_includes_page_segment() -> None: - transport, requests = _ok({"foundTestsCount": 0, "foundTests": []}) - async with AsyncBulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) as client: - await client.laboratory.results(4) - - assert str(requests[0].url) == f"{BASE}/patients/userLabTestList/4" - - -async def test_async_lab_result_detail_string_id() -> None: - transport, requests = _ok({"id": "4821-lab"}) - async with AsyncBulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) as client: - await client.laboratory.result_detail("4821-lab") - - assert str(requests[0].url) == f"{BASE}/patients/userLabTestDetail/4821-lab" - - -async def test_async_lab_order_posts_required_body() -> None: - transport, requests = _ok({"preOrderId": 1}) - async with AsyncBulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) as client: - res = await client.laboratory.order(12, 34, 56) - - assert res == {"preOrderId": 1} - req = requests[0] - assert str(req.url) == f"{BASE}/patients/addNewLaboratoryTest" - assert req.method == "POST" - assert req.headers["Authorization"] == "Bearer abc" - assert body_of(req) == {"testId": 12, "addressId": 34, "laboratoryId": 56} - - -# --- diets (async) --- - - -async def test_async_diet_list_includes_page_segment() -> None: - transport, requests = _ok({"foundDietsCount": 0, "foundDiets": []}) - async with AsyncBulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) as client: - await client.diets.list(2) - - assert str(requests[0].url) == f"{BASE}/patients/dietLists/2" - assert requests[0].method == "GET" - - -async def test_async_diet_detail() -> None: - transport, requests = _ok([{"time": "Sabah", "meals": []}]) - async with AsyncBulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) as client: - await client.diets.detail(55) - - req = requests[0] - assert str(req.url) == f"{BASE}/patients/diet/55" - assert req.method == "GET" - assert req.headers["Authorization"] == "Bearer abc" diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..575e7dd --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import httpx + +from bulutklinik import BulutklinikClient +from helpers import body_of, recording_transport + +BASE = "https://apitest.bulutklinik.com/api/v3" + +REF = {"identityNumber": "12345678901"} +WRITE_PATIENT = {"name": "Ada", "surname": "Lovelace", "phoneNumber": "+905551112233"} + + +def partner_client() -> tuple[BulutklinikClient, list[httpx.Request]]: + transport, requests = recording_transport( + lambda req: httpx.Response(200, json={"resultType": 0, "data": None}) + ) + return BulutklinikClient(environment="test", partner_token="PT", transport=transport), requests + + +def test_discovery_paths() -> None: + client, requests = partner_client() + + client.doctors.branches() + client.doctors.locations() + client.doctors.detail(42) + client.laboratory.catalog() + client.laboratory.catalog_detail(18246) + client.slots.schedule(7, schedule_date="2026-08-01") + + assert [str(r.url) for r in requests] == [ + f"{BASE}/outher/branches", + f"{BASE}/outher/locations", + f"{BASE}/outher/doctorInfos/42", + f"{BASE}/outher/laboratoryCatalog", + f"{BASE}/outher/laboratoryCatalog/18246", + f"{BASE}/outher/doctorSlots", + ] + + +def test_patient_reference_travels_in_the_body_not_the_url() -> None: + client, requests = partner_client() + + client.diets.list(REF, 2) + client.measures.list(REF, "glucose", 1, 0) + client.laboratory.results(REF) + + # The identity number must never leak into a URL — it would land in access + # logs, proxy logs and error breadcrumbs. + for request in requests: + assert "12345678901" not in str(request.url) + + assert str(requests[0].url) == f"{BASE}/outher/dietLists" + assert body_of(requests[0]) == {"patient": REF, "currentPage": 2} + + assert str(requests[1].url) == f"{BASE}/outher/measuresList/glucose" + assert body_of(requests[1]) == {"patient": REF, "currentPage": 1, "glucoseType": 0} + + assert str(requests[2].url) == f"{BASE}/outher/laboratoryResults" + + +def test_measures_graph_path() -> None: + client, requests = partner_client() + + client.measures.graph({"phoneNumber": "+905551112233"}, "weight", 3) + + assert str(requests[0].url) == f"{BASE}/outher/measuresGraph/weight/3" + + +def test_lab_result_id_round_trips_verbatim() -> None: + client, requests = partner_client() + + client.laboratory.result_detail(REF, "1234-lab") + client.laboratory.result_detail(REF, 1234) + + assert body_of(requests[0])["testId"] == "1234-lab" + assert body_of(requests[1])["testId"] == "1234" + + +def test_measure_write_verbs_and_paths() -> None: + client, requests = partner_client() + + client.measures.add_list( + WRITE_PATIENT, [{"type": "pulse", "date_time": "2026-06-17 09:00", "pulse": 72}] + ) + client.measures.add( + WRITE_PATIENT, + "tension", + {"date_time": "2026-06-17 09:00", "hypertension": 120, "hypotension": 80}, + ) + client.measures.update( + REF, "tension", 9, {"date_time": "2026-06-17 10:00", "hypertension": 125} + ) + client.measures.delete(REF, "tension", 9) + + assert [(r.method, str(r.url)) for r in requests] == [ + ("POST", f"{BASE}/outher/measures"), + ("POST", f"{BASE}/outher/measure/tension"), + ("PUT", f"{BASE}/outher/measure/tension"), + ("DELETE", f"{BASE}/outher/measure/tension"), + ] + + # Measure fields are flattened alongside `patient`, matching the server shape. + assert body_of(requests[1]) == { + "patient": WRITE_PATIENT, + "date_time": "2026-06-17 09:00", + "hypertension": 120, + "hypotension": 80, + } + assert body_of(requests[3]) == {"patient": REF, "id": 9} + + +def test_appointment_lifecycle() -> None: + client, requests = partner_client() + + client.appointments.check_doctor(2, 0) + client.appointments.reserve(1, 2, WRITE_PATIENT, without_agreement=True) + client.appointments.create("h", 5) + client.appointments.list("+905551112233") + client.appointments.cancel_without_slot({"hash": "h", "outherProcessId": 5}) + + assert [(r.method, str(r.url)) for r in requests] == [ + ("POST", f"{BASE}/outher/checkDoctor"), + ("POST", f"{BASE}/outher/reservationWithoutAgreement"), + ("POST", f"{BASE}/outher/appointment"), + ("POST", f"{BASE}/outher/appointments"), + ("DELETE", f"{BASE}/outher/appointmentWithoutSlot"), + ] + assert body_of(requests[1]) == {"slotId": 1, "doctorId": 2, "user": WRITE_PATIENT} + + +def test_reserve_defaults_to_the_hand_off_flow() -> None: + client, requests = partner_client() + + client.appointments.reserve(1, 2, WRITE_PATIENT) + client.appointments.instant_reserve(WRITE_PATIENT) + client.appointments.create_without_slot( + 2, "2026-08-01 09:00", "2026-08-01 09:30", WRITE_PATIENT + ) + + assert [str(r.url) for r in requests] == [ + f"{BASE}/outher/reservation", + f"{BASE}/outher/instantReservation", + f"{BASE}/outher/appointmentWithoutSlot", + ] + assert body_of(requests[1]) == {"user": WRITE_PATIENT} + + +def test_legacy_teusan_contract_stays_flat() -> None: + client, requests = partner_client() + + client.measures.health_information( + identity="12345678901", + phone_number="+905551112233", + data=[{"type": "pulse", "date_time": "2026-06-17 09:00", "pulse": 72}], + ) + + assert str(requests[0].url) == f"{BASE}/outher/healthInformation" + # No `patient` wrapper here — this endpoint predates that contract. + assert body_of(requests[0]) == { + "identity": "12345678901", + "phoneNumber": "+905551112233", + "data": [{"type": "pulse", "date_time": "2026-06-17 09:00", "pulse": 72}], + } diff --git a/tests/test_tokens.py b/tests/test_tokens.py index 07e7245..5980d91 100644 --- a/tests/test_tokens.py +++ b/tests/test_tokens.py @@ -1,23 +1,28 @@ from __future__ import annotations -from bulutklinik import InMemoryTokenStore +from bulutklinik import InMemoryTokenStore, TokenStore def test_seed_set_and_clear() -> None: - store = InMemoryTokenStore("a", "r") - assert store.get_access_token() == "a" - assert store.get_refresh_token() == "r" + store = InMemoryTokenStore("a") + assert store.get_token() == "a" - store.set_tokens("a2", "r2") - assert store.get_access_token() == "a2" - assert store.get_refresh_token() == "r2" + store.set_token("b") + assert store.get_token() == "b" store.clear() - assert store.get_access_token() is None - assert store.get_refresh_token() is None + assert store.get_token() is None def test_defaults_to_none() -> None: - store = InMemoryTokenStore() - assert store.get_access_token() is None - assert store.get_refresh_token() is None + assert InMemoryTokenStore().get_token() is None + + +def test_explicit_none_unsets() -> None: + store = InMemoryTokenStore("a") + store.set_token(None) + assert store.get_token() is None + + +def test_in_memory_store_satisfies_the_protocol() -> None: + assert isinstance(InMemoryTokenStore("a"), TokenStore) diff --git a/tests/test_transport.py b/tests/test_transport.py index d14c040..78bcfaf 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -5,6 +5,7 @@ from bulutklinik import ( AuthenticationError, + AuthorizationError, BulutklinikClient, InMemoryTokenStore, NotFoundError, @@ -14,163 +15,181 @@ ) from helpers import body_of, recording_transport +BASE = "https://apitest.bulutklinik.com/api/v3" -def test_unwraps_data_and_sends_headers() -> None: - transport, requests = recording_transport( - lambda req: httpx.Response(200, json={"resultType": 0, "data": {"searchedDoctors": []}}) - ) - client = BulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") - ) +REF = {"identityNumber": "12345678901"} - res = client.doctors.quick_search("kardiyo") - assert res == {"searchedDoctors": []} - req = requests[0] - assert str(req.url) == "https://apitest.bulutklinik.com/api/v3/patients/quickSearch" - assert req.headers["Authorization"] == "Bearer abc" - assert req.headers["lang"] == "tr" - assert body_of(req) == {"searchText": "kardiyo", "listType": None, "location": None} +def _ok(_: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"resultType": 0, "data": {"ok": True}}) -def test_request_escape_hatch_bearer_get() -> None: - transport, requests = recording_transport( - lambda req: httpx.Response(200, json={"resultType": 0, "data": {"ok": True}}) +def client_with(responder, **kwargs) -> tuple[BulutklinikClient, list[httpx.Request]]: + transport, requests = recording_transport(responder) + kwargs.setdefault("partner_token", "PT") + return BulutklinikClient(environment="test", transport=transport, **kwargs), requests + + +def test_unwraps_data_and_sends_partner_token_and_lang() -> None: + client, requests = client_with( + lambda req: httpx.Response( + 200, json={"resultType": 0, "data": {"foundDoctorsCount": 0, "foundDoctors": []}} + ) ) + + res = client.doctors.search({"withFreeText": "kardiyoloji"}, 1, ["slot"]) + + assert res == {"foundDoctorsCount": 0, "foundDoctors": []} + assert str(requests[0].url) == f"{BASE}/outher/search" + assert requests[0].headers["Authorization"] == "Bearer PT" + assert requests[0].headers["lang"] == "tr" + assert body_of(requests[0]) == { + "searchParams": {"withFreeText": "kardiyoloji"}, + "orderParams": ["slot"], + "currentPage": 1, + } + + +def test_api_version_v4_changes_only_the_base_url() -> None: + transport, requests = recording_transport(_ok) client = BulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("abc") + environment="test", api_version="v4", partner_token="PT", transport=transport ) - res = client.request("GET", "/patients/customEndpoint") + client.doctors.branches() + + assert str(requests[0].url) == "https://apitest.bulutklinik.com/api/v4/outher/branches" + + +def test_missing_token_fails_before_dispatch() -> None: + dispatched = {"n": 0} + + def responder(_: httpx.Request) -> httpx.Response: + dispatched["n"] += 1 + return _ok(_) + + transport, _requests = recording_transport(responder) + client = BulutklinikClient(environment="test", transport=transport) + + with pytest.raises(AuthenticationError): + client.doctors.branches() + assert dispatched["n"] == 0 + + +def test_partner_token_and_token_store_together_is_rejected() -> None: + with pytest.raises(ValueError, match="not both"): + BulutklinikClient(partner_token="PT", token_store=InMemoryTokenStore("OTHER")) + + +def test_token_is_read_from_the_store_on_every_call() -> None: + store = InMemoryTokenStore("first") + transport, requests = recording_transport(_ok) + client = BulutklinikClient(environment="test", transport=transport, token_store=store) + + client.doctors.branches() + store.set_token("second") + client.doctors.branches() + + assert [r.headers["Authorization"] for r in requests] == ["Bearer first", "Bearer second"] + + +def test_request_escape_hatch_defaults_to_partner() -> None: + client, requests = client_with(_ok) + + res = client.request("GET", "/outher/customEndpoint") assert res == {"ok": True} - req = requests[0] - assert str(req.url) == "https://apitest.bulutklinik.com/api/v3/patients/customEndpoint" - assert req.method == "GET" - assert req.headers["Authorization"] == "Bearer abc" + assert str(requests[0].url) == f"{BASE}/outher/customEndpoint" + assert requests[0].headers["Authorization"] == "Bearer PT" def test_request_escape_hatch_public_post_body() -> None: - transport, requests = recording_transport( + client, requests = client_with( lambda req: httpx.Response(200, json={"resultType": 0, "data": {"id": 7}}) ) - client = BulutklinikClient(environment="test", transport=transport) res = client.request("POST", "/general/somePublicEndpoint", auth="public", body={"foo": "bar"}) assert res == {"id": 7} - req = requests[0] - assert req.method == "POST" - assert "Authorization" not in req.headers - assert body_of(req) == {"foo": "bar"} + assert "Authorization" not in requests[0].headers + assert body_of(requests[0]) == {"foo": "bar"} -def test_maps_422_to_validation() -> None: - transport, _ = recording_transport( - lambda req: httpx.Response(422, json={"resultType": 1, "errorType": "validation"}) - ) - client = BulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("a") +def test_maps_422_to_validation_error() -> None: + client, _ = client_with( + lambda req: httpx.Response( + 422, json={"resultType": 1, "errorType": "validation", "errorMessage": "bad"} + ) ) with pytest.raises(ValidationError): client.doctors.branches() -def test_maps_numeric_error_type_404() -> None: - transport, _ = recording_transport( - lambda req: httpx.Response( - 404, json={"resultType": 1, "errorType": 1, "errorMessage": "Bilinmeyen"} - ) - ) - client = BulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("a") - ) - with pytest.raises(NotFoundError): - client.doctors.quick_search("kardiyo") +def test_maps_403_to_authorization_error() -> None: + client, _ = client_with(lambda req: httpx.Response(403, json={"resultType": 1})) + with pytest.raises(AuthorizationError): + client.doctors.branches() -def test_maps_429_with_retry_after() -> None: - transport, _ = recording_transport( - lambda req: httpx.Response(429, headers={"Retry-After": "30"}, json={"resultType": 1}) +def test_maps_404_and_429() -> None: + client, _ = client_with(lambda req: httpx.Response(404, json={"resultType": 1})) + with pytest.raises(NotFoundError): + client.doctors.branches() + + rate, _ = client_with( + lambda req: httpx.Response(429, json={"resultType": 1}, headers={"retry-after": "30"}) ) - client = BulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("a") + with pytest.raises(RateLimitError) as excinfo: + rate.doctors.branches() + assert excinfo.value.retry_after == 30 + + +def test_numeric_error_type_does_not_crash() -> None: + client, _ = client_with( + lambda req: httpx.Response( + 404, json={"resultType": 1, "errorType": 1, "errorMessage": "Bilinmeyen bir hata."} + ) ) - with pytest.raises(RateLimitError) as exc_info: + with pytest.raises(NotFoundError): client.doctors.branches() - assert exc_info.value.retry_after == 30 -def test_refreshes_once_then_retries() -> None: - state = {"data_calls": 0} +def test_expired_token_is_surfaced_without_retrying() -> None: + attempts = {"n": 0} - def responder(req: httpx.Request) -> httpx.Response: - if req.url.path.endswith("/general/refreshApi"): - return httpx.Response( - 200, - json={"resultType": 0, "data": {"access_token": "new", "refresh_token": "newr"}}, - ) - state["data_calls"] += 1 - if state["data_calls"] == 1: - return httpx.Response(401, json={"resultType": 4}) - return httpx.Response(200, json={"resultType": 0, "data": {"ok": True}}) + def responder(_: httpx.Request) -> httpx.Response: + attempts["n"] += 1 + return httpx.Response(401, json={"resultType": 4, "errorMessage": "You must log in."}) - transport, requests = recording_transport(responder) - store = InMemoryTokenStore("old", "r") - client = BulutklinikClient( - environment="test", client_id="c", client_secret="s", transport=transport, token_store=store - ) + store = InMemoryTokenStore("expired") + client, _ = client_with(responder, partner_token=None, token_store=store) - res = client.measures.last() + with pytest.raises(AuthenticationError, match="cannot refresh it"): + client.measures.last(REF) - assert res == {"ok": True} - assert store.get_access_token() == "new" - assert requests[-1].headers["Authorization"] == "Bearer new" + assert attempts["n"] == 1 + # An expired token is kept: the caller may want to inspect it while + # installing the replacement. Only a revoked one is cleared. + assert store.get_token() == "expired" -def test_logout_clears_store() -> None: - transport, _ = recording_transport( - lambda req: httpx.Response(200, json={"resultType": 2, "errorMessage": "logged out"}) +def test_logout_clears_the_store() -> None: + store = InMemoryTokenStore("revoked") + client, _ = client_with( + lambda req: httpx.Response(200, json={"resultType": 2, "errorMessage": "logged out"}), + partner_token=None, + token_store=store, ) - store = InMemoryTokenStore("a", "r") - client = BulutklinikClient(environment="test", transport=transport, token_store=store) with pytest.raises(AuthenticationError): - client.measures.last() - assert store.get_access_token() is None + client.measures.last(REF) + assert store.get_token() is None -def test_wraps_network_errors() -> None: - def responder(req: httpx.Request) -> httpx.Response: +def test_network_failure_is_wrapped() -> None: + def boom(_: httpx.Request) -> httpx.Response: raise httpx.ConnectError("boom") - transport, _ = recording_transport(responder) - client = BulutklinikClient( - environment="test", transport=transport, token_store=InMemoryTokenStore("a") - ) + client, _ = client_with(boom) with pytest.raises(TransportError): client.doctors.branches() - - -def test_measure_path_and_partner_token() -> None: - transport, requests = recording_transport( - lambda req: httpx.Response(200, json={"resultType": 0, "data": None}) - ) - client = BulutklinikClient( - environment="test", - partner_token="PT", - transport=transport, - token_store=InMemoryTokenStore("a"), - ) - - client.measures.list("glucose", 1, 0) - assert ( - str(requests[0].url) - == "https://apitest.bulutklinik.com/api/v3/patients/userMeasuresList/glucose/1/0" - ) - - client.measures.partner_health_information( - phone_number="5551112233", - data=[{"type": "pulse", "date_time": "2026-06-17 09:00", "pulse": 72}], - ) - assert requests[-1].headers["Authorization"] == "Bearer PT"