diff --git a/CHANGELOG.md b/CHANGELOG.md index 328d41c..ff3567a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,80 @@ All notable changes to the Bulutklinik C++ SDK are documented here. The format i based 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. Their paths, bodies and behaviour are unchanged — this is a rename. + Resource classes lost the `Partner` prefix (`PartnerDoctorsResource` → + `DoctorsResource`); `PartnerNamespace` is gone. +- **`TokenStore` now holds one partner token**: `token()` / `set_token()` / + `clear()` replace `access_token()` / `refresh_token()` / `set_tokens()`. + `InMemoryTokenStore` takes the token as its single constructor argument. +- **`ClientOptions::partner_token` is now the client's credential** and is + required for every call. Setting both `partner_token` and `token_store` throws + `std::invalid_argument` from the constructor rather than silently picking one. +- **No silent refresh.** A `401` / `resultType 4` throws `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`. +- **`Auth` is now `{ Public, Partner }`**; `Auth::Bearer` is gone, and + `RequestOptions::auth` defaults to `Auth::Partner`. +- `MeasuresResource::partner_health_information` → + `MeasuresResource::health_information`, now `[[deprecated]]`. +- `DoctorsResource::search` takes `(search_params, current_page, order_params)` + instead of a `SearchInput` — the `/outher` search has no `other_params` or + `per_page_limit`, and `order_params` excludes `point`. + +### Added + +- **`ApiVersion` (`V3` / `V4`) and `ClientOptions::api_version`.** Every path is + version-agnostic, so targeting v4 is configuration, not a code change. Default + stays `V3`. + +### Fixed + +- The test suite now compiles. The 0.6.0 partner tests parsed + `HttpRequest::body` (a `std::optional`) directly, which never + built; they use `.value()` now. 0.6.0 shipped without a compile check. + +### 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. +- `ClientOptions::client_id` / `::client_secret`. +- The `LoginResult`, `CardInfo`, `RegisterInput`, `VerifyRegistrationInput`, + `ConfirmRegistrationEmailInput`, `VerifyRegistrationSocialInput`, + `RegisterSocialInput`, `ForgotPasswordInput`, `ResetPasswordInput`, + `AddressInput`, `AddressUpdateInput`, `SearchInput`, `PaymentInput`, + `MealInput` and `LabOrderInput` types. + ## [0.6.0] ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 0180495..8f87ced 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(bulutklinik_sdk VERSION 0.6.0 LANGUAGES CXX) +project(bulutklinik_sdk VERSION 1.0.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -21,7 +21,7 @@ option(BULUTKLINIK_BUILD_TESTS "Build the test suite" ON) if(BULUTKLINIK_BUILD_TESTS) enable_testing() find_package(Catch2 3 CONFIG REQUIRED) - add_executable(bulutklinik_tests tests/test_transport.cpp tests/test_ai.cpp tests/test_lab_diets.cpp) + add_executable(bulutklinik_tests tests/test_transport.cpp tests/test_resources.cpp) target_link_libraries(bulutklinik_tests PRIVATE bulutklinik_sdk Catch2::Catch2WithMain nlohmann_json::nlohmann_json) add_test(NAME bulutklinik_tests COMMAND bulutklinik_tests) 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 6f0c577..68f823f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,18 @@ -# sdk-cpp — Bulutklinik API SDK for C++ +# sdk-cpp — Bulutklinik partner API SDK for C++ -Official Bulutklinik API SDK for C++ (C++17). Built on +Official Bulutklinik **partner** API SDK for C++ (C++17). Built on [cpr](https://github.com/libcpr/cpr) (libcurl) + [nlohmann/json](https://github.com/nlohmann/json). -Covers the patient flow: **auth, doctor search, slots, appointments, payments, -health measures, AI image analysis (skin + meals), lab results, and diet lists**. 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 (CMake + vcpkg) @@ -29,104 +36,216 @@ target_link_libraries(your_app PRIVATE bulutklinik::sdk) ```cpp #include +#include #include int main() { bulutklinik::ClientOptions options; - options.environment = bulutklinik::Environment::Production; // Production | Test | Local - options.client_id = "clientId"; - options.client_secret = "clientSecret"; + options.environment = bulutklinik::Environment::Production; // Production | Test | Local + options.api_version = bulutklinik::ApiVersion::V3; // V3 (default) | V4 + if (const char* token = std::getenv("BK_PARTNER_TOKEN")) { + options.partner_token = token; + } bulutklinik::Client client(options); - // 1) Log in (tokens are stored automatically) - auto login = client.auth().connect("patient@example.com", std::string("•••••••"), "email"); - if (login.two_factor_required) { - client.auth().connect_with_two_factor("123456", *login.two_factor_response); - } + // 1) Find a doctor you can book — returns an nlohmann::json ("data" payload) + auto result = client.doctors().search( + nlohmann::json{{"withFreeText", "kardiyoloji"}}, 1, {"slot"}); + std::string doctor_id = std::to_string(result["foundDoctors"][0]["doctor_id"].get()); - // 2) Search — returns an nlohmann::json (the "data" payload) - bulutklinik::SearchInput input; - input.search_params = {{"withFreeText", "kardiyoloji"}}; - input.order_params = {"slot"}; - input.other_params = {"isInterviewable"}; - auto result = client.doctors().search(input); + // 2) Free slots + auto schedule = client.slots().schedule(doctor_id, std::string("2026-08-01")); - // 3) Slots, then 4) reserve ("YYYY-MM-DD HH:mm") - std::string doctor_id = std::to_string(result["foundDoctors"][0]["doctor_id"].get()); - auto slots = client.slots().schedule(doctor_id, "interview"); - client.appointments().reserve_interview(doctor_id, "2026-06-20 14:30"); + // 3) Hold one for a patient — named inline, no session + bulutklinik::Patient user; + user.name = "Ada"; + user.surname = "Lovelace"; + user.phone_number = "+905551112233"; + auto held = client.appointments().reserve_without_agreement(slot_id, doctor_id, user); + + // 4) Confirm before held["reservationExpired"] passes + client.appointments().create(held["hash"].get(), + held["outherProcessId"].get()); } ``` ## Services -| Accessor | Methods | -|---------------------------|---------| -| `client.auth()` | `connect`, `connect_with_two_factor`, `verify_registration`, `confirm_registration_email`, `register_patient`, `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_measure`, `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_address` | - -Data methods return `nlohmann::json`. (`register_patient` / `delete_measure` / -`delete_address` are named to avoid the C++ keywords `register` / `delete`.) - -## AI image analysis - -Skin-lesion analysis ("Cildimde Neyim Var") and meal-photo calorie/nutrition -estimation. Both take base64 images and return the `data` payload verbatim; the -`meals` input maps to the API's snake_case body (`portion_size`, `portion_grams`, -`meal_type`). +28 endpoints across six groups. + +| Group | Methods | +|--------------------------|---------| +| `client.doctors()` | `search`, `branches`, `detail`, `locations` | +| `client.slots()` | `schedule` | +| `client.appointments()` | `reserve`, `reserve_without_agreement`, `instant_reserve`, `create`, `create_without_slot`, `cancel_without_slot`, `list`, `info`, `check_doctor` | +| `client.measures()` | `last`, `list`, `graph`, `add_list`, `add`, `update`, `delete_measure`, `health_information` | +| `client.laboratory()` | `catalog`, `catalog_detail`, `results`, `result_detail` | +| `client.diets()` | `list`, `detail` | + +`delete_measure` carries that name because `delete` is a reserved keyword. + +## 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** need only the reference fields. The server looks solely inside your own +company and never creates anything: + +```cpp +bulutklinik::Patient reference; +reference.identity_number = "12345678901"; +client.measures().last(reference); +client.diets().list(reference); +``` + +`identity_number` is primary; `phone_number` 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** need `name`, `surname` and `phone_number` too, because the patient is +created inside your company if absent. + +## Booking + +Two flows, depending on who collects the agreements and the payment: + +```cpp +// (A) Hand off to the patient — returns a browser url for agreements + payment. +auto held = client.appointments().reserve(slot_id, doctor_id, user); + +// (B) You already collected them — returns a hash to confirm yourself. +auto held = client.appointments().reserve_without_agreement(slot_id, doctor_id, user); +client.appointments().create(hash, outher_process_id); +``` + +**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 the url verbatim and never opens or follows it. + +`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. + +The token is read from a `TokenStore` on **every** request, so a long-running +process can pick up a newly issued one without being rebuilt: ```cpp -// Skin — a loose array of records (`branch_id` optional) -auto skin = client.skin().analyze({{{"image", ""}, {"branch_id", 42}}}); - -// Meals — a typed input; portion_grams is required when portion_size == "custom" -bulutklinik::MealInput meal; -meal.image = ""; -meal.portion_size = "custom"; // small | medium | large | custom -meal.portion_grams = 300; -meal.meal_type = "lunch"; // breakfast | lunch | dinner | snack -meal.note = "az yağlı"; // optional -auto meals = client.meals().analyze(meal); +class VaultTokenStore : public bulutklinik::TokenStore { +public: + std::optional token() const override { /* … */ } + void set_token(const std::optional& t) override { /* … */ } + void clear() override { /* … */ } +}; + +options.token_store = std::make_shared(); + +// …or rotate the default in-memory store in place: +client.token_store().set_token(newly_issued_token); ``` -## Authentication & tokens +Set `partner_token` **or** `token_store`, not both — the constructor throws +`std::invalid_argument` rather than guessing which one you meant. -- `connect` / `connect_with_two_factor` / `register_patient` store tokens automatically. -- On a `401` (or `resultType 4`), the SDK silently refreshes once and retries - (thread-safe, single shared refresh). -- Inject a custom store via `ClientOptions::token_store` (subclass `TokenStore`). +### When the token expires + +Tokens last about 30 days. An expired one comes back as `401` / `resultType 4`; +the SDK throws `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 + +```cpp +bulutklinik::Patient reference; +reference.identity_number = "12345678901"; + +// Write several measurements at once (max 200 per call, one transaction) +std::vector rows = { + {{"type", "tension"}, {"date_time", "2026-06-17 09:30"}, {"hypertension", 120}, {"hypotension", 80}}, +}; +client.measures().add_list(patient, rows); + +client.measures().last(reference); +client.measures().list(reference, "glucose", std::string("1"), 0); // 0=fasting, 1=postprandial +client.measures().graph(reference, "tension", 2); // period 2 = weekly +``` + +> 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. + +`measures().health_information` is the legacy `teusan` bulk endpoint, marked +`[[deprecated]]` and kept for existing integrations: it needs the `teusan` scope +instead of `apiouther`, takes a flat identity + phone number instead of a patient +object, 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. + +## Escape hatch + +Not every endpoint has a typed method. `client.request` reuses the same +transport, so headers, envelope unwrapping and typed exceptions all still apply: + +```cpp +auto data = client.request("GET", "/outher/somethingNew"); + +// Auth::Public reaches unauthenticated endpoints outside the partner surface, +// e.g. the city/district catalogue that feeds address forms. +bulutklinik::RequestOptions options; +options.auth = bulutklinik::Auth::Public; +auto config = client.request("GET", "/general/getConfig", options); +``` ## Errors -All derive from `bulutklinik::BulutklinikError`: `TransportError` and `ApiError` -→ `ValidationError` (422), `AuthenticationError` (401 / logout), -`AuthorizationError` (403), `NotFoundError` (404), `RateLimitError` (429). -`ApiError` carries `http_status`, `result_type`, `error_type`, `data`, `method`, -`path`, `retry_after`. +All exceptions derive from `bulutklinik::BulutklinikError`: + +`TransportError` (network) · `ApiError` → `ValidationError` (422), +`AuthenticationError` (401 / revoked / expired), `AuthorizationError` (403), +`NotFoundError` (404), `RateLimitError` (429). +Every `ApiError` carries `http_status`, `result_type`, `error_type`, `data`, +`method`, `path` and `retry_after`. ```cpp try { - client.payments().pay(input); + client.measures().last(reference); } catch (const bulutklinik::RateLimitError& e) { - if (e.retry_after) std::cerr << "retry after " << *e.retry_after << "\n"; -} catch (const bulutklinik::ApiError& e) { - std::cerr << e.what() << "\n"; + std::cerr << "retry after " << e.retry_after.value_or(0) << "\n"; +} catch (const bulutklinik::ValidationError& e) { + std::cerr << e.data.dump() << "\n"; } ``` -## Payments (3-D Secure) +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. + +## Types -`payments().pay` returns data containing `payment3DUrl` on a 3DS flow — a browser -URL to open. The bank → server callback completes the capture. +Every method returns `nlohmann::json` — the unwrapped `data` payload. Numeric +ids are passed as `std::string` so they survive round-tripping (a lab result id +may carry a `-lab` suffix). Optional parameters use `std::optional`. ## License diff --git a/conanfile.py b/conanfile.py index cba4a71..4df2efe 100644 --- a/conanfile.py +++ b/conanfile.py @@ -4,7 +4,7 @@ class BulutklinikSdkConan(ConanFile): name = "bulutklinik-sdk" - version = "0.3.0" + version = "1.0.1" license = "MIT" url = "https://github.com/bulutklinik/cpp-sdk" homepage = "https://github.com/bulutklinik/cpp-sdk" diff --git a/include/bulutklinik/bulutklinik.hpp b/include/bulutklinik/bulutklinik.hpp index c77b4e1..28f226b 100644 --- a/include/bulutklinik/bulutklinik.hpp +++ b/include/bulutklinik/bulutklinik.hpp @@ -1,4 +1,9 @@ -// Bulutklinik API SDK for C++ (C++17). Public API. +// Bulutklinik partner API SDK for C++ (C++17). Public API. +// +// 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. #ifndef BULUTKLINIK_BULUTKLINIK_HPP #define BULUTKLINIK_BULUTKLINIK_HPP @@ -17,9 +22,14 @@ namespace bulutklinik { /// Base URL presets. enum class Environment { Production, Test, Local }; -/// Authorization mode for a request. `Bearer` uses the stored access token, -/// `Partner` the configured partner token, `Public` sends no `Authorization`. -enum class Auth { Public, Bearer, Partner }; +/// API version segment. The `/outher` surface is route-for-route identical on +/// both, so switching is configuration rather than a code change. +enum class ApiVersion { V3, V4 }; + +/// Authorization mode for a request. `Partner` sends the configured partner +/// token; `Public` sends no `Authorization` header. Every typed method is +/// `Partner` — `Public` is only reachable through `Client::request`. +enum class Auth { Public, Partner }; // ---------------- errors ---------------- @@ -36,6 +46,11 @@ class TransportError : public BulutklinikError { }; /// An HTTP response was received but the call was not successful. +/// +/// Note that `/outher` reports most business-rule failures as HTTP 501 with +/// `result_type` 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. class ApiError : public BulutklinikError { public: ApiError(const std::string& message, int http_status, std::optional result_type, @@ -64,10 +79,15 @@ class ValidationError : public ApiError { public: using ApiError::ApiError; }; +/// 401, a revoked token (result_type 2), or an expired one (result_type 4). class AuthenticationError : public ApiError { public: using ApiError::ApiError; }; +/// 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 AuthorizationError : public ApiError { public: using ApiError::ApiError; @@ -83,13 +103,19 @@ class RateLimitError : public ApiError { // ---------------- token store ---------------- -/// Pluggable token persistence. The default is in-memory. +/// Pluggable source for the partner token. +/// +/// 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. An empty optional means "no token"; the transport +/// then fails before dispatching rather than sending an anonymous request. +/// +/// Implementations must be thread-safe. class TokenStore { public: virtual ~TokenStore() = default; - virtual std::optional access_token() const = 0; - virtual std::optional refresh_token() const = 0; - virtual void set_tokens(const std::string& access, const std::optional& refresh) = 0; + virtual std::optional token() const = 0; + virtual void set_token(const std::optional& token) = 0; virtual void clear() = 0; }; @@ -97,32 +123,24 @@ class TokenStore { class InMemoryTokenStore : public TokenStore { public: InMemoryTokenStore() = default; - InMemoryTokenStore(std::optional access, std::optional refresh) - : access_(std::move(access)), refresh_(std::move(refresh)) {} + explicit InMemoryTokenStore(std::optional token) : token_(std::move(token)) {} - std::optional access_token() const override { - std::lock_guard lock(mutex_); - return access_; - } - std::optional refresh_token() const override { + std::optional token() const override { std::lock_guard lock(mutex_); - return refresh_; + return token_; } - void set_tokens(const std::string& access, const std::optional& refresh) override { + void set_token(const std::optional& token) override { std::lock_guard lock(mutex_); - access_ = access; - refresh_ = refresh; + token_ = token; } void clear() override { std::lock_guard lock(mutex_); - access_.reset(); - refresh_.reset(); + token_.reset(); } private: mutable std::mutex mutex_; - std::optional access_; - std::optional refresh_; + std::optional token_; }; // ---------------- HTTP backend ---------------- @@ -158,178 +176,23 @@ class CprHttpBackend : public HttpBackend { HttpResponse send(const HttpRequest& request) override; }; -// ---------------- models ---------------- - -struct LoginResult { - bool two_factor_required = false; - std::optional two_factor_response; -}; - -struct CardInfo { - std::string card_holder; - std::string card_number; - std::string card_exp_month; - std::string card_exp_year; - std::string card_cvv; -}; - -struct RegisterInput { - std::string name; - std::string surname; - std::string api_user_name; - std::string phone_number; - std::string password; - std::string sms_verification_code; - std::string response; - int accept_user_agreement = 1; - std::optional client_id; - std::optional client_secret; -}; - -/// Input for the registration verify step (AuthResource::verify_registration). -/// The endpoint requires a CAPTCHA token (recaptcha_v2 or captcha) minted by a -/// browser/human, and is authorized with the configured partner token. -struct VerifyRegistrationInput { - std::string name; - std::string surname; - std::string phone_number; - /// Country dial code only, e.g. "+90" (matches ^\+\d{1,3}$). - std::string phone_code; - std::string email; - std::string password; - int accept_user_agreement = 1; - /// Sent as "g-recaptcha-response-v2". Provide this or captcha. - std::optional recaptcha_v2; - /// Sent as "captcha". Provide this or recaptcha_v2. - std::optional captcha; - /// Optional structured agreement approvals, passed through verbatim. - std::optional user_agreements; -}; - -/// Step 2 of the e-mail-branch registration (AuthResource::confirm_registration_email). -struct ConfirmRegistrationEmailInput { - std::string verification_code; - /// The blob from verify_registration (when confirmationType was "email"). - std::string response; - std::optional user_agreements; -}; - -/// Step 1 of social sign-up (public; no CAPTCHA and no partner token). -struct VerifyRegistrationSocialInput { - std::string name; - std::string surname; - std::string phone_number; - std::string password; - /// Social provider identifier (e.g. "google", "apple"). - std::string social_type; - /// The social provider key/token identifying the user. - std::string key; - std::optional email; - int accept_user_agreement = 1; - std::optional user_agreements; -}; - -/// Step 2 of social sign-up. Does NOT mint tokens; log in via connect (social) after. -struct RegisterSocialInput { - std::string sms_verification_code; - /// The blob from verify_registration_social. - std::string response; - std::optional user_agreements; -}; - -/// Step 1 of password reset (AuthResource::forgot_password). -struct ForgotPasswordInput { - std::string phone_number; - /// Optional "YYYY-MM-DD"; required by installs that verify identity. - std::optional birthdate; - /// Sent as "g-recaptcha-response-v2". Set this or captcha (required outside local env). - std::optional recaptcha_v2; - std::optional captcha; -}; - -/// Step 2 of password reset (AuthResource::reset_password). -struct ResetPasswordInput { - std::string sms_confirm_code; - /// The blob from forgot_password. - std::string response; - std::string password; -}; - -/// A new patient address (AddressesResource::add). city_id/district_id are numeric -/// ids sent as strings; city_id comes from doctors().locations(), district_id from -/// GET /getConfig (cities[].districts[]). -struct AddressInput { - std::string title; - std::optional description; - std::string city_id; - std::string district_id; - std::string address; - std::string location_lat; - std::string location_lng; - /// 1 makes it the default (the first address is default anyway). - std::optional is_default; -}; - -/// An address update by id (AddressesResource::update). Unset optionals are omitted. -struct AddressUpdateInput { - std::string id; - std::optional title; - std::optional description; - std::optional city_id; - std::optional district_id; - std::optional address; - std::optional location_lat; - std::optional location_lng; - std::optional is_default; -}; - -struct SearchInput { - nlohmann::json search_params = nlohmann::json::object(); - std::vector order_params; - std::vector other_params; - int current_page = 1; - int per_page_limit = 20; -}; - -struct PaymentInput { - std::string doctor_id; - std::string appointment_date; - bool is_3d = false; - bool terms_accept = false; - std::string appointment_type = "interview"; - std::optional card_info; - std::optional card_id; - int save_card = 0; - std::string discount_code; - std::optional case_detail; -}; - -/// Input for `meals().analyze`. `image` is base64 (a `data:…;base64,` prefix is -/// accepted). `portion_size` is one of `small | medium | large | custom` and -/// `meal_type` one of `breakfast | lunch | dinner | snack`. `portion_grams` is -/// required when `portion_size == "custom"`. `note` is optional free text. -struct MealInput { - std::string image; - std::string portion_size; - std::string meal_type; - std::optional portion_grams; - std::optional note; -}; - -/// Input for `laboratory().order`. All three ids are required and map to the -/// API body `{ testId, addressId, laboratoryId }`. -struct LabOrderInput { - std::string test_id; - std::string address_id; - std::string laboratory_id; -}; +// ---------------- configuration ---------------- +/// Client configuration. +/// +/// Set `partner_token` **or** `token_store`, not both — either the literal or the +/// store is the source of truth for the credential, and guessing which one the +/// caller meant is how credential bugs get shipped. Passing both throws +/// std::invalid_argument from the Client constructor. struct ClientOptions { Environment environment = Environment::Production; + /// Ignored when `base_url` is set. + ApiVersion api_version = ApiVersion::V3; + /// Explicit base URL; overrides `environment` + `api_version`. std::optional base_url; std::string lang = "tr"; - std::optional client_id; - std::optional client_secret; + /// The partner token issued for your integration. Seeds the default + /// in-memory token store. std::optional partner_token; std::shared_ptr token_store; std::shared_ptr http_backend; @@ -340,7 +203,7 @@ struct ClientOptions { /// `nlohmann::json` (a null value means "no body"); a per-request `lang` /// overrides the client default when set. struct RequestOptions { - Auth auth = Auth::Bearer; + Auth auth = Auth::Partner; nlohmann::json body = nlohmann::json(nullptr); std::optional lang; }; @@ -349,215 +212,217 @@ namespace detail { class Transport; } -// ---------------- resources ---------------- +// ---------------- patient references ---------------- + +/// Identifies a patient. +/// +/// Reads need only `identity_number` (primary) or `phone_number` (accepted solely +/// when it matches exactly one patient in your company — the column is not unique, +/// and the server fails closed rather than guessing). The server looks only inside +/// your own company on this path and never creates anything, so 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 need `name`, `surname` and `phone_number` as well: if no matching +/// patient exists in your company the server creates one. +struct Patient { + std::optional name; + std::optional surname; + std::optional phone_number; + std::optional identity_number; + std::optional email; + /// `Y-m-d`. + std::optional birthdate; + /// ISO country code present in `bas_com_countries.code`. + std::optional nationality; + std::optional price; -class AuthResource { -public: - explicit AuthResource(detail::Transport* transport) : t_(transport) {} - - LoginResult connect(const std::string& api_user_name, - const std::optional& api_user_password, - const std::string& login_mode, - const std::optional& client_id = std::nullopt, - const std::optional& client_secret = std::nullopt, - const std::optional& with_phone_number = std::nullopt); - void connect_with_two_factor(const std::string& sms_verification_code, const std::string& response); - /// Registration step 1: send the verification code and return the raw data - /// holding the encrypted `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 register_patient. - nlohmann::json verify_registration(const VerifyRegistrationInput& input); - /// Named register_patient because `register` is a reserved keyword in C++. - void register_patient(const RegisterInput& input); - /// Step 2 of e-mail-branch registration: confirm the e-mailed code and get the - /// SMS blob (confirmationType "sms") to feed into register_patient. Public. - nlohmann::json confirm_registration_email(const ConfirmRegistrationEmailInput& input); - /// Step 1 of social sign-up: send the SMS code, return the raw data holding the - /// response blob. Public (no CAPTCHA/partner token). Feeds register_social. - nlohmann::json verify_registration_social(const VerifyRegistrationSocialInput& input); - /// Step 2 of social sign-up: create the social patient. Does NOT log in — call - /// connect with login_mode "social" afterwards. Public. - void register_social(const RegisterSocialInput& input); - /// Step 1 of password reset: send the SMS confirm code, return the raw data - /// holding the response blob. A CAPTCHA token is required outside local env. Public. - nlohmann::json forgot_password(const ForgotPasswordInput& input); - /// Step 2 of password reset: set the new password with the SMS confirm code + blob. Public. - void reset_password(const ResetPasswordInput& input); - void refresh(); - void disconnect(); + /// Serialises only the fields that were set, matching the server contract. + nlohmann::json to_json() const; +}; -private: - detail::Transport* t_; +/// Addresses one appointment either by its process (`hash` + `outher_process_id`) +/// or by its coordinates (`doctor_id` + `appointment_date` + `is_outher_doctor`). +/// Supply one pair or the other. +struct AppointmentLookup { + std::optional hash; + std::optional outher_process_id; + std::optional doctor_id; + /// `Y-m-d H:i`. + std::optional appointment_date; + std::optional is_outher_doctor; + + nlohmann::json to_json() const; }; +// ---------------- resources ---------------- + +/// Doctor discovery. Results are scoped to the doctors enabled for your +/// integration, so a doctor returned here is one you can book. `locations` is the +/// exception — a global city catalogue, not company-scoped. class DoctorsResource { public: explicit DoctorsResource(detail::Transport* transport) : t_(transport) {} + /// `order_params` accepts "name", "order" and "slot". + /// + /// `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. + nlohmann::json search(const nlohmann::json& search_params, int current_page = 1, + const std::vector& order_params = {}); nlohmann::json branches(); + /// The `doctor_id` here feeds `SlotsResource::schedule`. + nlohmann::json detail(const std::string& doctor_id); + /// City list. Global catalogue — not scoped to your company. nlohmann::json locations(); - nlohmann::json quick_search(const std::string& search_text, - const std::optional& list_type = std::nullopt, - const std::optional& location = std::nullopt); - nlohmann::json search(const SearchInput& input); - nlohmann::json detail(const std::string& id, const std::optional& corporate = std::nullopt); private: detail::Transport* t_; }; +/// Doctor availability. class SlotsResource { public: explicit SlotsResource(detail::Transport* transport) : t_(transport) {} - nlohmann::json schedule(const std::string& doctor_id, const std::string& list_type, - const std::optional& schedule_date = std::nullopt, - int schedule_step = 7, int schedule_page = 1); + /// Either pass `schedule_date` (`Y-m-d`), or page with `schedule_step` + + /// `schedule_page`; the server requires one of the two forms. + /// + /// Returns a date-keyed map; `slotId` feeds `AppointmentsResource::reserve`. + nlohmann::json schedule(const std::string& doctor_id, + const std::optional& schedule_date = std::nullopt, + std::optional schedule_step = std::nullopt, + std::optional schedule_page = std::nullopt); private: detail::Transport* t_; }; +/// The appointment lifecycle. The patient is supplied inline as `user`; the +/// server materialises it inside your company on write. +/// +/// Two booking flows: +/// - hand off to the patient: `reserve` returns a `url` the patient opens in a +/// browser to accept the agreements and pay; +/// - you collected the agreements: `reserve_without_agreement` returns a `hash` +/// to feed, with `outherProcessId`, into `create`. +/// +/// Payment is never taken through the API. No partner endpoint produces a +/// financial record; that is what the browser hand-off is for. class AppointmentsResource { public: explicit AppointmentsResource(detail::Transport* transport) : t_(transport) {} - nlohmann::json reserve_interview(const std::string& doctor_id, const std::string& appointment_date, - const std::string& appointment_type = "interview"); - nlohmann::json add_physical(const std::string& doctor_id, const std::string& appointment_date); - nlohmann::json cancel(const std::string& event_id); - /// The patient's appointments ({foundAppointmentsCount, foundAppointments}). Each - /// item's event_id feeds cancel; rows with event_id "0" are paid-order/refund - /// entries (not cancellable). Server paging is disabled — omit page for the full list. - nlohmann::json list(std::optional page = std::nullopt); - /// The patient's active online-slot reservation holds (with a minute_diff countdown). - nlohmann::json reservations(); - -private: - detail::Transport* t_; -}; - -/// The patient's saved addresses. Required by laboratory().order() (needs an -/// addressId). add/update take a city_id (from doctors().locations()) and a -/// district_id (from GET /getConfig — cities[].districts[]). -class AddressesResource { -public: - explicit AddressesResource(detail::Transport* transport) : t_(transport) {} - - /// List saved addresses (default first). Each item's "id" is the addressId. - nlohmann::json list(); - /// Add an address. Success data is {"addressId": ...}. The first address is default. - nlohmann::json add(const AddressInput& input); - /// Update an address by id. Send only id + is_default to flip the default flag. - nlohmann::json update(const AddressUpdateInput& input); - /// Delete an address by id (sent in the body). Named delete_address because - /// `delete` is a reserved keyword in C++. Default/used addresses cannot be deleted. - nlohmann::json delete_address(const std::string& id); - -private: - detail::Transport* t_; -}; - -class PaymentsResource { -public: - explicit PaymentsResource(detail::Transport* transport) : t_(transport) {} - - nlohmann::json check_discount_code(const std::string& check_type, const std::string& discount_code, - const std::optional& doctor_id = std::nullopt, - const std::optional& order_id = std::nullopt, - const std::optional& special_service_id = std::nullopt, - const std::optional& program_slug = std::nullopt); - nlohmann::json get_cards(); - nlohmann::json save_card(const CardInfo& card); - nlohmann::json pay(const PaymentInput& input); - nlohmann::json delete_card(const std::string& card_id); + /// Hold an online slot and get back a `url` for the patient to complete + /// agreements and payment in a browser. + nlohmann::json reserve(const std::string& slot_id, const std::string& doctor_id, + const Patient& user); + /// Same hold, for integrations that collect the agreements themselves. + /// Returns a `hash` plus `reservationExpired` — confirm before it passes. + nlohmann::json reserve_without_agreement(const std::string& slot_id, const std::string& doctor_id, + const Patient& user); + /// Instant reservation — no slot; the server picks an available doctor. + nlohmann::json instant_reserve(const Patient& user); + /// Turn a reservation into a confirmed appointment. + nlohmann::json create(const std::string& hash, const std::string& outher_process_id); + /// Book a free-form time range outside the slot grid. + nlohmann::json create_without_slot(const std::string& doctor_id, const std::string& start_date, + const std::string& finish_date, const Patient& user, + std::optional is_outher_doctor = std::nullopt); + /// Cancel an appointment made with `create_without_slot` — and only those; + /// ones confirmed through `create` are not cancellable here. + nlohmann::json cancel_without_slot(const AppointmentLookup& lookup); + /// The appointments you created for that phone number, not the patient's + /// history across the platform. + nlohmann::json list(const std::string& phone_number, + const std::optional& page = std::nullopt, + const std::optional& type = std::nullopt); + nlohmann::json info(const AppointmentLookup& lookup); + /// Whether a doctor is bookable through your integration. Fails with 501 when + /// they are not — call it before offering a doctor. + nlohmann::json check_doctor(const std::string& doctor_id, int is_outher_doctor); private: detail::Transport* t_; }; -class MeasuresResource { -public: - explicit MeasuresResource(detail::Transport* transport) : t_(transport) {} - - nlohmann::json add_list(const std::vector& records); - nlohmann::json add(const std::string& measure_type, const nlohmann::json& fields); - nlohmann::json update(const std::string& measure_type, const nlohmann::json& fields); - /// Named delete_measure because `delete` is a reserved keyword in C++. - nlohmann::json delete_measure(const std::string& measure_type, const std::string& id); - nlohmann::json last(); - nlohmann::json list(const std::string& measure_type, const std::string& page, - std::optional glucose_type = std::nullopt); - nlohmann::json graph(const std::string& measure_type, int period, const std::string& page, - std::optional glucose_type = std::nullopt); - nlohmann::json partner_health_information(const std::optional& identity, - const std::optional& phone_number, - const std::vector& data); - -private: - detail::Transport* t_; -}; - -/// "Cildimde Neyim Var" — AI skin-lesion analysis. -class SkinResource { -public: - explicit SkinResource(detail::Transport* transport) : t_(transport) {} - - /// Analyze one or more skin photos. Each image is a loose record like - /// `{"image": "", "branch_id": 42}` (`branch_id` optional). Returns - /// the `data` payload verbatim (per-image lesion label, Turkish comment, - /// confidence, possible ICD hints and an opaque `case_detail` blob). - nlohmann::json analyze(const std::vector& images); - -private: - detail::Transport* t_; -}; - -/// AI meal-photo calorie/nutrition estimation (sibling of `skin`). -class MealsResource { +/// Diet lists recorded for a patient inside your own company. Lists written by +/// other clinics are not visible here. +class DietsResource { public: - explicit MealsResource(detail::Transport* transport) : t_(transport) {} + explicit DietsResource(detail::Transport* transport) : t_(transport) {} - /// Estimate calories and nutrition from a meal photo. Input names map to the - /// API's snake_case body (`portion_size`, `portion_grams`, `meal_type`). - nlohmann::json analyze(const MealInput& input); + /// Page size is fixed to 20 server-side. + nlohmann::json list(const Patient& patient, + const std::optional& page = std::nullopt); + /// `list_id` comes from `list`. + nlohmann::json detail(const Patient& patient, const std::string& list_id); private: detail::Transport* t_; }; -/// The patient's laboratory results, the orderable test catalog, and pre-ordering. +/// Laboratory catalogue (global, static) and results (your company only, merging +/// the clinic's HBYS lab requests and TmcLab order groups). +/// +/// Ordering a test is not available here — it creates a financial record. class LaboratoryResource { public: explicit LaboratoryResource(detail::Transport* transport) : t_(transport) {} - /// The patient's completed/in-progress lab results. `page` defaults to 1 - /// server-side when the segment is omitted. - nlohmann::json results(std::optional page = std::nullopt); - /// One result's detail. `test_id` is a string (`"123"` or `"123-lab"`), - /// interpolated verbatim. - nlohmann::json result_detail(const std::string& test_id); - /// The orderable test-group catalog. nlohmann::json catalog(); - /// One catalog group by id. - nlohmann::json catalog_detail(const std::string& id); - /// Pre-order a lab test. All three ids are required. - nlohmann::json order(const LabOrderInput& input); + /// Prices are the plain list prices — the patient-side discount pass does not + /// apply here. + nlohmann::json catalog_detail(const std::string& test_id); + /// Each item's `id` is accepted verbatim by `result_detail`; a `-lab` suffix + /// marks a TmcLab order group. + nlohmann::json results(const Patient& patient, + const std::optional& page = std::nullopt); + nlohmann::json result_detail(const Patient& patient, const std::string& test_id); private: detail::Transport* t_; }; -/// The patient's diet lists (a dietitian's "Diyet Listesi"). JSON only. -class DietsResource { +/// Health measurements. +/// +/// 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. +class MeasuresResource { public: - explicit DietsResource(detail::Transport* transport) : t_(transport) {} + explicit MeasuresResource(detail::Transport* transport) : t_(transport) {} - /// The patient's diet lists. `page` defaults to 1 server-side when omitted. - nlohmann::json list(std::optional page = std::nullopt); - /// One diet list's detail by `list_id`. - nlohmann::json detail(const std::string& list_id); + nlohmann::json last(const Patient& patient); + /// `glucose_type` applies to "glucose" only (0=fasting, 1=postprandial). + nlohmann::json list(const Patient& patient, const std::string& measure_type, + const std::optional& page = std::nullopt, + std::optional glucose_type = std::nullopt); + /// `period`: 1=day, 2=week, 3=month, 4=year. + nlohmann::json graph(const Patient& patient, const std::string& measure_type, int period, + const std::optional& page = std::nullopt, + std::optional glucose_type = std::nullopt); + /// Write several measurements of mixed types in one transaction. The server + /// caps a single call at 200 rows. + nlohmann::json add_list(const Patient& patient, const std::vector& data); + nlohmann::json add(const Patient& patient, const std::string& measure_type, + const nlohmann::json& fields); + nlohmann::json update(const Patient& patient, const std::string& measure_type, + const std::string& id, const nlohmann::json& fields); + /// Named delete_measure because `delete` is a reserved keyword in C++. + nlohmann::json delete_measure(const Patient& patient, const std::string& measure_type, + const std::string& id); + /// Legacy bulk submission for `teusan` integrations. + /// + /// Deprecated: requires the `teusan` scope instead of `apiouther`, takes a flat + /// identity + phone number instead of a patient object, and writes into the + /// shared consumer tenant rather than your own company — so the values are not + /// readable through `last` or `list`. Prefer `add_list`. + [[deprecated("Requires the teusan scope and writes into the shared consumer tenant; prefer add_list.")]] + nlohmann::json health_information(const std::optional& identity, + const std::optional& phone_number, + const std::vector& data); private: detail::Transport* t_; @@ -565,44 +430,43 @@ class DietsResource { // ---------------- client ---------------- -/// The Bulutklinik API client. Construct once and reuse; resources are obtained -/// via accessor methods (e.g. client.doctors().quick_search(...)). +/// The Bulutklinik partner API client. Construct once and reuse; resources are +/// obtained via accessor methods (e.g. client.doctors().branches()). class Client { public: + /// @throws std::invalid_argument when both `partner_token` and `token_store` + /// are set. explicit Client(ClientOptions options = {}); ~Client(); Client(const Client&) = delete; Client& operator=(const Client&) = delete; - AuthResource auth(); DoctorsResource doctors(); SlotsResource slots(); AppointmentsResource appointments(); - PaymentsResource payments(); MeasuresResource measures(); - SkinResource skin(); - MealsResource meals(); LaboratoryResource laboratory(); DietsResource diets(); - AddressesResource addresses(); /// 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 (`Auth::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 this only for the gaps. + /// so default headers, the chosen `auth` mode (`Auth::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. /// /// @example /// ```cpp - /// auto branches = client.request("GET", "/patients/allBranches"); - /// auto created = client.request("POST", "/patients/someNewEndpoint", - /// {bulutklinik::Auth::Bearer, {{"foo", "bar"}}}); + /// auto branches = client.request("GET", "/outher/branches"); + /// // Auth::Public reaches unauthenticated endpoints outside the partner surface + /// auto config = client.request("GET", "/general/getConfig", + /// {bulutklinik::Auth::Public}); /// ``` nlohmann::json request(const std::string& method, const std::string& path, const RequestOptions& options = {}); + /// The active token store. Write a newly issued partner token here to rotate + /// the credential without rebuilding the client. TokenStore& token_store(); private: diff --git a/src/bulutklinik.cpp b/src/bulutklinik.cpp index 3ff2eb7..cf276b5 100644 --- a/src/bulutklinik.cpp +++ b/src/bulutklinik.cpp @@ -5,16 +5,20 @@ namespace bulutklinik { namespace { -std::string base_url_for(Environment env) { +std::string api_root_for(Environment env) { switch (env) { case Environment::Production: - return "https://api.bulutklinik.com/api/v3"; + return "https://api.bulutklinik.com/api"; case Environment::Test: - return "https://apitest.bulutklinik.com/api/v3"; + return "https://apitest.bulutklinik.com/api"; case Environment::Local: - return "https://api-bulutklinik.test/api/v3"; + return "https://api-bulutklinik.test/api"; } - return "https://api.bulutklinik.com/api/v3"; + return "https://api.bulutklinik.com/api"; +} + +std::string version_segment(ApiVersion version) { + return version == ApiVersion::V4 ? "v4" : "v3"; } bool iequals(const std::string& a, const std::string& b) { @@ -83,6 +87,13 @@ nlohmann::json parse_envelope(const std::string& text) { if (result_type && *result_type == 2) { throw AuthenticationError(message, status, result_type, error_type, data, method, path, retry); } + // resultType 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 && *result_type == 4) { + throw AuthenticationError(message + " The partner token is expired or invalid - install a newly" + " issued token; the SDK cannot refresh it.", + status, result_type, error_type, data, method, path, retry); + } if (is_validation) { throw ValidationError(message, status, result_type, error_type, data, method, path, retry); } @@ -104,73 +115,63 @@ nlohmann::json parse_envelope(const std::string& text) { namespace detail { -enum class AuthMode { Public, Bearer, Partner }; +enum class AuthMode { Public, Partner }; +/// Builds requests, unwraps the response envelope and maps failures to typed +/// exceptions. +/// +/// 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. class Transport { public: Transport(std::shared_ptr backend, std::string base_url, std::string lang, - std::optional client_id, std::optional client_secret, - std::optional partner_token, std::shared_ptr token_store, long timeout_ms) + std::shared_ptr token_store, long timeout_ms) : backend_(std::move(backend)), base_url_(std::move(base_url)), lang_(std::move(lang)), - client_id_(std::move(client_id)), - client_secret_(std::move(client_secret)), - partner_token_(std::move(partner_token)), token_store_(std::move(token_store)), timeout_ms_(timeout_ms) {} TokenStore& token_store() { return *token_store_; } - const std::optional& client_id() const { return client_id_; } - const std::optional& client_secret() const { return client_secret_; } nlohmann::json send(const std::string& method, const std::string& path, AuthMode auth, const nlohmann::json& body = nlohmann::json(), const std::optional& lang = std::nullopt) { - return send_impl(method, path, auth, body, lang, false); - } - - void refresh() { - if (!try_refresh(std::nullopt)) { - throw AuthenticationError("token refresh failed", 401, std::nullopt, nlohmann::json(nullptr), - nlohmann::json(nullptr), "POST", "/general/refreshApi", std::nullopt); - } - } - -private: - struct Dispatch { - int status; - nlohmann::json envelope; - std::optional retry_after; - }; - - nlohmann::json send_impl(const std::string& method, const std::string& path, AuthMode auth, - const nlohmann::json& body, const std::optional& lang, - bool is_retry) { - std::optional stale_access; - if (auth == AuthMode::Bearer) { - stale_access = token_store_->access_token(); - } - Dispatch d = dispatch(method, path, auth, body, lang); std::optional result_type = result_type_of(d.envelope); if (d.status >= 200 && d.status < 300 && result_type && *result_type == 0) { return d.envelope.contains("data") ? d.envelope["data"] : nlohmann::json(nullptr); } - - const bool expired = d.status == 401 || (result_type && *result_type == 4); - if (auth == AuthMode::Bearer && expired && !is_retry && try_refresh(stale_access)) { - return send_impl(method, path, auth, body, lang, 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 (result_type && *result_type == 2) { token_store_->clear(); } throw_api_error(method, path, d.status, d.envelope, d.retry_after); } +private: + struct Dispatch { + int status; + nlohmann::json envelope; + std::optional retry_after; + }; + Dispatch dispatch(const std::string& method, const std::string& path, AuthMode auth, const nlohmann::json& body, const std::optional& lang = std::nullopt) { + std::optional token; + if (auth == AuthMode::Partner) { + token = token_store_->token(); + if (!token || token->empty()) { + // Dispatching anyway would only come back as an opaque 401. + throw AuthenticationError("No partner token configured.", 0, std::nullopt, + nlohmann::json(nullptr), nlohmann::json(nullptr), method, path, + std::nullopt); + } + } + HttpRequest req; req.method = method; req.url = base_url_ + path; @@ -181,15 +182,8 @@ class Transport { req.body = body.dump(); req.headers["Content-Type"] = "application/json"; } - if (auth == AuthMode::Bearer) { - auto token = token_store_->access_token(); - if (token && !token->empty()) { - req.headers["Authorization"] = "Bearer " + *token; - } - } else if (auth == AuthMode::Partner) { - if (partner_token_ && !partner_token_->empty()) { - req.headers["Authorization"] = "Bearer " + *partner_token_; - } + if (token) { + req.headers["Authorization"] = "Bearer " + *token; } HttpResponse resp = backend_->send(req); @@ -205,515 +199,310 @@ class Transport { return Dispatch{resp.status, parse_envelope(resp.body), retry_after}; } - bool try_refresh(const std::optional& stale_access) { - std::lock_guard lock(refresh_mutex_); - if (stale_access && token_store_->access_token() != stale_access) { - return true; - } - auto refresh_token = token_store_->refresh_token(); - if (!refresh_token || refresh_token->empty() || !client_id_ || client_id_->empty() || - !client_secret_ || client_secret_->empty()) { - return false; - } - - nlohmann::json body = { - {"refreshToken", *refresh_token}, - {"clientId", *client_id_}, - {"clientSecretKey", *client_secret_}, - }; - Dispatch d = dispatch("POST", "/general/refreshApi", AuthMode::Public, body); - std::optional result_type = result_type_of(d.envelope); - if (d.status < 200 || d.status >= 300 || !result_type || *result_type != 0 || - !d.envelope.contains("data") || !d.envelope["data"].is_object() || - !d.envelope["data"].contains("access_token") || !d.envelope["data"]["access_token"].is_string()) { - token_store_->clear(); - return false; - } - const auto& data = d.envelope["data"]; - std::optional new_refresh = *refresh_token; - if (data.contains("refresh_token") && data["refresh_token"].is_string()) { - new_refresh = data["refresh_token"].get(); - } - token_store_->set_tokens(data["access_token"].get(), new_refresh); - return true; - } - std::shared_ptr backend_; std::string base_url_; std::string lang_; - std::optional client_id_; - std::optional client_secret_; - std::optional partner_token_; std::shared_ptr token_store_; long timeout_ms_; - std::mutex refresh_mutex_; }; } // namespace detail -namespace { - -void store_tokens(detail::Transport* t, const nlohmann::json& data) { - if (!data.is_object() || !data.contains("access_token") || !data["access_token"].is_string()) { - throw BulutklinikError("Login response did not contain an access token"); - } - std::optional refresh; - if (data.contains("refresh_token") && data["refresh_token"].is_string()) { - refresh = data["refresh_token"].get(); - } - t->token_store().set_tokens(data["access_token"].get(), refresh); -} - -LoginResult finish_login(detail::Transport* t, const nlohmann::json& data) { - if (data.is_object() && data.contains("access_token") && data["access_token"].is_string()) { - store_tokens(t, data); - return LoginResult{false, std::nullopt}; - } - if (data.is_object() && data.contains("response") && data["response"].is_string()) { - return LoginResult{true, data["response"].get()}; - } - return LoginResult{false, std::nullopt}; -} - -nlohmann::json card_to_json(const CardInfo& c) { - return { - {"cardHolder", c.card_holder}, - {"cardNumber", c.card_number}, - {"cardExpMonth", c.card_exp_month}, - {"cardExpYear", c.card_exp_year}, - {"cardCvv", c.card_cvv}, - }; -} - -} // namespace - // ---------------- Client ---------------- Client::Client(ClientOptions options) { - std::string base = options.base_url ? *options.base_url : base_url_for(options.environment); + // 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 (options.partner_token && options.token_store) { + throw std::invalid_argument( + "bulutklinik: set either partner_token or token_store, not both. " + "Seed your own store with the token if you need custom persistence."); + } + + std::string base = options.base_url + ? *options.base_url + : api_root_for(options.environment) + "/" + version_segment(options.api_version); while (!base.empty() && base.back() == '/') { base.pop_back(); } - auto store = options.token_store ? options.token_store : std::make_shared(); + auto store = options.token_store ? options.token_store + : std::make_shared(options.partner_token); auto backend = options.http_backend ? options.http_backend : std::make_shared(); - transport_ = std::make_shared(backend, base, options.lang, options.client_id, - options.client_secret, options.partner_token, store, - options.timeout_ms); + transport_ = std::make_shared(backend, base, options.lang, store, options.timeout_ms); } Client::~Client() = default; -AuthResource Client::auth() { return AuthResource(transport_.get()); } DoctorsResource Client::doctors() { return DoctorsResource(transport_.get()); } SlotsResource Client::slots() { return SlotsResource(transport_.get()); } AppointmentsResource Client::appointments() { return AppointmentsResource(transport_.get()); } -PaymentsResource Client::payments() { return PaymentsResource(transport_.get()); } MeasuresResource Client::measures() { return MeasuresResource(transport_.get()); } -SkinResource Client::skin() { return SkinResource(transport_.get()); } -MealsResource Client::meals() { return MealsResource(transport_.get()); } LaboratoryResource Client::laboratory() { return LaboratoryResource(transport_.get()); } DietsResource Client::diets() { return DietsResource(transport_.get()); } -AddressesResource Client::addresses() { return AddressesResource(transport_.get()); } nlohmann::json Client::request(const std::string& method, const std::string& path, const RequestOptions& options) { - detail::AuthMode auth = detail::AuthMode::Bearer; - switch (options.auth) { - case Auth::Public: - auth = detail::AuthMode::Public; - break; - case Auth::Bearer: - auth = detail::AuthMode::Bearer; - break; - case Auth::Partner: - auth = detail::AuthMode::Partner; - break; - } + detail::AuthMode auth = + options.auth == Auth::Public ? detail::AuthMode::Public : detail::AuthMode::Partner; return transport_->send(method, path, auth, options.body, options.lang); } TokenStore& Client::token_store() { return transport_->token_store(); } -// ---------------- AuthResource ---------------- +// ---------------- resources ---------------- -LoginResult AuthResource::connect(const std::string& api_user_name, - const std::optional& api_user_password, - const std::string& login_mode, - const std::optional& client_id, - const std::optional& client_secret, - const std::optional& with_phone_number) { - nlohmann::json body; - body["apiUserName"] = api_user_name; - body["apiUserPassword"] = api_user_password ? nlohmann::json(*api_user_password) : nlohmann::json(nullptr); - body["apiClientId"] = client_id ? *client_id : t_->client_id().value_or(""); - body["apiSecretKey"] = client_secret ? *client_secret : t_->client_secret().value_or(""); - body["loginMode"] = login_mode; - if (with_phone_number) { - body["withPhoneNumber"] = *with_phone_number; - } - nlohmann::json data = t_->send("POST", "/general/connectApi", detail::AuthMode::Public, body); - return finish_login(t_, data); -} - -void AuthResource::connect_with_two_factor(const std::string& sms_verification_code, const std::string& response) { - nlohmann::json body = {{"smsVerificationCode", sms_verification_code}, {"response", response}}; - nlohmann::json data = t_->send("POST", "/general/connectApiWithTwoFactor", detail::AuthMode::Public, body); - store_tokens(t_, data); -} - -nlohmann::json AuthResource::verify_registration(const VerifyRegistrationInput& in) { - nlohmann::json body; - body["name"] = in.name; - body["surname"] = in.surname; - body["phoneNumber"] = in.phone_number; - body["phone_code"] = in.phone_code; - body["email"] = in.email; - body["password"] = in.password; - body["passwordAgain"] = in.password; - body["acceptUserAgreement"] = in.accept_user_agreement == 0 ? 1 : in.accept_user_agreement; - if (in.recaptcha_v2) { - body["g-recaptcha-response-v2"] = *in.recaptcha_v2; - } - if (in.captcha) { - body["captcha"] = *in.captcha; - } - if (in.user_agreements) { - body["userAgreements"] = *in.user_agreements; - } - return t_->send("POST", "/patients/verifyAddingNewPatient", detail::AuthMode::Partner, body); -} - -void AuthResource::register_patient(const RegisterInput& in) { - nlohmann::json body; - body["name"] = in.name; - body["surname"] = in.surname; - body["apiUserName"] = in.api_user_name; - body["phoneNumber"] = in.phone_number; - body["password"] = in.password; - body["smsVerificationCode"] = in.sms_verification_code; - body["response"] = in.response; - body["acceptUserAgreement"] = in.accept_user_agreement == 0 ? 1 : in.accept_user_agreement; - body["apiClientId"] = in.client_id ? *in.client_id : t_->client_id().value_or(""); - body["apiSecretKey"] = in.client_secret ? *in.client_secret : t_->client_secret().value_or(""); - nlohmann::json data = t_->send("POST", "/patients/addNewPatient", detail::AuthMode::Public, body); - store_tokens(t_, data); -} - -nlohmann::json AuthResource::confirm_registration_email(const ConfirmRegistrationEmailInput& in) { - nlohmann::json body; - body["verificationCode"] = in.verification_code; - body["response"] = in.response; - if (in.user_agreements) { - body["userAgreements"] = *in.user_agreements; - } - return t_->send("POST", "/patients/emailConfirmationRegister", detail::AuthMode::Public, body); -} +namespace { -nlohmann::json AuthResource::verify_registration_social(const VerifyRegistrationSocialInput& in) { - nlohmann::json body; - body["name"] = in.name; - body["surname"] = in.surname; - body["phoneNumber"] = in.phone_number; - body["password"] = in.password; - body["passwordAgain"] = in.password; - body["socialType"] = in.social_type; - body["key"] = in.key; - body["acceptUserAgreement"] = in.accept_user_agreement == 0 ? 1 : in.accept_user_agreement; - if (in.email) { - body["email"] = *in.email; - } - if (in.user_agreements) { - body["userAgreements"] = *in.user_agreements; - } - return t_->send("POST", "/patients/verifyAddingNewPatientSocial", detail::AuthMode::Public, body); +/// Adds an optional string only when it was set, so unset fields never reach the +/// wire as nulls the server would have to interpret. +void put_opt(nlohmann::json& target, const char* key, const std::optional& value) { + if (value) target[key] = *value; } -void AuthResource::register_social(const RegisterSocialInput& in) { +/// Body shared by every patient-scoped read: `{"patient": {...}, ...}`. +nlohmann::json patient_body(const Patient& patient) { nlohmann::json body; - body["smsVerificationCode"] = in.sms_verification_code; - body["response"] = in.response; - if (in.user_agreements) { - body["userAgreements"] = *in.user_agreements; - } - t_->send("POST", "/patients/addNewPatientWithSocial", detail::AuthMode::Public, body); + body["patient"] = patient.to_json(); + return body; } -nlohmann::json AuthResource::forgot_password(const ForgotPasswordInput& in) { - nlohmann::json body; - body["phoneNumber"] = in.phone_number; - if (in.birthdate) { - body["birthdate"] = *in.birthdate; - } - if (in.recaptcha_v2) { - body["g-recaptcha-response-v2"] = *in.recaptcha_v2; +/// Flattens measure fields next to the patient reference, optionally adding an +/// id. The server expects the columns at the top level, not nested. +nlohmann::json measure_body(const Patient& patient, const nlohmann::json* fields, + const std::optional& id) { + nlohmann::json body = patient_body(patient); + if (id) body["id"] = *id; + if (fields && fields->is_object()) { + for (auto it = fields->begin(); it != fields->end(); ++it) body[it.key()] = it.value(); } - if (in.captcha) { - body["captcha"] = *in.captcha; - } - return t_->send("POST", "/patients/forgotPassword", detail::AuthMode::Public, body); -} - -void AuthResource::reset_password(const ResetPasswordInput& in) { - nlohmann::json body; - body["smsConfirmCode"] = in.sms_confirm_code; - body["response"] = in.response; - body["password"] = in.password; - body["passwordAgain"] = in.password; - t_->send("PUT", "/patients/forgotPassword", detail::AuthMode::Public, body); + return body; } -void AuthResource::refresh() { t_->refresh(); } +} // namespace -void AuthResource::disconnect() { - struct ClearGuard { - detail::Transport* t; - ~ClearGuard() { t->token_store().clear(); } - } guard{t_}; - t_->send("POST", "/general/disconnectApi", detail::AuthMode::Bearer, nlohmann::json::object()); +nlohmann::json Patient::to_json() const { + nlohmann::json out = nlohmann::json::object(); + put_opt(out, "name", name); + put_opt(out, "surname", surname); + put_opt(out, "phoneNumber", phone_number); + put_opt(out, "identityNumber", identity_number); + put_opt(out, "email", email); + put_opt(out, "birthdate", birthdate); + put_opt(out, "nationality", nationality); + if (price) out["price"] = *price; + return out; +} + +nlohmann::json AppointmentLookup::to_json() const { + nlohmann::json out = nlohmann::json::object(); + put_opt(out, "hash", hash); + put_opt(out, "outherProcessId", outher_process_id); + put_opt(out, "doctorId", doctor_id); + put_opt(out, "appointmentDate", appointment_date); + if (is_outher_doctor) out["isOutherDoctor"] = *is_outher_doctor; + return out; } // ---------------- DoctorsResource ---------------- -nlohmann::json DoctorsResource::branches() { - return t_->send("GET", "/patients/allBranches", detail::AuthMode::Bearer); -} - -nlohmann::json DoctorsResource::locations() { - return t_->send("GET", "/patients/allLocations", detail::AuthMode::Bearer); +nlohmann::json DoctorsResource::search(const nlohmann::json& search_params, int current_page, + const std::vector& order_params) { + nlohmann::json body = { + {"searchParams", search_params}, + {"orderParams", order_params}, + {"currentPage", current_page}, + }; + return t_->send("POST", "/outher/search", detail::AuthMode::Partner, body); } -nlohmann::json DoctorsResource::quick_search(const std::string& search_text, - const std::optional& list_type, - const std::optional& location) { - nlohmann::json body; - body["searchText"] = search_text; - body["listType"] = list_type ? nlohmann::json(*list_type) : nlohmann::json(nullptr); - body["location"] = location ? nlohmann::json(*location) : nlohmann::json(nullptr); - return t_->send("POST", "/patients/quickSearch", detail::AuthMode::Bearer, body); +nlohmann::json DoctorsResource::branches() { + return t_->send("GET", "/outher/branches", detail::AuthMode::Partner); } -nlohmann::json DoctorsResource::search(const SearchInput& in) { - nlohmann::json body; - body["searchParams"] = in.search_params; - body["orderParams"] = in.order_params; - body["otherParams"] = in.other_params; - body["currentPage"] = in.current_page > 0 ? in.current_page : 1; - body["perPageLimit"] = in.per_page_limit > 0 ? in.per_page_limit : 20; - return t_->send("POST", "/patients/filteredSearch", detail::AuthMode::Bearer, body); +nlohmann::json DoctorsResource::detail(const std::string& doctor_id) { + return t_->send("GET", "/outher/doctorInfos/" + doctor_id, detail::AuthMode::Partner); } -nlohmann::json DoctorsResource::detail(const std::string& id, const std::optional& corporate) { - std::string path = "/patients/doctorDetail/" + id + (corporate ? "/" + *corporate : ""); - return t_->send("GET", path, bulutklinik::detail::AuthMode::Bearer); +nlohmann::json DoctorsResource::locations() { + return t_->send("GET", "/outher/locations", detail::AuthMode::Partner); } // ---------------- SlotsResource ---------------- -nlohmann::json SlotsResource::schedule(const std::string& doctor_id, const std::string& list_type, - const std::optional& schedule_date, int schedule_step, - int schedule_page) { +nlohmann::json SlotsResource::schedule(const std::string& doctor_id, + const std::optional& schedule_date, + std::optional schedule_step, + std::optional schedule_page) { nlohmann::json body; body["doctorId"] = doctor_id; - body["scheduleDate"] = schedule_date ? nlohmann::json(*schedule_date) : nlohmann::json(nullptr); - body["scheduleStep"] = schedule_step; - body["schedulePage"] = schedule_page; - body["listType"] = list_type; - return t_->send("POST", "/patients/doctorScheduler", detail::AuthMode::Bearer, body); + put_opt(body, "scheduleDate", schedule_date); + if (schedule_step) body["scheduleStep"] = *schedule_step; + if (schedule_page) body["schedulePage"] = *schedule_page; + return t_->send("POST", "/outher/doctorSlots", detail::AuthMode::Partner, body); } // ---------------- AppointmentsResource ---------------- -nlohmann::json AppointmentsResource::reserve_interview(const std::string& doctor_id, - const std::string& appointment_date, - const std::string& appointment_type) { - nlohmann::json body = { - {"doctorId", doctor_id}, - {"appointmentDate", appointment_date}, - {"appointmentType", appointment_type}, - }; - return t_->send("POST", "/patients/addInterviewDateReservation", detail::AuthMode::Bearer, body); +nlohmann::json AppointmentsResource::reserve(const std::string& slot_id, + const std::string& doctor_id, + const Patient& user) { + nlohmann::json body = {{"slotId", slot_id}, {"doctorId", doctor_id}, {"user", user.to_json()}}; + return t_->send("POST", "/outher/reservation", detail::AuthMode::Partner, body); } -nlohmann::json AppointmentsResource::add_physical(const std::string& doctor_id, const std::string& appointment_date) { - nlohmann::json body = {{"doctorId", doctor_id}, {"appointmentDate", appointment_date}}; - return t_->send("POST", "/patients/addNewAppointment", detail::AuthMode::Bearer, body); +nlohmann::json AppointmentsResource::reserve_without_agreement(const std::string& slot_id, + const std::string& doctor_id, + const Patient& user) { + nlohmann::json body = {{"slotId", slot_id}, {"doctorId", doctor_id}, {"user", user.to_json()}}; + return t_->send("POST", "/outher/reservationWithoutAgreement", detail::AuthMode::Partner, body); } -nlohmann::json AppointmentsResource::cancel(const std::string& event_id) { - return t_->send("DELETE", "/patients/deleteUserAppointment/" + event_id, detail::AuthMode::Bearer); +nlohmann::json AppointmentsResource::instant_reserve(const Patient& user) { + nlohmann::json body = {{"user", user.to_json()}}; + return t_->send("POST", "/outher/instantReservation", detail::AuthMode::Partner, body); } -nlohmann::json AppointmentsResource::list(std::optional page) { - std::string path = page ? "/patients/userAppointments/" + *page : "/patients/userAppointments"; - return t_->send("GET", path, detail::AuthMode::Bearer); +nlohmann::json AppointmentsResource::create(const std::string& hash, + const std::string& outher_process_id) { + nlohmann::json body = {{"hash", hash}, {"outherProcessId", outher_process_id}}; + return t_->send("POST", "/outher/appointment", detail::AuthMode::Partner, body); } -nlohmann::json AppointmentsResource::reservations() { - return t_->send("GET", "/patients/userReservations", detail::AuthMode::Bearer); +nlohmann::json AppointmentsResource::create_without_slot(const std::string& doctor_id, + const std::string& start_date, + const std::string& finish_date, + const Patient& user, + std::optional is_outher_doctor) { + nlohmann::json body = { + {"doctorId", doctor_id}, + {"startDate", start_date}, + {"finishDate", finish_date}, + {"user", user.to_json()}, + }; + if (is_outher_doctor) body["isOutherDoctor"] = *is_outher_doctor; + return t_->send("POST", "/outher/appointmentWithoutSlot", detail::AuthMode::Partner, body); } -// ---------------- AddressesResource ---------------- - -nlohmann::json AddressesResource::list() { - return t_->send("GET", "/patients/userAddress", detail::AuthMode::Bearer); +nlohmann::json AppointmentsResource::cancel_without_slot(const AppointmentLookup& lookup) { + return t_->send("DELETE", "/outher/appointmentWithoutSlot", detail::AuthMode::Partner, + lookup.to_json()); } -nlohmann::json AddressesResource::add(const AddressInput& in) { +nlohmann::json AppointmentsResource::list(const std::string& phone_number, + const std::optional& page, + const std::optional& type) { nlohmann::json body; - body["title"] = in.title; - body["cityId"] = in.city_id; - body["districtId"] = in.district_id; - body["address"] = in.address; - body["locationLat"] = in.location_lat; - body["locationLng"] = in.location_lng; - if (in.description) { - body["description"] = *in.description; - } - if (in.is_default) { - body["isDefault"] = *in.is_default; - } - return t_->send("POST", "/patients/userAddress", detail::AuthMode::Bearer, body); + body["phoneNumber"] = phone_number; + put_opt(body, "page", page); + put_opt(body, "type", type); + return t_->send("POST", "/outher/appointments", detail::AuthMode::Partner, body); } -nlohmann::json AddressesResource::update(const AddressUpdateInput& in) { - nlohmann::json body; - body["id"] = in.id; - if (in.title) { - body["title"] = *in.title; - } - if (in.description) { - body["description"] = *in.description; - } - if (in.city_id) { - body["cityId"] = *in.city_id; - } - if (in.district_id) { - body["districtId"] = *in.district_id; - } - if (in.address) { - body["address"] = *in.address; - } - if (in.location_lat) { - body["locationLat"] = *in.location_lat; - } - if (in.location_lng) { - body["locationLng"] = *in.location_lng; - } - if (in.is_default) { - body["isDefault"] = *in.is_default; - } - return t_->send("PUT", "/patients/userAddress", detail::AuthMode::Bearer, body); +nlohmann::json AppointmentsResource::info(const AppointmentLookup& lookup) { + return t_->send("POST", "/outher/appointmentInfo", detail::AuthMode::Partner, lookup.to_json()); } -nlohmann::json AddressesResource::delete_address(const std::string& id) { - nlohmann::json body = {{"id", id}}; - return t_->send("DELETE", "/patients/userAddress", detail::AuthMode::Bearer, body); +nlohmann::json AppointmentsResource::check_doctor(const std::string& doctor_id, + int is_outher_doctor) { + nlohmann::json body = {{"doctorId", doctor_id}, {"isOutherDoctor", is_outher_doctor}}; + return t_->send("POST", "/outher/checkDoctor", detail::AuthMode::Partner, body); } -// ---------------- PaymentsResource ---------------- +// ---------------- DietsResource ---------------- -nlohmann::json PaymentsResource::check_discount_code(const std::string& check_type, const std::string& discount_code, - const std::optional& doctor_id, - const std::optional& order_id, - const std::optional& special_service_id, - const std::optional& program_slug) { - nlohmann::json body; - body["checkType"] = check_type; - body["discountCode"] = discount_code; - if (doctor_id) { - body["doctorId"] = *doctor_id; - } - if (order_id) { - body["orderId"] = *order_id; - } - if (special_service_id) { - body["specialServiceId"] = *special_service_id; - } - if (program_slug) { - body["programSlug"] = *program_slug; - } - return t_->send("POST", "/patients/checkDiscountCode", detail::AuthMode::Bearer, body); +nlohmann::json DietsResource::list(const Patient& patient, + const std::optional& page) { + nlohmann::json body = patient_body(patient); + put_opt(body, "currentPage", page); + return t_->send("POST", "/outher/dietLists", detail::AuthMode::Partner, body); } -nlohmann::json PaymentsResource::get_cards() { - return t_->send("GET", "/payments/getCards", detail::AuthMode::Bearer); +nlohmann::json DietsResource::detail(const Patient& patient, const std::string& list_id) { + nlohmann::json body = patient_body(patient); + body["listId"] = list_id; + return t_->send("POST", "/outher/diet", detail::AuthMode::Partner, body); } -nlohmann::json PaymentsResource::save_card(const CardInfo& card) { - return t_->send("POST", "/payments/saveCard", detail::AuthMode::Bearer, card_to_json(card)); +// ---------------- LaboratoryResource ---------------- + +nlohmann::json LaboratoryResource::catalog() { + return t_->send("GET", "/outher/laboratoryCatalog", detail::AuthMode::Partner); } -nlohmann::json PaymentsResource::pay(const PaymentInput& in) { - nlohmann::json body; - body["doctorId"] = in.doctor_id; - body["appointmentDate"] = in.appointment_date; - body["appointmentType"] = in.appointment_type; - body["is3D"] = in.is_3d; - body["termsAccept"] = in.terms_accept; - body["saveCard"] = in.save_card; - body["discountCode"] = in.discount_code; - if (in.card_id) { - body["cardId"] = *in.card_id; - } - if (in.card_info) { - body["cardInfo"] = card_to_json(*in.card_info); - } - if (in.case_detail) { - body["caseDetail"] = *in.case_detail; - } - return t_->send("POST", "/payments/interviewPayment", detail::AuthMode::Bearer, body); +nlohmann::json LaboratoryResource::catalog_detail(const std::string& test_id) { + return t_->send("GET", "/outher/laboratoryCatalog/" + test_id, detail::AuthMode::Partner); +} + +nlohmann::json LaboratoryResource::results(const Patient& patient, + const std::optional& page) { + nlohmann::json body = patient_body(patient); + put_opt(body, "currentPage", page); + return t_->send("POST", "/outher/laboratoryResults", detail::AuthMode::Partner, body); } -nlohmann::json PaymentsResource::delete_card(const std::string& card_id) { - return t_->send("DELETE", "/payments/deleteCard/" + card_id, detail::AuthMode::Bearer); +nlohmann::json LaboratoryResource::result_detail(const Patient& patient, + const std::string& test_id) { + // Kept as a string so a "-lab" suffix (TmcLab order group) survives the round trip. + nlohmann::json body = patient_body(patient); + body["testId"] = test_id; + return t_->send("POST", "/outher/laboratoryResult", detail::AuthMode::Partner, body); } // ---------------- MeasuresResource ---------------- -nlohmann::json MeasuresResource::add_list(const std::vector& records) { - nlohmann::json body; - body["data"] = records; - return t_->send("POST", "/patients/addNewUserMeasures", detail::AuthMode::Bearer, body); +nlohmann::json MeasuresResource::last(const Patient& patient) { + return t_->send("POST", "/outher/lastMeasures", detail::AuthMode::Partner, patient_body(patient)); } -nlohmann::json MeasuresResource::add(const std::string& measure_type, const nlohmann::json& fields) { - return t_->send("POST", "/patients/addNewUserMeasures/" + measure_type, detail::AuthMode::Bearer, fields); +nlohmann::json MeasuresResource::list(const Patient& patient, const std::string& measure_type, + const std::optional& page, + std::optional glucose_type) { + nlohmann::json body = patient_body(patient); + put_opt(body, "currentPage", page); + if (glucose_type) body["glucoseType"] = *glucose_type; + return t_->send("POST", "/outher/measuresList/" + measure_type, detail::AuthMode::Partner, body); } -nlohmann::json MeasuresResource::update(const std::string& measure_type, const nlohmann::json& fields) { - return t_->send("PUT", "/patients/updateUserMeasures/" + measure_type, detail::AuthMode::Bearer, fields); +nlohmann::json MeasuresResource::graph(const Patient& patient, const std::string& measure_type, + int period, const std::optional& page, + std::optional glucose_type) { + nlohmann::json body = patient_body(patient); + put_opt(body, "currentPage", page); + if (glucose_type) body["glucoseType"] = *glucose_type; + std::string path = "/outher/measuresGraph/" + measure_type + "/" + std::to_string(period); + return t_->send("POST", path, detail::AuthMode::Partner, body); } -nlohmann::json MeasuresResource::delete_measure(const std::string& measure_type, const std::string& id) { - nlohmann::json body = {{"id", id}}; - return t_->send("DELETE", "/patients/deleteUserMeasures/" + measure_type, detail::AuthMode::Bearer, body); +nlohmann::json MeasuresResource::add_list(const Patient& patient, + const std::vector& data) { + nlohmann::json body = patient_body(patient); + body["data"] = data; + return t_->send("POST", "/outher/measures", detail::AuthMode::Partner, body); } -nlohmann::json MeasuresResource::last() { - return t_->send("GET", "/patients/measuresList", detail::AuthMode::Bearer); +nlohmann::json MeasuresResource::add(const Patient& patient, const std::string& measure_type, + const nlohmann::json& fields) { + return t_->send("POST", "/outher/measure/" + measure_type, detail::AuthMode::Partner, + measure_body(patient, &fields, std::nullopt)); } -nlohmann::json MeasuresResource::list(const std::string& measure_type, const std::string& page, - std::optional glucose_type) { - std::string path = "/patients/userMeasuresList/" + measure_type + "/" + page; - if (glucose_type) { - path += "/" + std::to_string(*glucose_type); - } - return t_->send("GET", path, detail::AuthMode::Bearer); +nlohmann::json MeasuresResource::update(const Patient& patient, const std::string& measure_type, + const std::string& id, const nlohmann::json& fields) { + return t_->send("PUT", "/outher/measure/" + measure_type, detail::AuthMode::Partner, + measure_body(patient, &fields, id)); } -nlohmann::json MeasuresResource::graph(const std::string& measure_type, int period, const std::string& page, - std::optional glucose_type) { - std::string path = "/patients/userMeasuresGraph/" + measure_type + "/" + std::to_string(period) + "/" + page; - if (glucose_type) { - path += "/" + std::to_string(*glucose_type); - } - return t_->send("GET", path, detail::AuthMode::Bearer); +nlohmann::json MeasuresResource::delete_measure(const Patient& patient, + const std::string& measure_type, + const std::string& id) { + return t_->send("DELETE", "/outher/measure/" + measure_type, detail::AuthMode::Partner, + measure_body(patient, nullptr, id)); } -nlohmann::json MeasuresResource::partner_health_information(const std::optional& identity, - const std::optional& phone_number, - const std::vector& data) { +nlohmann::json MeasuresResource::health_information(const std::optional& identity, + const std::optional& phone_number, + const std::vector& data) { + // Legacy `teusan` contract: flat, no `patient` wrapper. Kept verbatim. nlohmann::json body; body["identity"] = identity ? nlohmann::json(*identity) : nlohmann::json(nullptr); body["phoneNumber"] = phone_number ? nlohmann::json(*phone_number) : nlohmann::json(nullptr); @@ -721,67 +510,4 @@ nlohmann::json MeasuresResource::partner_health_information(const std::optional< return t_->send("POST", "/outher/healthInformation", detail::AuthMode::Partner, body); } -// ---------------- SkinResource ---------------- - -nlohmann::json SkinResource::analyze(const std::vector& images) { - nlohmann::json body; - body["images"] = images; - return t_->send("POST", "/patients/imageCheck", detail::AuthMode::Bearer, body); -} - -// ---------------- MealsResource ---------------- - -nlohmann::json MealsResource::analyze(const MealInput& in) { - nlohmann::json body; - body["image"] = in.image; - body["portion_size"] = in.portion_size; - body["meal_type"] = in.meal_type; - if (in.portion_grams) { - body["portion_grams"] = *in.portion_grams; - } - if (in.note) { - body["note"] = *in.note; - } - return t_->send("POST", "/patients/imageAnalyzeMeal", detail::AuthMode::Bearer, body); -} - -// ---------------- LaboratoryResource ---------------- - -nlohmann::json LaboratoryResource::results(std::optional page) { - std::string path = "/patients/userLabTestList" + (page ? "/" + *page : ""); - return t_->send("GET", path, detail::AuthMode::Bearer); -} - -nlohmann::json LaboratoryResource::result_detail(const std::string& test_id) { - return t_->send("GET", "/patients/userLabTestDetail/" + test_id, detail::AuthMode::Bearer); -} - -nlohmann::json LaboratoryResource::catalog() { - return t_->send("GET", "/patients/allLaboratoryTests", detail::AuthMode::Bearer); -} - -nlohmann::json LaboratoryResource::catalog_detail(const std::string& id) { - return t_->send("GET", "/patients/laboratoryTestDetail/" + id, detail::AuthMode::Bearer); -} - -nlohmann::json LaboratoryResource::order(const LabOrderInput& in) { - nlohmann::json body = { - {"testId", in.test_id}, - {"addressId", in.address_id}, - {"laboratoryId", in.laboratory_id}, - }; - return t_->send("POST", "/patients/addNewLaboratoryTest", detail::AuthMode::Bearer, body); -} - -// ---------------- DietsResource ---------------- - -nlohmann::json DietsResource::list(std::optional page) { - std::string path = "/patients/dietLists" + (page ? "/" + *page : ""); - return t_->send("GET", path, detail::AuthMode::Bearer); -} - -nlohmann::json DietsResource::detail(const std::string& list_id) { - return t_->send("GET", "/patients/diet/" + list_id, detail::AuthMode::Bearer); -} - } // namespace bulutklinik diff --git a/tests/test_ai.cpp b/tests/test_ai.cpp deleted file mode 100644 index 4964fda..0000000 --- a/tests/test_ai.cpp +++ /dev/null @@ -1,114 +0,0 @@ -#include -#include -#include -#include -#include - -#include - -#include - -using namespace bulutklinik; - -namespace { - -class MockBackend : public HttpBackend { -public: - std::function responder; - std::vector requests; - - HttpResponse send(const HttpRequest& request) override { - requests.push_back(request); - return responder(request); - } -}; - -HttpResponse json_resp(int status, const std::string& body) { - HttpResponse r; - r.status = status; - r.body = body; - return r; -} - -ClientOptions base_options(const std::shared_ptr& backend, std::shared_ptr store) { - ClientOptions o; - o.base_url = "http://localhost"; - o.http_backend = backend; - o.token_store = std::move(store); - return o; -} - -} // namespace - -TEST_CASE("skin.analyze posts images to /patients/imageCheck with a bearer token") { - auto backend = std::make_shared(); - backend->responder = [](const HttpRequest&) { - return json_resp(200, R"({"resultType":0,"data":{"status":[{"id":1,"label":"nevus","case_detail":"blob"}]}})"); - }; - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - std::vector images = {{{"image", "BASE64"}, {"branch_id", 42}}}; - auto data = client.skin().analyze(images); - - REQUIRE(data["status"][0]["label"] == "nevus"); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/imageCheck"); - REQUIRE(backend->requests.at(0).method == "POST"); - REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer abc"); - - auto body = nlohmann::json::parse(backend->requests.at(0).body.value()); - nlohmann::json expected = {{"images", {{{"image", "BASE64"}, {"branch_id", 42}}}}}; - REQUIRE(body == expected); -} - -TEST_CASE("meals.analyze maps the input to the snake_case body with optional fields") { - auto backend = std::make_shared(); - backend->responder = [](const HttpRequest&) { - return json_resp(200, R"({"resultType":0,"data":{"status":{"comment":"{}"}}})"); - }; - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - MealInput input; - input.image = "BASE64"; - input.portion_size = "custom"; - input.portion_grams = 300; - input.meal_type = "lunch"; - input.note = "az yağlı"; - client.meals().analyze(input); - - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/imageAnalyzeMeal"); - REQUIRE(backend->requests.at(0).method == "POST"); - - auto body = nlohmann::json::parse(backend->requests.at(0).body.value()); - nlohmann::json expected = { - {"image", "BASE64"}, - {"portion_size", "custom"}, - {"meal_type", "lunch"}, - {"portion_grams", 300}, - {"note", "az yağlı"}, - }; - REQUIRE(body == expected); -} - -TEST_CASE("meals.analyze omits optional fields when not set") { - auto backend = std::make_shared(); - backend->responder = [](const HttpRequest&) { - return json_resp(200, R"({"resultType":0,"data":{"status":{"comment":"{}"}}})"); - }; - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - MealInput input; - input.image = "BASE64"; - input.portion_size = "medium"; - input.meal_type = "snack"; - client.meals().analyze(input); - - auto body = nlohmann::json::parse(backend->requests.at(0).body.value()); - nlohmann::json expected = { - {"image", "BASE64"}, - {"portion_size", "medium"}, - {"meal_type", "snack"}, - }; - REQUIRE(body == expected); - REQUIRE_FALSE(body.contains("portion_grams")); - REQUIRE_FALSE(body.contains("note")); -} diff --git a/tests/test_lab_diets.cpp b/tests/test_lab_diets.cpp deleted file mode 100644 index 3de3c24..0000000 --- a/tests/test_lab_diets.cpp +++ /dev/null @@ -1,156 +0,0 @@ -#include -#include -#include -#include -#include - -#include - -#include - -using namespace bulutklinik; - -namespace { - -class MockBackend : public HttpBackend { -public: - std::function responder; - std::vector requests; - - HttpResponse send(const HttpRequest& request) override { - requests.push_back(request); - return responder(request); - } -}; - -HttpResponse json_resp(int status, const std::string& body) { - HttpResponse r; - r.status = status; - r.body = body; - return r; -} - -ClientOptions base_options(const std::shared_ptr& backend, std::shared_ptr store) { - ClientOptions o; - o.base_url = "http://localhost"; - o.http_backend = backend; - o.token_store = std::move(store); - return o; -} - -std::shared_ptr ok_backend(const std::string& data = R"({"resultType":0,"data":{}})") { - auto backend = std::make_shared(); - backend->responder = [data](const HttpRequest&) { return json_resp(200, data); }; - return backend; -} - -} // namespace - -TEST_CASE("laboratory.results GETs the list with a page segment and a bearer token") { - auto backend = ok_backend(R"({"resultType":0,"data":{"foundTestsCount":0,"foundTests":[]}})"); - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - client.laboratory().results(std::string("2")); - - REQUIRE(backend->requests.at(0).method == "GET"); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/userLabTestList/2"); - REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer abc"); - REQUIRE_FALSE(backend->requests.at(0).body.has_value()); -} - -TEST_CASE("laboratory.results omits the page segment when page is not set") { - auto backend = ok_backend(R"({"resultType":0,"data":{"foundTestsCount":0,"foundTests":[]}})"); - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - client.laboratory().results(); - - REQUIRE(backend->requests.at(0).method == "GET"); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/userLabTestList"); -} - -TEST_CASE("laboratory.result_detail interpolates a string test id verbatim (incl. -lab suffix)") { - auto backend = ok_backend(); - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - client.laboratory().result_detail("4821-lab"); - - REQUIRE(backend->requests.at(0).method == "GET"); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/userLabTestDetail/4821-lab"); - REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer abc"); -} - -TEST_CASE("laboratory.catalog GETs the orderable test catalog") { - auto backend = ok_backend(); - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - client.laboratory().catalog(); - - REQUIRE(backend->requests.at(0).method == "GET"); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/allLaboratoryTests"); -} - -TEST_CASE("laboratory.catalog_detail GETs a single catalog group by id") { - auto backend = ok_backend(); - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - client.laboratory().catalog_detail("17"); - - REQUIRE(backend->requests.at(0).method == "GET"); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/laboratoryTestDetail/17"); -} - -TEST_CASE("laboratory.order POSTs the three ids to addNewLaboratoryTest") { - auto backend = ok_backend(R"({"resultType":0,"data":{"preOrderId":99}})"); - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - LabOrderInput input; - input.test_id = "12"; - input.address_id = "34"; - input.laboratory_id = "56"; - auto data = client.laboratory().order(input); - - REQUIRE(data["preOrderId"] == 99); - REQUIRE(backend->requests.at(0).method == "POST"); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/addNewLaboratoryTest"); - REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer abc"); - - auto body = nlohmann::json::parse(backend->requests.at(0).body.value()); - nlohmann::json expected = { - {"testId", "12"}, - {"addressId", "34"}, - {"laboratoryId", "56"}, - }; - REQUIRE(body == expected); -} - -TEST_CASE("diets.list GETs the diet lists with a page segment and a bearer token") { - auto backend = ok_backend(R"({"resultType":0,"data":{"foundDietsCount":0,"foundDiets":[]}})"); - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - client.diets().list(std::string("3")); - - REQUIRE(backend->requests.at(0).method == "GET"); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/dietLists/3"); - REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer abc"); -} - -TEST_CASE("diets.list omits the page segment when page is not set") { - auto backend = ok_backend(R"({"resultType":0,"data":{"foundDietsCount":0,"foundDiets":[]}})"); - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - client.diets().list(); - - REQUIRE(backend->requests.at(0).method == "GET"); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/dietLists"); -} - -TEST_CASE("diets.detail GETs one diet list by list id") { - auto backend = ok_backend(); - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); - - client.diets().detail("501"); - - REQUIRE(backend->requests.at(0).method == "GET"); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/diet/501"); - REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer abc"); -} diff --git a/tests/test_resources.cpp b/tests/test_resources.cpp new file mode 100644 index 0000000..5ac7319 --- /dev/null +++ b/tests/test_resources.cpp @@ -0,0 +1,261 @@ +#include +#include +#include +#include +#include + +#include + +#include + +using namespace bulutklinik; + +namespace { + +class MockBackend : public HttpBackend { +public: + std::function responder; + std::vector requests; + + HttpResponse send(const HttpRequest& request) override { + requests.push_back(request); + return responder(request); + } +}; + +HttpResponse ok_resp() { + HttpResponse r; + r.status = 200; + r.body = R"({"resultType":0,"data":null})"; + return r; +} + +std::shared_ptr make_backend() { + auto backend = std::make_shared(); + backend->responder = [](const HttpRequest&) { return ok_resp(); }; + return backend; +} + +ClientOptions partner_options(const std::shared_ptr& backend) { + ClientOptions o; + o.base_url = "http://localhost"; + o.http_backend = backend; + o.partner_token = "PT"; + return o; +} + +Patient reference_patient() { + Patient p; + p.identity_number = "12345678901"; + return p; +} + +Patient write_patient() { + Patient p; + p.name = "Ada"; + p.surname = "Lovelace"; + p.phone_number = "+905551112233"; + return p; +} + +} // namespace + +TEST_CASE("every call sends the partner token") { + auto backend = make_backend(); + Client client(partner_options(backend)); + + client.doctors().branches(); + client.measures().last(reference_patient()); + + for (const auto& request : backend->requests) { + REQUIRE(request.headers.at("Authorization") == "Bearer PT"); + } +} + +TEST_CASE("discovery paths") { + auto backend = make_backend(); + Client client(partner_options(backend)); + + client.doctors().locations(); + client.doctors().detail("42"); + client.laboratory().catalog(); + client.laboratory().catalog_detail("18246"); + client.slots().schedule("7", std::string("2026-08-01")); + + const std::vector expected = { + "/outher/locations", + "/outher/doctorInfos/42", + "/outher/laboratoryCatalog", + "/outher/laboratoryCatalog/18246", + "/outher/doctorSlots", + }; + for (size_t i = 0; i < expected.size(); ++i) { + REQUIRE(backend->requests[i].url.find(expected[i]) != std::string::npos); + } +} + +TEST_CASE("patient reference travels in the body, not the path") { + auto backend = make_backend(); + Client client(partner_options(backend)); + const Patient patient = reference_patient(); + + client.diets().list(patient, std::string("2")); + client.measures().list(patient, "glucose", std::string("1"), 0); + client.laboratory().results(patient); + + // The identity number must never leak into a URL — it would land in access + // logs, proxy logs and error breadcrumbs. + for (const auto& request : backend->requests) { + REQUIRE(request.url.find("12345678901") == std::string::npos); + } + + auto body = nlohmann::json::parse(backend->requests[0].body.value()); + REQUIRE(body["patient"]["identityNumber"] == "12345678901"); + REQUIRE(body["currentPage"] == "2"); + + REQUIRE(backend->requests[1].url.find("/outher/measuresList/glucose") != std::string::npos); +} + +TEST_CASE("lab result id round-trips with its suffix") { + auto backend = make_backend(); + Client client(partner_options(backend)); + const Patient patient = reference_patient(); + + client.laboratory().result_detail(patient, "1234-lab"); + auto body = nlohmann::json::parse(backend->requests[0].body.value()); + REQUIRE(body["testId"] == "1234-lab"); +} + +TEST_CASE("measure write verbs and paths") { + auto backend = make_backend(); + Client client(partner_options(backend)); + const Patient writer = write_patient(); + const Patient reference = reference_patient(); + + std::vector rows = { + {{"type", "pulse"}, {"date_time", "2026-06-17 09:00"}, {"pulse", 72}}, + }; + client.measures().add_list(writer, rows); + client.measures().add( + writer, "tension", + nlohmann::json{{"date_time", "2026-06-17 09:00"}, {"hypertension", 120}, {"hypotension", 80}}); + client.measures().update( + reference, "tension", "9", + nlohmann::json{{"date_time", "2026-06-17 10:00"}, {"hypertension", 125}, {"hypotension", 85}}); + client.measures().delete_measure(reference, "tension", "9"); + + REQUIRE(backend->requests[0].method == "POST"); + REQUIRE(backend->requests[0].url.find("/outher/measures") != std::string::npos); + REQUIRE(backend->requests[1].method == "POST"); + REQUIRE(backend->requests[1].url.find("/outher/measure/tension") != std::string::npos); + REQUIRE(backend->requests[2].method == "PUT"); + REQUIRE(backend->requests[3].method == "DELETE"); + + // Measure fields are flattened alongside `patient`, matching the server shape. + auto add_body = nlohmann::json::parse(backend->requests[1].body.value()); + REQUIRE(add_body["hypertension"] == 120); + REQUIRE(add_body["patient"]["name"] == "Ada"); + + auto delete_body = nlohmann::json::parse(backend->requests[3].body.value()); + REQUIRE(delete_body["id"] == "9"); +} + +TEST_CASE("appointment lifecycle") { + auto backend = make_backend(); + Client client(partner_options(backend)); + const Patient user = write_patient(); + + client.appointments().reserve("1", "2", user); + client.appointments().create("h", "5"); + client.appointments().list("+905551112233"); + + AppointmentLookup lookup; + lookup.hash = "h"; + lookup.outher_process_id = "5"; + client.appointments().cancel_without_slot(lookup); + + REQUIRE(backend->requests[0].url.find("/outher/reservation") != std::string::npos); + REQUIRE(backend->requests[1].url.find("/outher/appointment") != std::string::npos); + REQUIRE(backend->requests[2].url.find("/outher/appointments") != std::string::npos); + REQUIRE(backend->requests[3].method == "DELETE"); + + auto body = nlohmann::json::parse(backend->requests[0].body.value()); + REQUIRE(body["slotId"] == "1"); + REQUIRE(body["user"]["surname"] == "Lovelace"); +} + +TEST_CASE("remaining appointment endpoints") { + auto backend = make_backend(); + Client client(partner_options(backend)); + const Patient user = write_patient(); + + client.appointments().check_doctor("2", 0); + client.appointments().reserve_without_agreement("1", "2", user); + client.appointments().instant_reserve(user); + client.appointments().create_without_slot("2", "2026-08-01 09:00", "2026-08-01 09:30", user); + + AppointmentLookup lookup; + lookup.hash = "h"; + lookup.outher_process_id = "5"; + client.appointments().info(lookup); + + const std::vector expected = { + "/outher/checkDoctor", + "/outher/reservationWithoutAgreement", + "/outher/instantReservation", + "/outher/appointmentWithoutSlot", + "/outher/appointmentInfo", + }; + for (size_t i = 0; i < expected.size(); ++i) { + REQUIRE(backend->requests[i].url.find(expected[i]) != std::string::npos); + } +} + +TEST_CASE("measures graph path and the legacy teusan shape") { + auto backend = make_backend(); + Client client(partner_options(backend)); + + Patient by_phone; + by_phone.phone_number = "+905551112233"; + client.measures().graph(by_phone, "weight", 3); + REQUIRE(backend->requests[0].url.find("/outher/measuresGraph/weight/3") != std::string::npos); + + std::vector rows = { + {{"type", "pulse"}, {"date_time", "2026-06-17 09:00"}, {"pulse", 72}}, + }; +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#elif defined(_MSC_VER) +#pragma warning(push) +#pragma warning(disable : 4996) +#endif + // Deliberately exercising the deprecated legacy endpoint. + client.measures().health_information(std::string("12345678901"), + std::string("+905551112233"), rows); +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#elif defined(_MSC_VER) +#pragma warning(pop) +#endif + + REQUIRE(backend->requests[1].url.find("/outher/healthInformation") != std::string::npos); + auto body = nlohmann::json::parse(backend->requests[1].body.value()); + // No `patient` wrapper here — this endpoint predates that contract. + REQUIRE_FALSE(body.contains("patient")); + REQUIRE(body["identity"] == "12345678901"); +} + +TEST_CASE("diet detail and catalog detail paths") { + auto backend = make_backend(); + Client client(partner_options(backend)); + const Patient reference = reference_patient(); + + client.diets().detail(reference, "77"); + client.laboratory().catalog_detail("18246"); + + REQUIRE(backend->requests[0].url.find("/outher/diet") != std::string::npos); + auto body = nlohmann::json::parse(backend->requests[0].body.value()); + REQUIRE(body["listId"] == "77"); + REQUIRE(backend->requests[1].url.find("/outher/laboratoryCatalog/18246") != std::string::npos); +} diff --git a/tests/test_transport.cpp b/tests/test_transport.cpp index 627d674..2a4647e 100644 --- a/tests/test_transport.cpp +++ b/tests/test_transport.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -30,50 +31,123 @@ HttpResponse json_resp(int status, const std::string& body) { return r; } -ClientOptions base_options(const std::shared_ptr& backend, std::shared_ptr store) { +/// Options pointing at the mock backend. Unless a store is supplied, the +/// credential is the partner token "PT". +ClientOptions base_options(const std::shared_ptr& backend, + std::shared_ptr store = nullptr) { ClientOptions o; o.base_url = "http://localhost"; o.http_backend = backend; - o.token_store = std::move(store); + if (store) { + o.token_store = std::move(store); + } else { + o.partner_token = "PT"; + } return o; } +std::shared_ptr ok_backend() { + auto backend = std::make_shared(); + backend->responder = [](const HttpRequest&) { + return json_resp(200, R"({"resultType":0,"data":{"ok":true}})"); + }; + return backend; +} + +Patient reference_patient() { + Patient p; + p.identity_number = "12345678901"; + return p; +} + } // namespace -TEST_CASE("unwraps data and sends headers") { +TEST_CASE("unwraps data and sends the partner token and lang header") { auto backend = std::make_shared(); backend->responder = [](const HttpRequest&) { - return json_resp(200, R"({"resultType":0,"data":{"searchedDoctors":[]}})"); + return json_resp(200, R"({"resultType":0,"data":{"foundDoctors":[]}})"); }; - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); + Client client(base_options(backend)); - auto data = client.doctors().quick_search("kardiyo"); + auto data = client.doctors().search(nlohmann::json{{"withFreeText", "kardiyoloji"}}, 1, {"slot"}); - REQUIRE(data["searchedDoctors"].is_array()); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/quickSearch"); - REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer abc"); + REQUIRE(data["foundDoctors"].is_array()); + REQUIRE(backend->requests.at(0).url == "http://localhost/outher/search"); + REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer PT"); REQUIRE(backend->requests.at(0).headers.at("lang") == "tr"); } -TEST_CASE("request escape hatch issues a bearer GET to the right URL") { - auto backend = std::make_shared(); - backend->responder = [](const HttpRequest&) { - return json_resp(200, R"({"resultType":0,"data":{"ok":true}})"); - }; - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); +TEST_CASE("api version selects the base URL without changing any path") { + for (const auto& pair : std::vector>{ + {ApiVersion::V3, "https://apitest.bulutklinik.com/api/v3/outher/branches"}, + {ApiVersion::V4, "https://apitest.bulutklinik.com/api/v4/outher/branches"}, + }) { + auto backend = ok_backend(); + ClientOptions o; + o.environment = Environment::Test; + o.api_version = pair.first; + o.partner_token = "PT"; + o.http_backend = backend; + Client client(o); + + client.doctors().branches(); + + REQUIRE(backend->requests.at(0).url == pair.second); + } +} + +TEST_CASE("partner_token and token_store together is rejected") { + ClientOptions o; + o.partner_token = "PT"; + o.token_store = std::make_shared(std::string("OTHER")); + + REQUIRE_THROWS_AS(Client(o), std::invalid_argument); +} + +TEST_CASE("partner_token seeds the default store") { + auto backend = ok_backend(); + Client client(base_options(backend)); + + REQUIRE(client.token_store().token().value() == "PT"); +} + +TEST_CASE("a missing token fails before dispatch") { + auto backend = ok_backend(); + Client client(base_options(backend, std::make_shared())); + + REQUIRE_THROWS_AS(client.doctors().branches(), AuthenticationError); + REQUIRE(backend->requests.empty()); +} + +TEST_CASE("the token is read from the store on every call") { + auto backend = ok_backend(); + auto store = std::make_shared(std::string("first")); + Client client(base_options(backend, store)); + + client.doctors().branches(); + store->set_token(std::string("second")); + client.doctors().branches(); + + REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer first"); + REQUIRE(backend->requests.at(1).headers.at("Authorization") == "Bearer second"); +} + +TEST_CASE("request escape hatch defaults to the partner token") { + auto backend = ok_backend(); + Client client(base_options(backend)); - auto data = client.request("GET", "/patients/customEndpoint"); + auto data = client.request("GET", "/outher/customEndpoint"); REQUIRE(data["ok"].get()); - REQUIRE(backend->requests.at(0).url == "http://localhost/patients/customEndpoint"); + REQUIRE(backend->requests.at(0).url == "http://localhost/outher/customEndpoint"); REQUIRE(backend->requests.at(0).method == "GET"); - REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer abc"); + REQUIRE(backend->requests.at(0).headers.at("Authorization") == "Bearer PT"); } TEST_CASE("request escape hatch sends a public POST body and omits Authorization") { auto backend = std::make_shared(); backend->responder = [](const HttpRequest&) { return json_resp(200, R"({"resultType":0,"data":{"id":7}})"); }; - Client client(base_options(backend, std::make_shared(std::string("abc"), std::nullopt))); + Client client(base_options(backend)); RequestOptions options; options.auth = Auth::Public; @@ -92,45 +166,42 @@ TEST_CASE("maps 422 to ValidationError") { backend->responder = [](const HttpRequest&) { return json_resp(422, R"({"resultType":1,"errorType":"validation"})"); }; - Client client(base_options(backend, std::make_shared(std::string("a"), std::nullopt))); + Client client(base_options(backend)); REQUIRE_THROWS_AS(client.doctors().branches(), ValidationError); } +TEST_CASE("maps 403 to AuthorizationError") { + auto backend = std::make_shared(); + backend->responder = [](const HttpRequest&) { return json_resp(403, R"({"resultType":1})"); }; + Client client(base_options(backend)); + + REQUIRE_THROWS_AS(client.doctors().branches(), AuthorizationError); +} + TEST_CASE("maps numeric 404 to NotFoundError") { auto backend = std::make_shared(); backend->responder = [](const HttpRequest&) { return json_resp(404, R"({"resultType":1,"errorType":1,"errorMessage":"Bilinmeyen"})"); }; - Client client(base_options(backend, std::make_shared(std::string("a"), std::nullopt))); + Client client(base_options(backend)); - REQUIRE_THROWS_AS(client.doctors().quick_search("x"), NotFoundError); + REQUIRE_THROWS_AS(client.doctors().branches(), NotFoundError); } -TEST_CASE("refreshes once then retries with the new token") { +TEST_CASE("an expired token is surfaced without retrying") { auto backend = std::make_shared(); - int data_calls = 0; - backend->responder = [&data_calls](const HttpRequest& req) { - if (req.url.find("/general/refreshApi") != std::string::npos) { - return json_resp(200, R"({"resultType":0,"data":{"access_token":"new","refresh_token":"r2"}})"); - } - ++data_calls; - if (data_calls == 1) { - return json_resp(401, R"({"resultType":4})"); - } - return json_resp(200, R"({"resultType":0,"data":{"ok":true}})"); + backend->responder = [](const HttpRequest&) { + return json_resp(401, R"({"resultType":4,"errorMessage":"You must log in."})"); }; - auto store = std::make_shared(std::string("old"), std::string("r")); - ClientOptions o = base_options(backend, store); - o.client_id = "c"; - o.client_secret = "s"; - Client client(o); - - auto data = client.measures().last(); + auto store = std::make_shared(std::string("expired")); + Client client(base_options(backend, store)); - REQUIRE(data["ok"].get()); - REQUIRE(store->access_token().value() == "new"); - REQUIRE(backend->requests.back().headers.at("Authorization") == "Bearer new"); + REQUIRE_THROWS_AS(client.measures().last(reference_patient()), AuthenticationError); + REQUIRE(backend->requests.size() == 1); + // An expired token is kept: the caller may want to inspect it while + // installing the replacement. Only a revoked one is cleared. + REQUIRE(store->token().value() == "expired"); } TEST_CASE("logout clears the store") { @@ -138,44 +209,22 @@ TEST_CASE("logout clears the store") { backend->responder = [](const HttpRequest&) { return json_resp(200, R"({"resultType":2,"errorMessage":"logged out"})"); }; - auto store = std::make_shared(std::string("a"), std::string("r")); + auto store = std::make_shared(std::string("revoked")); Client client(base_options(backend, store)); - REQUIRE_THROWS_AS(client.measures().last(), AuthenticationError); - REQUIRE_FALSE(store->access_token().has_value()); + REQUIRE_THROWS_AS(client.measures().last(reference_patient()), AuthenticationError); + REQUIRE_FALSE(store->token().has_value()); } -TEST_CASE("connect stores tokens and fills credentials") { +TEST_CASE("transport failures become TransportError") { auto backend = std::make_shared(); backend->responder = [](const HttpRequest&) { - return json_resp(200, R"({"resultType":0,"data":{"access_token":"t","refresh_token":"r"}})"); - }; - auto store = std::make_shared(); - ClientOptions o = base_options(backend, store); - o.client_id = "c"; - o.client_secret = "s"; - Client client(o); - - auto result = client.auth().connect("u", std::string("p"), "email"); - - REQUIRE_FALSE(result.two_factor_required); - REQUIRE(store->access_token().value() == "t"); - auto body = nlohmann::json::parse(backend->requests.at(0).body.value()); - REQUIRE(body["apiClientId"] == "c"); - REQUIRE(body["loginMode"] == "email"); -} - -TEST_CASE("uses the partner token") { - auto backend = std::make_shared(); - backend->responder = [](const HttpRequest&) { return json_resp(200, R"({"resultType":0,"data":null})"); }; - ClientOptions o = base_options(backend, std::make_shared(std::string("a"), std::nullopt)); - o.partner_token = "PT"; - Client client(o); - - std::vector data = { - {{"type", "pulse"}, {"date_time", "2026-06-17 09:00"}, {"pulse", 72}}, + HttpResponse r; + r.transport_error = true; + r.error_message = "boom"; + return r; }; - client.measures().partner_health_information(std::nullopt, std::string("5551112233"), data); + Client client(base_options(backend)); - REQUIRE(backend->requests.back().headers.at("Authorization") == "Bearer PT"); + REQUIRE_THROWS_AS(client.doctors().branches(), TransportError); } diff --git a/vcpkg.json b/vcpkg.json index 7088599..652287a 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,6 +1,6 @@ { "name": "bulutklinik-sdk", - "version": "0.2.0", + "version": "1.0.1", "builtin-baseline": "f3e10653cc27d62a37a3763cd84b38bca07c6075", "dependencies": [ "cpr",