diff --git a/CHANGELOG.md b/CHANGELOG.md index d3142cf..8522999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,73 @@ All notable changes to `bulutklinik/sdk` are documented here. The format is base [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.healthInformation()` 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 and moved from + `Bulutklinik\Sdk\Resource\Partner\` up to `Bulutklinik\Sdk\Resource\`; + `PartnerNamespace` is gone. +- **`TokenStore` now holds one partner token**: `getToken()` / `setToken()` / + `clear()` replace `getAccessToken()` / `getRefreshToken()` / `setTokens()`. + `InMemoryTokenStore` takes the token as its single constructor argument. +- **`partnerToken` is now the client's credential** and is required for every + call. Passing both `partnerToken:` and `tokenStore:` to `ClientConfig` throws + `InvalidArgumentException` rather than silently picking one. +- **No silent refresh.** A `401` / `resultType 4` throws `AuthenticationException` + 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 `AuthenticationException`, rather + than sending an anonymous request that returns an opaque `401`. +- **Escape hatch `$auth` defaults to `'partner'`**; the `'bearer'` mode no longer + exists. `'public'` remains, for unauthenticated endpoints outside the surface. +- `Environment::baseUrl()` now takes an `ApiVersion` and a new + `Environment::apiRoot()` returns the version-less root. +- `measures->partnerHealthInformation()` → `measures->healthInformation()`. +- `doctors->search()` signature is now `(array $searchParams, int $currentPage = 1, + array $orderParams = [])` — `otherParams` and `perPageLimit` are gone, and + `orderParams` no longer accepts `point`. + +### Added + +- **`ApiVersion` enum** (`V3` / `V4`) and an `apiVersion:` option on + `ClientConfig`. Every path is version-agnostic, so targeting v4 is + configuration, not a code change. Default stays `V3`. +- `ClientConfig::resolveTokenStore()`. + +### 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. +- `clientId` / `clientSecret` on `ClientConfig`. +- The `LoginResult` class. + ## [0.6.0] ### Added diff --git a/DESIGN.md b/DESIGN.md index 8f2fc38..a450a80 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -6,38 +6,66 @@ > The canonical copy lives at `dev-kits/DESIGN.md`; an identical copy is vendored > into each language repository and re-synced whenever this file changes. > -> Wire contract is derived from `dev-kits/Bulutklinik.postman_collection.json` -> ("Bulutklinik API — Randevu & Ödeme Akışı"), validated against the BulutklinikAPI -> source (Laravel 8.12, OAuth2/Passport). +> Wire contract is derived from the BulutklinikAPI source (Laravel 8.12, +> OAuth2/Passport) — `app/Packages/Integration/Outher` and `routes/{v3,v4}/outher.php`. -- **Spec version:** 0.6.0 (completes the registration flows and closes flow-gaps found in the API audit: adds `auth.confirmRegistrationEmail` (the e-mail-branch middle step that `register` actually needs), the social sign-up pair `auth.verifyRegistrationSocial`/`auth.registerSocial`, the password-reset pair `auth.forgotPassword`/`auth.resetPassword`, `appointments.list`/`appointments.reservations` (the source of the `event_id` that `cancel` needs), and the new `addresses` group required by `laboratory.order`; 0.5.0 added `auth.verifyRegistration`; 0.4.0 added `laboratory` + `diets`; 0.3.0 added `skin` + `meals`; 0.2.0 added the §7.2 escape hatch) -- **API:** BulutklinikAPI v3 -- **Scope:** 11 services / 48 endpoints (patient persona). Designed to grow. +- **Spec version:** 1.0.1 — **breaking** (from 0.6.x). The SDKs become a single-persona, + partner-only surface. Everything that required a patient login is gone; every + method now runs on the company-scoped `/outher` channel with a pre-issued + partner token. See §12 for what was removed and why. +- **API:** BulutklinikAPI `v3` (default) or `v4` — selectable per client. +- **Scope:** 6 services / 28 endpoints (partner persona). --- ## 1. Scope -The SDKs cover the patient appointment-and-payment flow, health measurements, and -AI image analysis: - -| Service | Endpoints | Purpose | -|----------------|:---------:|------------------------------------------------------| -| `auth` | 11 | Login, 2FA, refresh, registration (verify/e-mail-confirm/create), social sign-up, password reset, logout | -| `doctors` | 5 | Branches, locations, quick/filtered search, detail | -| `slots` | 1 | Doctor availability (materialized slots) | -| `appointments` | 5 | Reserve, physical appointment, cancel, list, reservations | -| `payments` | 5 | Discount check, saved cards, pay (3DS) | -| `measures` | 8 | Health measurements (CRUD, list, graph, partner) | -| `skin` | 1 | AI skin-lesion analysis ("Cildimde Neyim Var") | -| `meals` | 1 | AI meal-photo calorie/nutrition estimation | -| `laboratory` | 5 | Lab results, orderable test catalog, test pre-order | -| `diets` | 2 | Diet lists (list + detail) written by the dietitian | -| `addresses` | 4 | The patient's saved addresses (needed by `laboratory.order`) | - -Out of scope for this collection (may be added later): "Anlık randevu" (programs), -video-call (calls). The SDK surface is designed so new services slot in as new -resource groups without breaking existing ones. +The SDKs expose the **partner** persona: a clinic-integration channel where the +caller is a company, authenticated by a pre-issued partner token, acting on the +patients of **its own company**. + +| Service | Endpoints | Purpose | +|----------------|:---------:|-------------------------------------------------------------| +| `doctors` | 4 | Doctor discovery: search, branches, detail, city list | +| `slots` | 1 | Doctor availability (materialized slots) | +| `appointments` | 9 | Reserve, confirm, free-form booking, cancel, list, lookup | +| `measures` | 8 | Health measurements for a named patient (read + write) | +| `laboratory` | 4 | Lab results for a named patient + orderable test catalog | +| `diets` | 2 | Diet lists written by a dietitian, for a named patient | + +### 1.1 What "partner persona" means + +| | partner (this SDK) | +|---|---| +| Who authenticates | your **company**, via a pre-issued partner token | +| Whose data you see | patients of **your own company only** | +| How a patient is named | **inline on every call** (`patient` / `user` object) — there is no session | +| Token lifecycle | issued out of band, ~30 days; **the SDK cannot refresh it** (§5.3) | + +There is no patient login, no session, and no per-user access token. Two calls +for two different patients are indistinguishable to the transport — the patient +reference travels in the request body. + +**Patient identity is carried in the body, never in the URL.** A TCKN in a path +segment would land in access logs, proxy logs and Sentry breadcrumbs. This is why +several read endpoints are `POST` although they are semantically reads. + +### 1.2 Deliberately out of scope + +Not exposed, because the API has no company-scoped equivalent: + +- **Patient authentication and registration** — login, 2FA, token refresh, sign-up, + social sign-up, password reset, logout. +- **Payments** — discount codes, the saved-card vault, 3-D Secure. Partner booking + hands payment off to a browser `url` (§6.3); no partner endpoint produces a + financial record. +- **Self-service AI** — skin-lesion analysis, meal photo analysis. +- **Patient address book** — it exists only to feed the patient-side lab order, + which is itself unavailable to partners. +- Other partner scopes that exist server-side but are separate integrations: + `apilab` (laboratory result write-back), `apidevice` (medical devices), and the + plain-`apiusers` doctor-calendar endpoints. These may be added later as their + own resource groups. --- @@ -45,14 +73,24 @@ resource groups without breaking existing ones. ### 2.1 Base URLs -| Env | Base URL | -|--------------|------------------------------------------------| -| `production` | `https://api.bulutklinik.com/api/v3` | -| `test` | `https://apitest.bulutklinik.com/api/v3` | -| `local` | `https://api-bulutklinik.test/api/v3` (Herd) | +The base URL is `/`. -The client accepts either a named environment preset or an explicit base URL. -Default: `production`. +| Env | API root | +|--------------|------------------------------------------| +| `production` | `https://api.bulutklinik.com/api` | +| `test` | `https://apitest.bulutklinik.com/api` | +| `local` | `https://api-bulutklinik.test/api` (Herd)| + +| `apiVersion` | Segment | Notes | +|--------------|---------|----------------------------------------------------------| +| `v3` | `/v3` | **Default.** The long-standing surface. | +| `v4` | `/v4` | The consolidated architecture. Route-for-route identical for `/outher` — the API's `outher:audit-routes` command enforces v3/v4 parity. | + +The client accepts a named environment preset **plus** an `apiVersion`, or an +explicit `baseUrl` that overrides both. Defaults: `production` + `v3`. + +> Every path in §6 is version-agnostic (`/outher/...`); only the base URL differs. +> Switching `apiVersion` is a configuration change, not a code change. ### 2.2 Required headers @@ -61,13 +99,13 @@ Default: `production`. | `Accept` | `application/json` | Always. | | `Content-Type` | `application/json` | On requests with a body. | | `lang` | `tr` (default), `en`, `de`, `az` | Configurable per-client and per-request. | -| `Authorization`| `Bearer ` | Protected endpoints only. Omitted on public endpoints; partner endpoint uses the partner token. | +| `Authorization`| `Bearer ` | On every endpoint in §6. | ### 2.3 HTTP methods Endpoints use `GET`, `POST`, `PUT`, `DELETE` as specified per endpoint in §6. -Path parameters (e.g. `{id}`, `{type}`, `{page}`) are URL segments, not query -string. Request bodies are JSON. +Path parameters (e.g. `{type}`, `{period}`, `{doctorId}`) are URL segments, not +query string. Request bodies are JSON. --- @@ -100,12 +138,24 @@ typed error (§4). |:-----:|----------|------------------------------------------------------------------------------| | `0` | Success | Return `data`. | | `1` | Error | Raise `ApiError` (or a more specific subtype based on HTTP status / `errorType`). | -| `2` | Logout | Clear the token store, raise `AuthenticationError` (session revoked). | -| `3` | Update | Raise `ApiError` with an "update required" marker (client/app too old). | -| `4` | Refresh | Token expired. **Not** returned by `refreshApi`; returned by the global handler on any protected call that receives an expired/invalid token (HTTP 401). Triggers the auto-refresh+retry flow (§5.4). | +| `2` | Logout | Clear the token store, raise `AuthenticationError` (token revoked). | +| `3` | Update | Raise `ApiError` with an "update required" marker. | +| `4` | Refresh | The partner token is expired or invalid. **There is nothing to refresh** (§5.3) — raise `AuthenticationError` telling the caller to install a newly issued token. | + +> Implementation note: `/outher` returns `resultType 4` with HTTP `401` on an +> expired token. A bare HTTP `401` without a parseable envelope MUST be treated +> identically. Neither triggers a retry. -> Implementation note: `resultType 4` is the canonical refresh signal, but a bare -> HTTP `401` (without a parseable envelope) MUST be treated identically. +### 3.2 The `501` convention + +`/outher` reports most business-rule failures as HTTP **`501`** with +`resultType 1` — "patient not found in your company", "diet list is not yours", +"slot no longer free", "doctor not bookable through your integration". It is not +a server crash. Callers should read `errorMessage`, not the status code alone. + +The read endpoints deliberately return the **same** message for "this patient is +not in your company" and "this patient does not exist". Distinguishing them would +turn the endpoint into a TCKN-probing oracle. --- @@ -121,317 +171,212 @@ BulutklinikError (base — all SDK errors derive from this) ├── TransportError (network failure, timeout, DNS, TLS — no HTTP response) └── ApiError (got an HTTP response that wasn't a success) ├── ValidationError (422, or errorType=validation) - ├── AuthenticationError (401 / resultType 2 logout / failed refresh) - ├── AuthorizationError (403 — authenticated but not permitted/scoped) + ├── AuthenticationError (401 / resultType 2 / resultType 4 — token invalid, expired or revoked) + ├── AuthorizationError (403 — authenticated but the token lacks the scope, or carries no company) ├── NotFoundError (404) └── RateLimitError (429 — throttled; carries Retry-After if present) ``` Each `ApiError` carries: `httpStatus`, `resultType`, `errorType`, `errorMessage`, the raw `data`, and the originating request (method + path) for debugging. -Mapping precedence: logout (`resultType == 2`) → string `errorType == "validation"` -→ HTTP status (401→Auth, 403→Authz, 404→NotFound, 422→Validation, 429→RateLimit) -→ otherwise (incl. numeric `errorType`, or success HTTP with `resultType != 0`) → `ApiError`. +Mapping precedence: logout/expiry (`resultType == 2` or `4`) → string +`errorType == "validation"` → HTTP status (401→Auth, 403→Authz, 404→NotFound, +422→Validation, 429→RateLimit) → otherwise (incl. numeric `errorType`, or success +HTTP with `resultType != 0`) → `ApiError`. Because `errorType` may be numeric (§3), guard before string-matching it. ---- - -## 5. Authentication & token lifecycle - -OAuth2 via Laravel Passport. Access token lifetime ~30 days, refresh token ~130 -days. The token grant happens server-side inside `connectApi` (no direct -`oauth/token` HTTP call from the SDK). - -### 5.1 Login — `auth.connect` - -`POST /general/connectApi` (also aliased at root `/connectApi`). **Public** (no Bearer). - -Request body: - -| Field | Required | Notes | -|-------------------|:--------:|------------------------------------------------------------------| -| `apiUserName` | ✓ | Identifier per `loginMode` (email / TC / phone / user_id). | -| `apiUserPassword` | ✓* | Required except `social` / `afterRegister` modes. | -| `apiClientId` | ✓ | OAuth client id. | -| `apiSecretKey` | ✓ | OAuth client secret. | -| `loginMode` | ✓ | `email` \| `identity` \| `phone` \| `user_id` \| `social` \| `afterRegister`. | -| `withPhoneNumber` | — | Some installs require it in `phone` mode. | - -`loginMode` `social` / `afterRegister` skip password validation -(`validateForPassportPasswordGrant`). - -Success → `data: { access_token, refresh_token, password_policy }`. The SDK -persists both tokens via the token store (§5.5). - -**2FA branch:** if SMS 2FA is enabled (`sms_2fa_status=1`), `data.access_token` is -absent and `data.response` carries an encrypted blob. The SDK surfaces this as a -*two-factor challenge* (typed result, not an error) so the caller can collect the -SMS code and call `auth.connectWithTwoFactor`. - -### 5.2 2FA verification — `auth.connectWithTwoFactor` - -`POST /general/connectApiWithTwoFactor`. **Public** (middleware verifies the SMS code -inside the encrypted blob). - -Request body: - -| Field | Required | Notes | -|------------------------|:--------:|------------------------------------------------| -| `smsVerificationCode` | ✓ | The code the user received by SMS. | -| `response` | ✓ | The encrypted blob from `connect`'s `data.response`. | - -(The collection also sends `tokenInfo`, but the server ignores it — the real token -is decrypted from `response`. SDKs send only `smsVerificationCode` + `response`.) - -Success → `data: { access_token, refresh_token }`. Token is **not** re-minted here; -it was minted during `connect` and is returned now. - -### 5.3 Token refresh — `auth.refresh` - -`POST /general/refreshApi`. **Public.** Uses the Passport `refresh_token` grant. - -Request body: `{ refreshToken, clientId, clientSecretKey }`. -Success → `data: { access_token, refresh_token }` (both rotated; persist both). - -### 5.4 Silent auto-refresh + retry (mandatory in every SDK) - -On any **protected** call: - -1. Send the request with the current access token. -2. If the response is `401` **or** `resultType == 4`, and a refresh token exists, - and this request has **not** already been retried: - a. Call `auth.refresh` with the stored refresh token + client credentials. - b. Persist the new tokens. - c. Retry the original request **once**. -3. If the refresh call itself fails, or `resultType == 2` (logout), clear the - token store and raise `AuthenticationError`. -4. Auto-refresh must be **concurrency-safe**: simultaneous 401s share a single - in-flight refresh (no refresh stampede). Single-threaded SDKs (e.g. plain JS) - gate on one shared promise; threaded SDKs (Java/C#/Go/C++) use a mutex. - -The retry is bounded to one attempt to prevent loops. - -### 5.5 Token store (pluggable) - -A `TokenStore` abstraction holds the access + refresh tokens. Default -implementation is in-memory. Consumers may inject a custom store (file, DB, -secure storage). Required operations (named per language): - -- get access token / get refresh token -- set tokens (access, refresh) — atomically -- clear (on logout / revoked session) +> `403` deserves a note: it is raised not only for a missing `apiouther` scope but +> also when the token resolves to a user with no company. The company boundary is +> derived from the authenticated principal, never from request input — so a `403` +> here means the credential itself is wrong, and retrying with different body +> parameters will never help. -### 5.6 Registration — `verifyRegistration` → (`confirmRegistrationEmail`) → `register` - -Registration is a multi-call flow. `verifyRegistration` returns `confirmationType`: - -- **`"sms"`** → feed its `response` + the SMS code straight into `register`. -- **`"email"`** → the user gets the code by **e-mail**; call `confirmRegistrationEmail` - first (it verifies the e-mail code, sends an **SMS** code, and returns a *fresh* - `response` blob), then feed that blob + the SMS code into `register`. - -⚠️ **A headerless SDK caller always gets `"email"`.** The SMS branch of -`verifyAddingNewPatient` only triggers when an `appversion` header ≤ 5.27 is present; -SDKs send no such header, so `confirmRegistrationEmail` is a **required** middle step, -not optional. `register` (guarded by `checkPhoneVerificationSmsCode`, which requires -`smsVerificationCode`/`smsVerificationExpire` in the blob) cannot consume the e-mail -blob directly — it returns 501. (Social sign-up uses a separate 2-step pair, §5.6.4.) - -#### 5.6.1 Verify — `auth.verifyRegistration` - -`POST /patients/verifyAddingNewPatient`. **Not public** — guarded by -`auth:apiusers`, so it uses the SDK's **partner** token (the same apiusers bearer as -`partnerHealthInformation`, no specific scope required here), plus a -`throttle:perHourFifty` limit. This is the reason the step needs a configured -`partnerToken`; a patient bearer will **not** satisfy the guard. +--- -Request body: `name`, `surname`, `phoneNumber`, `phone_code`, `email`, `password`, -`passwordAgain`, `acceptUserAgreement`, one of `g-recaptcha-response-v2` / `captcha`, -optional `userAgreements[]`. +## 5. Authentication -Rules (validated in `VerifyAddingNewPatientRequest`): -- `phoneNumber` must match `^[+]([0-9\s\(\)]*)$` and be **unique** in `mbl_users` - (this is where duplicate-account detection happens). -- `phone_code` must match `^\+\d{1,3}$` (e.g. `+90`). -- `email` is required (modern app versions) and unique in `mbl_users`. -- `passwordAgain` must equal `password`; the SDKs auto-fill it from `password`. -- **CAPTCHA is mandatory** (`g-recaptcha-response-v2` *or* `captcha`, required-without - each other), validated last via a live Cloudflare/Google call. A pure server-side - caller cannot mint this token — it must come from a browser/human. The SDK method is - therefore a **thin passthrough**: the caller supplies the captcha token. +### 5.1 The partner token -Success → `data: { response: "", confirmationType: "sms" | "email" }`. -The SDK returns `data` verbatim; feed `response` (and the code the user receives) into -`register`. The `response` blob is opaque and passed through unchanged (§8.2). +OAuth2 via Laravel Passport, guard `apiusers`, scope `apiouther` (plus `teusan` +for `measures.healthInformation`). The token is **issued out of band** — through +the Bulutklinik Developer Platform, not by any SDK call. There is no +client-credentials grant the SDK can drive, and no `oauth/token` request. -#### 5.6.2 Confirm e-mail — `auth.confirmRegistrationEmail` +Consequences the SDKs must honour: -`POST /patients/emailConfirmationRegister`. **Public** (guarded by -`checkEmailVerificationCode` + throttle). Called only when `verifyRegistration` -returned `confirmationType: "email"`. +1. The token is a **configuration input**, like an API key. +2. There is **no login method** on the client. +3. There is **no auto-refresh** and no retry-after-refresh (contrast: spec 0.x §5.4). +4. The company the token belongs to is fixed at issue time. It cannot be + overridden per request. -Request body: `verificationCode` (the e-mailed code), `response` (the blob from -`verifyRegistration`), optional `userAgreements[]`. The rest of the profile is -carried inside the encrypted blob and merged server-side (`prepareForValidation`), -so the SDK only sends these fields. +### 5.2 Token store (pluggable) -Success → `data: { response: "", confirmationType: "sms" }`. Feed that -`response` + the SMS code into `register`. +The token is read through a `TokenStore` on **every** request, so a long-lived +process can rotate the credential without being rebuilt — point the store at a +file, a database, or a secret manager and the next call picks up the new value. -#### 5.6.3 Create — `auth.register` +Required operations (named per language): -`POST /patients/addNewPatient`. **Public** but guarded by SMS verification -(`checkPhoneVerificationSmsCode`) + throttle. +| Operation | Purpose | +|------------|------------------------------------------------------------------| +| get token | Return the current partner token, or null/empty if none. | +| set token | Replace the stored token (accepts null to unset). | +| clear | Drop the stored token. Called automatically on `resultType 2`. | -Request body: `name`, `surname`, `apiUserName`, `phoneNumber`, `password`, -`smsVerificationCode`, `response` (the SMS blob from the previous step), -`acceptUserAgreement` (1), `apiClientId`, `apiSecretKey`. +The default implementation is in-memory. The `partnerToken` client option is a +convenience that seeds one: -Rules (validated): -- `phoneNumber` must match `^[+]([0-9\s\(\)]*)$` — i.e. start with `+` and country - code (e.g. `+90 555 111 22 33`). Bare digits are rejected. -- `apiUserName` is used as the `afterRegister` token username; send the **same** - `+CC` value as `phoneNumber`, otherwise auto-login mints a wrong/empty token. -- Password is stored as `Hash::make(BULUT_API_ENC_KEY . password)` (bcrypt rounds=12). - -Success → patient created + automatic `afterRegister` login → `data: { access_token, refresh_token }`. - -#### 5.6.4 Social sign-up — `verifyRegistrationSocial` → `registerSocial` - -A separate **public** 2-step pair for users who authenticate via a social provider. -Unlike `verifyRegistration`, both are public — **no CAPTCHA and no partner token**. - -- `verifyRegistrationSocial` → `POST /patients/verifyAddingNewPatientSocial`. Body: - `name`, `surname`, `phoneNumber`, `password`, `passwordAgain`, `socialType`, `key`, - optional `email`, `acceptUserAgreement`, `userAgreements[]`. Sends the SMS code → - `data: { response }` (note: **no** `confirmationType`). -- `registerSocial` → `POST /patients/addNewPatientWithSocial` (guarded by - `checkPhoneVerificationSmsCode`). Body: `smsVerificationCode`, `response` (blob from - the verify step), optional `userAgreements[]`; the profile is merged from the blob. - Creates the social patient and its `sec_social_login` link row. **Does not - auto-login** — obtain tokens afterwards with `connect({ loginMode: "social" })`. +``` +new Client({ partnerToken: "…" }) ⇒ in-memory store seeded with the token +new Client({ tokenStore: myVaultStore }) ⇒ the token comes from your store +new Client({ partnerToken: …, tokenStore: … }) ⇒ configuration error, raised at construction +``` -### 5.7 Logout — `auth.disconnect` +Passing both is rejected rather than silently resolved: either the literal or the +store is the source of truth, and guessing which one the caller meant is how +credential bugs get shipped. -`POST /general/disconnectApi`. **Bearer required** (`auth:patients,apiusers,doctors`). -Revokes the current access + refresh tokens server-side. The SDK then clears the -token store. Optional device-token fields (firebase/ios) may be added to the body. +If no token is available when a request is dispatched, the SDK raises +`AuthenticationError` **before** touching the network — an unauthenticated call to +`/outher` would only come back as a confusing `401`/`resultType 4`. -### 5.8 Password reset — `forgotPassword` → `resetPassword` - -A **public** 2-step self-service reset flow. +### 5.3 Expiry -- `forgotPassword` → `POST /patients/forgotPassword`. Body: `phoneNumber` (must be a - registered number), optional `birthdate` (`YYYY-MM-DD`; some installs verify it), - and one of `g-recaptcha-response-v2` / `captcha` — **CAPTCHA is mandatory outside - the local environment** (browser-minted, like `verifyRegistration`). Sends the SMS - confirm code → `data: { response }`. -- `resetPassword` → `PUT /patients/forgotPassword` (guarded by - `checkForgotPasswordConfirmSmsCode`). Body: `smsConfirmCode`, `response` (blob from - `forgotPassword`), `password`, `passwordAgain`. Sets the new password (keyed by the - phone/birthdate carried in the blob). Terminal — returns a success message, no tokens. +Passport issues these tokens with a ~30 day lifetime. When one expires the API +answers `401` + `resultType 4`; the SDK raises `AuthenticationError` and does +**not** retry. Recovery is operational: obtain a newly issued token and write it +into the token store (or rebuild the client). SDK READMEs must say this plainly — +`resultType 4` used to mean "the SDK will fix this silently" and now means the +opposite. --- -## 6. Endpoint reference (48) +## 6. Endpoint reference (28) Notation: **Canonical name** = language-neutral concept → per-language naming -follows §7. `[public]` = no auth; `[bearer]` = access token; `[partner]` = partner -token; `[scope:…]` = required OAuth scope. - -### 6.1 `auth` - -| Canonical | Method | Path | Auth | -|----------------------|--------|------------------------------------|----------| -| `connect` | POST | `/general/connectApi` | public | -| `connectWithTwoFactor`| POST | `/general/connectApiWithTwoFactor` | public | -| `refresh` | POST | `/general/refreshApi` | public | -| `verifyRegistration` | POST | `/patients/verifyAddingNewPatient` | partner | -| `confirmRegistrationEmail` | POST | `/patients/emailConfirmationRegister` | public | -| `register` | POST | `/patients/addNewPatient` | public* | -| `verifyRegistrationSocial` | POST | `/patients/verifyAddingNewPatientSocial` | public | -| `registerSocial` | POST | `/patients/addNewPatientWithSocial` | public* | -| `forgotPassword` | POST | `/patients/forgotPassword` | public | -| `resetPassword` | PUT | `/patients/forgotPassword` | public | -| `disconnect` | POST | `/general/disconnectApi` | bearer | - -(Bodies and responses in §5.) - -### 6.2 `doctors` `[bearer] [scope:patients,bulutweb]` - -| Canonical | Method | Path | Body / params | -|----------------|--------|----------------------------------------|---------------| -| `branches` | GET | `/patients/allBranches` | — | -| `locations` | GET | `/patients/allLocations` | — | -| `quickSearch` | POST | `/patients/quickSearch` | `searchText` (3–100, req), `listType` (`interview`\|`appointment`\|null), `location` (null) | -| `search` | POST | `/patients/filteredSearch` | `searchParams{}`, `orderParams[]`, `otherParams[]`, `currentPage` (≥1, req), `perPageLimit` (10–100) | -| `detail` | GET | `/patients/doctorDetail/{id}/{corporate?}` | path `id` (req), optional `corporate` | - -- `quickSearch` response: `{ searchedBranches, searchedDoctors, searchedCompanies, searchedGivenTreatments, searchedBlogs, queryText }`; each item `{ result_id, result_text, result_url, result_sub_text, result_type, result_image }`. -- `search.searchParams` keys: `withFreeText`, `withDoctorName`, `withBranchName`, `withBranchId` (`-1` excludes psychology/diet), `withLocationName`, `withLocationId`, `withCompanyName`, `withCompanyId`, `withGivenTreatments`, `withExpertyId`, `withInstitutionId`, `withNearestSlotDayRange`. - `orderParams`: `name` | `point` | `slot` | `order`. `otherParams`: `isKizilay` | `isQuestionable` | `isInterviewable` | `isAppointmentable`. - Response: `data: { foundDoctorsCount, foundDoctors: [ { doctor_id, name, surname, branch_name, star_rate, nearest_slot, isInterviewable, isAppointmentable, url, user_image, … } ] }`. -- `detail` returns `doctorGeneralInfo` (prices, session length, branch), education, languages, reviews, videos, special services, related clinics. The `doctor_id` here feeds later steps. - -### 6.3 `slots` `[bearer]` - -| Canonical | Method | Path | Body | -|------------|--------|-------------------------------|------| -| `schedule` | POST | `/patients/doctorScheduler` | `doctorId` (numeric, req); `scheduleDate` (`Y-m-d`, today..+21, optional); `scheduleStep` + `schedulePage` (window paging — both required when `scheduleDate` omitted); `listType` (req: `interview` → online slot_type 1,2; else physical slot_type 0,2) | - -Response: `data` = date-keyed map → for each date `[ { slotId, slotStart "HH:mm:ss", slotEnd "HH:mm:ss", available: true } ]`. Empty days are `[]`. -Next step's `appointmentDate` = `"Y-m-d H:i"` (date key + `slotStart`, **drop seconds**). - -### 6.4 `appointments` `[bearer] [scope:patients,bulutweb]` - -| Canonical | Method | Path | Body / params | -|--------------------|--------|--------------------------------------------|---------------| -| `reserveInterview` | POST | `/patients/addInterviewDateReservation` | `doctorId` (numeric, req), `appointmentDate` (`Y-m-d H:i`, today..+21, req), `appointmentType` (`interview`\|`appointment`, default `interview`) | -| `addPhysical` | POST | `/patients/addNewAppointment` | `doctorId` (numeric, req), `appointmentDate` (`Y-m-d H:i`, req). No `appointmentType`. | -| `cancel` | DELETE | `/patients/deleteUserAppointment/{eventId}`| path `eventId` (= `cln_events.id`) | -| `list` | GET | `/patients/userAppointments/{page?}` | optional path `page` (paging disabled — page 1 = full list) | -| `reservations` | GET | `/patients/userReservations` | — | - -`reserveInterview` success → `{ resultType: 0, data: null }`; failure → 501. -`cancel` → 501 for insurance appointments, past cancel-window, or not found. -Slot is resolved server-side from `doctorId` + `appointmentDate` (no `slotId` in request). -- `list` → `data: { foundAppointmentsCount, foundAppointments: [ { event_id, event_start_date, doctor_id, doctor_name, doctor_surname, status, amount, online_call, … } ] }`. **`event_id` is the id `cancel` takes**; rows with `event_id == "0"` are paid-order/refund entries (not cancellable) — filter them out. -- `reservations` → a bare array of active online-slot holds: `{ appoinment_date, doctor_id, doctor_name, doctor_surname, medical_branch_name, minute_diff, second_diff }` (pair `minute_diff`+`second_diff` for a countdown). - -### 6.5 `payments` - -| Canonical | Method | Path | Auth | Notes | -|--------------------|--------|-----------------------------------|--------|-------| -| `checkDiscountCode`| POST | `/patients/checkDiscountCode` | bearer | **`patients` prefix, not `payments`.** | -| `getCards` | GET | `/payments/getCards` | bearer | | -| `saveCard` | POST | `/payments/saveCard` | bearer | Flat fields (not nested). | -| `pay` | POST | `/payments/interviewPayment` | bearer | Throttle 20/h/IP. Returns `payment3DUrl`. | -| `deleteCard` | DELETE | `/payments/deleteCard/{cardId}` | bearer | path `cardId` | - -- `checkDiscountCode` body: `checkType` (`question`\|`appointment`\|`lab`\|`special`\|`physicallyAppointment`\|`tmcLab`\|`program`), `doctorId` (required except lab/tmcLab/program), `discountCode` (req), plus `orderId`/`specialServiceId`/`programSlug` per type. Valid → `data: { discount_code, discount_title, discount_id, prices }`. -- `getCards` → `data.cards[]: { id, card_holder_name, card_number (masked), card_type, created_at }`. `id` → `cardId`. -- `saveCard` body (flat, `SavePatientCardRequest`): `cardHolder`, `cardNumber`, `cardExpMonth` (`m`), `cardExpYear` (`Y`), `cardCvv` — all required. -- `pay` body: `doctorId` (req), `appointmentDate` (`Y-m-d H:i`, req), `appointmentType` (`interview`→order_type 0 / `appointment`→3), `is3D` (bool, req), `termsAccept` (accepted, req), `saveCard` (1=tokenize), `discountCode` (opt), `caseDetail` (opt, encrypted), **and** either `cardInfo{ cardHolder, cardNumber, cardExpMonth, cardExpYear, cardCvv }` (all-or-none) **or** `cardId` (saved card). Amount is computed server-side (no `amount` in request). -- `pay` response: see §8.1 (`payment3DUrl` handling). - -### 6.6 `measures` - -Patient endpoints `[bearer] [scope:patients]`; partner endpoint `[partner] [scope:teusan]`. -Records are written to the authenticated patient (`bas_com_company_id` from token). - -| Canonical | Method | Path | Body / params | -|----------------------------|--------|------------------------------------------------------|---------------| -| `addList` | POST | `/patients/addNewUserMeasures` | `data[]` — each item: `type` + that type's fields + `date_time`. **Primary "submit health data" endpoint.** | -| `add` | POST | `/patients/addNewUserMeasures/{type}` | path `type`; body: `date_time` + type fields | -| `update` | PUT | `/patients/updateUserMeasures/{type}` | path `type`; body: `id` (req) + fields + `date_time` | -| `delete` | DELETE | `/patients/deleteUserMeasures/{type}` | path `type`; body: `id` (req) | -| `last` | GET | `/patients/measuresList` | Latest value per type. | -| `list` | GET | `/patients/userMeasuresList/{type}/{page}/{glucoseType?}` | path; `glucoseType` 0/1 only for glucose | -| `graph` | GET | `/patients/userMeasuresGraph/{type}/{period}/{page}/{glucoseType?}` | `period` 1=day,2=week,3=month,4=year | -| `partnerHealthInformation` | POST | `/outher/healthInformation` | partner token; body: `identity`, `phoneNumber`, `data[]` | - -`addList` runs in a DB transaction; submit multiple measurements in one call. -`last` returns the most-recent of each type (tension splits into hypertension/hypotension; glucose splits into `hunger_glucose`/`postprandial_glucose`), each with a `*Date`. +follows §7. Every endpoint below requires the partner token; the scope column +lists the OAuth scope the token must carry. + +Two patient-reference shapes recur; both are defined in §8.1. + +- **`patientRef`** — `{ identityNumber?, phoneNumber? }`, at least one. Used by + **reads**. Never creates anything. +- **`bookingUser`** — `{ name, surname, phoneNumber, identityNumber?, email?, birthdate?, nationality?, price? }`. + Used by **writes**. Creates the patient in your company if absent. + +### 6.1 `doctors` `[scope:apiouther]` + +| Canonical | Method | Path | Body / params | +|-------------|--------|-------------------------------|---------------| +| `search` | POST | `/outher/search` | `searchParams{}` (req), `orderParams[]` (`name`\|`order`\|`slot`), `currentPage` (≥1, req) | +| `branches` | GET | `/outher/branches` | — | +| `detail` | GET | `/outher/doctorInfos/{doctorId}` | path `doctorId` (req, numeric) | +| `locations` | GET | `/outher/locations` | — | + +- Results are filtered to the doctors enabled for your integration (the server + applies your partner slug), so anything returned here is bookable by you. + `locations` is the exception: a global city catalogue, not company-scoped. +- `search.searchParams` accepts the same keys as the patient-side filtered search + (`withFreeText`, `withDoctorName`, `withBranchName`, `withBranchId`, + `withLocationName`, `withLocationId`, `withCompanyName`, `withCompanyId`, + `withGivenTreatments`, `withExpertyId`, `withInstitutionId`, + `withNearestSlotDayRange`). Response: `{ foundDoctorsCount, foundDoctors: [ { doctor_id, name, surname, branch_name, … } ] }`. + Note `orderParams` here is narrower than the patient surface — `point` is not accepted. +- **`searchParams` must contain at least one key.** Its rule is `required|array`, + and PHP's `required` rejects an empty array — so `{}` is a guaranteed `422`, + not an unfiltered search. SDKs must therefore make `searchParams` a required + argument and must not default it to an empty map. +- `detail` `doctor_id` feeds `slots.schedule` and the booking calls. + +### 6.2 `slots` `[scope:apiouther]` + +| Canonical | Method | Path | Body | +|------------|--------|-------------------------|------| +| `schedule` | POST | `/outher/doctorSlots` | `doctorId` (numeric, req); `scheduleDate` (`Y-m-d`, today..+21, optional); `scheduleStep` + `schedulePage` (window paging — both required when `scheduleDate` omitted) | + +Response: `data` = date-keyed map → for each date +`[ { slotId, slotStart "HH:mm:ss", slotEnd "HH:mm:ss", available: true } ]`. +Empty days are `[]`. `slotId` feeds `appointments.reserve`; an +`appointmentDate` elsewhere is `"Y-m-d H:i"` (date key + `slotStart`, **seconds dropped**). + +Unlike the patient surface there is no `listType` — the partner channel is online +interviews. + +### 6.3 `appointments` `[scope:apiouther]` + +| Canonical | Method | Path | Body / params | +|--------------------------|--------|-----------------------------------|---------------| +| `reserve` | POST | `/outher/reservation` | `slotId` (req), `doctorId` (req), `user{}` = bookingUser | +| `reserveWithoutAgreement`| POST | `/outher/reservationWithoutAgreement` | same as `reserve` | +| `instantReserve` | POST | `/outher/instantReservation` | `user{}` = bookingUser | +| `create` | POST | `/outher/appointment` | `hash` (req), `outherProcessId` (req, numeric) | +| `createWithoutSlot` | POST | `/outher/appointmentWithoutSlot` | `doctorId` (req), `startDate` (`Y-m-d H:i`, ≥ today, req), `finishDate` (`Y-m-d H:i`, after `startDate`, req), `isOutherDoctor` (0\|1), `user{}` = bookingUser | +| `cancelWithoutSlot` | DELETE | `/outher/appointmentWithoutSlot` | appointment lookup (below) | +| `list` | POST | `/outher/appointments` | `phoneNumber` (req), `page` (≥1), `type` (`normal`\|`instant`) | +| `info` | POST | `/outher/appointmentInfo` | appointment lookup (below) | +| `checkDoctor` | POST | `/outher/checkDoctor` | `doctorId` (req, numeric), `isOutherDoctor` (req, 0\|1) | + +**Appointment lookup** (`info`, `cancelWithoutSlot`) addresses one appointment +either **by process** — `hash` + `outherProcessId` — or **by coordinates** — +`doctorId` + `appointmentDate` (`Y-m-d H:i`) + `isOutherDoctor`. Send one pair or +the other; the server validates them as mutually `required_without`. + +**The two booking flows:** + +``` +(A) hand off to the patient reserve ──▶ data.url ──▶ patient opens it in a + browser: agreements + payment +(B) you collected the agreements reserveWithoutAgreement ──▶ data.hash + └─▶ create(hash, outherProcessId) ──▶ appointment +``` + +- `reserve` → `{ url, hash }`. `url` is a short link to the Bulutklinik agreement + and payment page; hand it to the patient. The SDK returns it verbatim and never + opens or follows it. +- `reserveWithoutAgreement` → `{ hash, doctorId, slotId, phoneNumber, reservationExpired }`. + `reservationExpired` (`Y-m-d H:i:s`) is the hold deadline — `create` after it + passes fails with `501`. +- `instantReserve` → `{ url }`. No slot: the server picks an available doctor. +- `create` → the appointment record plus `last_delete_time` (the cancellation + deadline). +- `createWithoutSlot` books a free-form range outside the slot grid, for + integrations running their own calendar. `cancelWithoutSlot` reverses it — and + **only** it; appointments created through `create` are not cancellable here. +- `list` returns the appointments **you** created for that phone number, not the + patient's full history across the platform. +- `checkDoctor` → `{ title, name, surname, branch_name, state: "1" }` when the + doctor is bookable through your integration; `501` when not. Call it before + showing a doctor as reservable. + +> Completing a payment (`outherProcess`) is **not** on the partner surface: those +> routes require a `patients`/`bulutweb` scope. Flow (A) exists precisely because +> the browser hand-off is where payment happens. + +### 6.4 `measures` `[scope:apiouther]` (`healthInformation`: `[scope:teusan]`) + +Reads resolve the patient inside your company and never create one. Writes create +the patient if needed. **Measurements are written to your own company** — a value +you write does not appear in the patient's Bulutklinik mobile app. That is the +intended consequence of tenant isolation, not a bug. + +| Canonical | Method | Path | Body / params | +|---------------------|--------|-----------------------------------------------|---------------| +| `last` | POST | `/outher/lastMeasures` | `patient{}` = patientRef | +| `list` | POST | `/outher/measuresList/{type}` | path `type`; `patient{}` = patientRef, `currentPage` (≥1), `glucoseType` (0\|1, glucose only) | +| `graph` | POST | `/outher/measuresGraph/{type}/{period}` | path `type`, `period` (1=day,2=week,3=month,4=year); `patient{}` = patientRef, `currentPage`, `glucoseType` | +| `addList` | POST | `/outher/measures` | `patient{}` = bookingUser, `data[]` (1–200 items) — each item `type` + that type's fields + `date_time` | +| `add` | POST | `/outher/measure/{type}` | path `type`; `patient{}` = bookingUser, `date_time` + type fields | +| `update` | PUT | `/outher/measure/{type}` | path `type`; `patient{}` = patientRef, `id` (req) + fields + `date_time` | +| `delete` | DELETE | `/outher/measure/{type}` | path `type`; `patient{}` = patientRef, `id` (req) | +| `healthInformation` | POST | `/outher/healthInformation` | `identity`, `phoneNumber`, `data[]` — legacy flat contract, **not** `patient{}` | + +- `addList` writes every row in **one transaction**, capped at **200 items** — + without a cap a single request would hold a transaction open across thousands of + rows and block on the `med_monitor_*` tables. +- `update`/`delete` take the read-side `patient{}` (if there is a row to change, + the patient already exists) and bound the write to `id` + patient + company. +- `id` for `update`/`delete` comes from `list`. **Measure type schema** (every record also requires `date_time` = `"Y-m-d H:i"`): @@ -451,166 +396,114 @@ Records are written to the authenticated patient (`bas_com_company_id` from toke | `step` | `step` | | `sleep` | `sleep` (hours; stored to `sleep_time`) | -Value rules: numeric; `tension`/`pulse` digits 1–10; `glucose` 0–99999.99 + `glucose_type` 0\|1; `weight`/`length` 0–99999.99; etc. - -> **Known API bug (document, don't replicate):** for the partner endpoint, -> `AddNewUserMeasuresListRequest::prepareForValidation` reads `identity` from -> `$this->message` instead of `$this->identity`, nulling it during validation; in -> practice matching falls back to `phoneNumber`. The SDK sends the correct -> contract (`identity` + `phoneNumber`) and notes this in the README. - -### 6.7 `skin` `[bearer] [scope:patients]` - -"Cildimde Neyim Var" — AI skin-lesion analysis. Submit one or more skin photos; each is -classified (lesion `label`), given a patient-friendly Turkish AI `comment`, image-quality -flags, a `confidence`, possible ICD hints and an opaque `case_detail` blob. - -| Canonical | Method | Path | Body | -|-----------|--------|------------------------|------| -| `analyze` | POST | `/patients/imageCheck` | `images[]` — each item `{ image (base64, req), branch_id? }` | - -Request: `{ "images": [ { "image": "", "branch_id"?: } ] }`. `image` is a -base64-encoded JPEG/PNG/WebP/HEIC (a `data:…;base64,` prefix is accepted). `branch_id` -optionally tags the stored media with a clinic branch. Mirrors `measures.addList` — a -loose array of records. - -Response `data`: `{ status: [ { id, isClear, isBright, label, comment, confidence, image, error, possible_icd, case_detail } ] }` — one entry per submitted image, `id` = 1-based index: -- `label` — lesion class from the classifier (may be empty). -- `comment` — patient-friendly Turkish AI summary. -- `isClear` / `isBright` — image-quality flags. -- `confidence` — classifier confidence (0–1) or null. -- `image` — stored media relative path. -- `possible_icd` — candidate ICD code(s) or null. -- `case_detail` — opaque base64-encrypted blob identifying the saved case (§8.2); can be forwarded verbatim as a payment's `caseDetail`. -- `error` — per-image error message or null. - -The SDK returns `data` verbatim (a mostly-untyped map) and never decrypts `case_detail`. -On a gateway failure the API still returns `status` entries with empty label/comment, so -callers should treat all fields as optional. - -### 6.8 `meals` `[bearer] [scope:patients]` - -AI meal-photo calorie/nutrition estimation — sibling of `skin` (same controller, different -domain). - -| Canonical | Method | Path | Body | -|-----------|--------|------------------------------|------| -| `analyze` | POST | `/patients/imageAnalyzeMeal` | `image` (base64, req), `portionSize` (req), `portionGrams?`, `mealType` (req), `note?` | - -The SDK input names map to the API's snake_case body -`{ image, portion_size, portion_grams?, meal_type, note? }` (like `payments.pay`, a typed -single input): -- `portion_size` ∈ `small | medium | large | custom` (required). -- `portion_grams` — required only when `portion_size` is `custom`. -- `meal_type` ∈ `breakfast | lunch | dinner | snack` (required). -- `note` — optional free text (≤1000 chars); the model reads Turkish preparation/portion modifiers. - -Response `data`: `{ status: { comment: "" } }` — `comment` is the model's -nutrition breakdown (a JSON-object string, per the server prompt); the SDK returns it -verbatim. - -### 6.9 `laboratory` `[bearer] [scope:patients]` - -The patient's own laboratory results, the orderable test catalog, and test pre-ordering. -The controller (`v3\General\Laboratory`) is `@hideFromAPIDocumentation`, so this group is -hand-written from the API source, not the auto-docs. - -| Canonical | Method | Path | Body / params | -|-----------------|--------|-----------------------------------------------|---------------| -| `results` | GET | `/patients/userLabTestList/{page?}` | optional `page` (default 1). The patient's completed/in-progress lab results. | -| `resultDetail` | GET | `/patients/userLabTestDetail/{testId}` | path `testId` (**string**) — pass the id from a `results` item verbatim. | -| `catalog` | GET | `/patients/allLaboratoryTests` | — (orderable test-group catalog). | -| `catalogDetail` | GET | `/patients/laboratoryTestDetail/{id}` | path `id` (numeric) — one catalog group. | -| `order` | POST | `/patients/addNewLaboratoryTest` | `testId` (numeric, req), `addressId` (numeric, req), `laboratoryId` (numeric, req). | - -- `results` `data`: `{ foundTestsCount, foundTests: [ { id, created_at, company_name, test_name, test_state, test_state_text, test_type, test_type_text } ] }`. `test_state` 0=Numune Alınıyor, 1=Çalışıyor, 2=Onaylandı; `test_type` 1=Normal, 2=Grup, 3=Alt Parametre. The list also unions in TMC-lab-ordered tests whose `id` carries a `-lab` suffix (e.g. `"4821-lab"`). -- `resultDetail`: `{testId}` may be a plain id (`"123"`, DB path) or `"-lab"` (TMC-lab path). `data` fields: `test_name, protocol_no, id, created_at, company_name, result, result_unit, test_state, test_state_text, test_type, test_type_text, result_type_text, sub_tests`. For `test_type == 1` the payload adds `normal_lower_limit, normal_upper_limit, panic_lower_limit, panic_upper_limit` (age/gender-aware). For `test_type == 2`, `sub_tests[]` carries per-parameter results plus those four limit fields. -- `catalog` returns `test_groups[]`: each `{ id, name, image, background, desc (HTML), tests[]{id,name}, laboratories[]{ company_id, name, doctor_id, branch_id, prices{ real_price, discount_rate, discounted_price, discount_code, discount_title, discount_id }, cities[]{id,name} } }`. Served from `config/laboratory.php`, not the DB. -- `catalogDetail` returns the single matching group; for `pat` users the per-laboratory `prices` are recomputed through the discount provider. -- `order` success → `data: { preOrderId }`. Validated against the catalog (`test_not_found`, `laboratory_not_found`), the user address (`user_address_not_found`, `invalid_user_address_for_lab` — the address city must be served by the lab), and duplicate open orders. Business failures return HTTP 501. -- **Not exposed:** the deprecated `POST /patients/addNewLabTest` (superseded by `order`), and the `results` endpoint's optional `companyId` query filter (SDKs use path params only, and that server-side filter is known-buggy). - -### 6.10 `diets` `[bearer] [scope:patients]` - -The patient's diet lists (a dietitian's "Diyet Listesi"). Controller `v3\General\Diets`, -`@hideFromAPIDocumentation`. JSON only — the server's PDF export (`dietFile`) is out of SDK scope -(it returns a binary `application/pdf`, not the envelope). - -| Canonical | Method | Path | Body / params | -|-----------|--------|-----------------------------------|---------------| -| `list` | GET | `/patients/dietLists/{page?}` | optional `page` (default 1). Page size is fixed to 10 server-side. | -| `detail` | GET | `/patients/diet/{listId}` | path `listId` (numeric) = a `list_id` from a `list` item. | - -- `list` `data`: `{ foundDietsCount, foundDiets: [ { list_id, diet_date, protocol_no, patient_name, patient_surname, patient_birthdate, patient_identity_no, doctor_company_name, doctor_name, doctor_surname, doctor_title, doctor_branch_name, doctor_image } ] }`. One entry per diet-program group; `list_id` feeds `detail`. -- `detail` `data`: an **array of meal-time groups** `[ { time, meals: [ { meal_time, total_calories, protocol_no, patient_*, doctor_company_name, diet_date, doctor_name, doctor_surname, doctor_title, doctor_image, doctor_certified_number, doctor_branch_name, meal_details: [ { quantity, explanation, meal_name, kcal, unit } ] } ] } ]`. An empty diet returns HTTP 501. - -### 6.11 `addresses` `[bearer] [scope:patients]` - -The patient's saved addresses. **Required by `laboratory.order`**, whose `addressId` -must reference one of these (and whose `city_id` must be in the lab's served cities). -All four verbs are the same path `/patients/userAddress` (distinguished by method). - -| Canonical | Method | Path | Body / params | -|-----------|--------|--------------------------|---------------| -| `list` | GET | `/patients/userAddress` | — | -| `add` | POST | `/patients/userAddress` | `title` (req), `cityId` (req, numeric), `districtId` (req, numeric), `address` (req), `locationLat` (req), `locationLng` (req), `description?`, `isDefault?` (0\|1) | -| `update` | PUT | `/patients/userAddress` | `id` (req); `title`/`cityId`/`districtId`/`address`/`locationLat`/`locationLng` are `required_without:isDefault`; `description?`, `isDefault?` | -| `delete` | DELETE | `/patients/userAddress` | `id` (req, in the **body** — not a path segment) | - -- `list` → a bare array (default first): `{ id, title, description, city_id, district_id, address, location_lat, location_lng, is_default, distinct_name }`. **`id` is the `addressId`** used by `update`/`delete`/`laboratory.order`. Returns 501 when the patient has no addresses (treat as "empty"). Only the district name is joined — map `city_id`→name via `doctors.locations`. -- `add` → `data: { addressId }`. The first address is forced default; setting `isDefault: 1` demotes the previous default. -- `update`/`delete` → message only (no `data`). The **default address cannot be deleted** (reassign via `update` first), nor can an address already used on an order. -- `cityId` comes from `doctors.locations` (`location_id`); `districtId` comes from `GET /getConfig` (`cities[].districts[].district_id`), reachable via the §7.2 escape hatch. +Value rules: numeric; `tension`/`pulse` digits 1–10; `glucose` 0–99999.99 + +`glucose_type` 0\|1; `weight`/`length` 0–99999.99; etc. + +`last` returns the most recent of each type (tension splits into +hypertension/hypotension; glucose into `hunger_glucose`/`postprandial_glucose`), +each with a `*Date`. + +> **`healthInformation` is the odd one out.** It predates the `patient{}` +> contract, needs the `teusan` scope instead of `apiouther`, and takes a flat +> `identity` + `phoneNumber`. Prefer `addList` for new integrations. +> +> **Its patient matching is an OR, and it is loose.** The lookup is +> `WHERE identity = … OR phone_number = …` against the **global** user table, +> taking the first row (`PatientUsersModel::patientUserFindWithOr`). A phone +> number alone can therefore resolve a person whose TCKN differs from the one you +> sent. Send both fields, but do not assume they are checked as a pair. This is +> the exact opposite of the `apiouther` reads in this group, which scope to your +> own company and fail closed on ambiguity (§8.1) — another reason to prefer +> `addList`. +> +> (Spec 0.x documented a defect here that nulled `identity` outright before it +> reached the lookup. That was fixed API-side on 2026-07-21; the OR remains.) + +### 6.5 `laboratory` `[scope:apiouther]` + +| Canonical | Method | Path | Body / params | +|-----------------|--------|---------------------------------------|---------------| +| `catalog` | GET | `/outher/laboratoryCatalog` | — | +| `catalogDetail` | GET | `/outher/laboratoryCatalog/{testId}` | path `testId` (req, numeric) | +| `results` | POST | `/outher/laboratoryResults` | `patient{}` = patientRef, `currentPage` (≥1) | +| `resultDetail` | POST | `/outher/laboratoryResult` | `patient{}` = patientRef, `testId` (req) | + +- `catalog` / `catalogDetail` are the **global orderable-test catalogue** — static + data, no patient and no company scoping. +- `resultDetail.testId` must be passed back **exactly** as `results` returned it: + a plain number is an HBYS lab request, a `-lab` suffix marks a TmcLab order + group (server pattern: `/^\d+(-lab)?$/`). The SDK does not parse or normalise it. +- Ordering a test is **not** available to partners (it creates a financial record). + +### 6.6 `diets` `[scope:apiouther]` + +| Canonical | Method | Path | Body | +|-----------|--------|----------------------|------| +| `list` | POST | `/outher/dietLists` | `patient{}` = patientRef, `currentPage` (≥1) | +| `detail` | POST | `/outher/diet` | `patient{}` = patientRef, `listId` (req, numeric) | + +- `list` → `{ foundDietsCount, foundDiets: [ { list_id, diet_date, protocol_no, patient_*, doctor_* } ] }`. + Page size is fixed to 20 server-side. `list_id` feeds `detail`. +- `detail` → an **array of meal-time groups** + `[ { time, meals: [ { meal_time, total_calories, …, meal_details: [ { quantity, explanation, meal_name, kcal, unit } ] } ] } ]`. + A `listId` that is not this patient's returns `501` with the generic message. --- ## 7. Naming conventions & API shape The client is a single root object exposing one accessor per service group; each -group exposes the canonical methods above. +group exposes the canonical methods above. **There is no namespace prefix** — the +partner surface *is* the surface. ``` -client.auth.connect(...) client.payments.pay(...) client.doctors.search(...) client.measures.addList(...) -client.slots.schedule(...) client.appointments.reserveInterview(...) -client.skin.analyze(...) client.meals.analyze(...) -client.laboratory.results(...) client.diets.list(...) +client.slots.schedule(...) client.laboratory.results(...) +client.appointments.reserve(...) client.diets.list(...) ``` Per-language casing & idioms: | Language | Method case | Notes | |----------|-------------|-------| -| JS/TS | `camelCase` | `client.doctors.quickSearch()`. Promise-based. | -| PHP | `camelCase` | `$client->doctors->quickSearch()`. Namespace `Bulutklinik\Sdk`. | -| Python | `snake_case`| `client.doctors.quick_search()`. Sync **and** async (`AsyncClient`). | -| Go | `PascalCase`| `client.Doctors.QuickSearch(ctx, …)`. Context-first, `(T, error)` returns. | -| Java | `camelCase` | `client.doctors().quickSearch(…)`. Builder for config; checked vs unchecked TBD in Faz 3. | -| C# | `PascalCase`+`Async` | `client.Doctors.QuickSearchAsync(…)`. `Task`, `CancellationToken`. | -| C++ | `snake_case`| `client.doctors().quick_search(…)`. Namespace `bulutklinik`. cpr + nlohmann/json. | +| JS/TS | `camelCase` | `client.doctors.search()`. Promise-based. | +| PHP | `camelCase` | `$client->doctors->search()`. Namespace `Bulutklinik\Sdk`. | +| Python | `snake_case`| `client.doctors.search()`. Sync **and** async (`AsyncClient`). | +| Go | `PascalCase`| `client.Doctors.Search(ctx, …)`. Context-first, `(T, error)` returns. | +| Java | `camelCase` | `client.doctors().search(…)`. Builder for config. | +| C# | `PascalCase`+`Async` | `client.Doctors.SearchAsync(…)`. `Task`, `CancellationToken`. | +| C++ | `snake_case`| `client.doctors().search(…)`. Namespace `bulutklinik`. cpr + nlohmann/json. | Request inputs are typed structures (objects/records/structs) per language; responses are typed where practical, otherwise a typed envelope + parsed `data`. +Where a method name would collide with a language keyword, the language's own +escape applies — e.g. C++ `measures.delete_measure(...)`, since `delete` is +reserved. + ### 7.1 Client configuration | Option | Default | Purpose | |---------------|----------------|----------------------------------------------------| -| `environment` / `baseUrl` | `production` | Named preset or explicit URL. | +| `environment` | `production` | Named preset (`production` \| `test` \| `local`). | +| `apiVersion` | `v3` | `v3` \| `v4`. Combined with `environment` to build the base URL. | +| `baseUrl` | — | Explicit URL; overrides `environment` + `apiVersion`. | | `lang` | `tr` | Default `lang` header; overridable per request. | -| `clientId` / `clientSecret` | — | Needed for `refresh` (and passed by `connect`). | -| `tokenStore` | in-memory | Pluggable persistence. | +| `partnerToken`| — | The partner token. Seeds the default in-memory store. | +| `tokenStore` | in-memory | Pluggable token source (§5.2). Mutually exclusive with `partnerToken`. | | `timeout` | sane default | Request timeout. | | `httpClient` | platform default | Injectable transport (PSR-18, http.Client, HttpClient, etc.). | +`clientId` / `clientSecret` are **gone** — they existed only for the patient +password and refresh grants. + ### 7.2 Escape hatch — arbitrary requests Not every endpoint has a typed resource method, and the API grows faster than the SDK surface. Every SDK therefore exposes **one generic request method on the root client** for calling any Bulutklinik API endpoint directly. It is not a separate HTTP client: it reuses the same transport, so default headers, the chosen auth -mode, silent token refresh + retry (§5.4), envelope unwrapping (§3) and the typed -error hierarchy (§4) all still apply. +mode, envelope unwrapping (§3) and the typed error hierarchy (§4) all still apply. Concept: @@ -621,65 +514,69 @@ client.request(method, path, { auth, body, lang }) -> data | Param | Notes | |----------|-------| | `method` | `GET` \| `POST` \| `PUT` \| `DELETE`. | -| `path` | Relative to the configured base URL, e.g. `/patients/allBranches`. Leading slash included. | -| `auth` | `public` \| `bearer` (**default**) \| `partner`. Accepted as a string or an existing public enum/const per language. | +| `path` | Relative to the configured base URL, e.g. `/outher/branches`. Leading slash included. | +| `auth` | `partner` (**default**) \| `public`. Accepted as a string or an existing public enum/const per language. | | `body` | Optional JSON payload (object/map/dict). Omitted on `GET`. | | `lang` | Optional per-request `lang` override, where the SDK's transport supports one (JS, PHP, Go, C++). Python / Java / C# apply the client-level `lang`. | -Returns the unwrapped `data` payload as the language's raw JSON value (the same -type a future typed resource method would parse from), and raises the same typed -errors on failure. Representative per-language signatures (idiomatic, return the -raw `data`): +Returns the unwrapped `data` payload as the language's raw JSON value, and raises +the same typed errors on failure. Representative per-language signatures: | Language | Signature | |----------|-----------| | JS/TS | `client.request({ method, path, auth?, body?, lang? }): Promise` | -| Python | `client.request(method, path, *, auth="bearer", body=None)` — plus the async client | -| PHP | `$client->request(string $method, string $path, string $auth = 'bearer', ?array $body = null, ?string $lang = null): mixed` | -| Go | `client.Do(ctx, method, path, *bk.RequestOptions) (json.RawMessage, error)` (nil options ⇒ bearer) | +| Python | `client.request(method, path, *, auth="partner", body=None)` — plus the async client | +| PHP | `$client->request(string $method, string $path, string $auth = 'partner', ?array $body = null, ?string $lang = null): mixed` | +| Go | `client.Do(ctx, method, path, *bk.RequestOptions) (json.RawMessage, error)` (nil options ⇒ partner) | | Java | `client.request(String method, String path, String auth, Object body)` → `JsonNode` | -| C# | `client.RequestAsync(HttpMethod method, string path, string auth = "bearer", object? body = null, CancellationToken = default)` → `JsonElement` | +| C# | `client.RequestAsync(HttpMethod method, string path, string auth = "partner", object? body = null, CancellationToken = default)` → `JsonElement` | | C++ | `client.request(method, path, bulutklinik::RequestOptions{})` → `nlohmann::json` | -This is the supported extension point for endpoints outside the 29 in §6. Prefer a -typed resource method when one exists; reach for `request` only for the gaps. +`auth: "public"` exists for the handful of unauthenticated endpoints outside §6 +that an integration may still need — e.g. `GET /general/getConfig` for the +`cities[].districts[]` list. Prefer a typed resource method when one exists. --- ## 8. Special cases -### 8.1 `payment3DUrl` (3-D Secure) — passthrough +### 8.1 How a patient reference resolves + +This is the single most important behaviour on the partner surface, and the two +paths are deliberately asymmetric. + +**Read path** (`patientRef` — `measures.last`/`list`/`graph`/`update`/`delete`, +`laboratory.results`/`resultDetail`, `diets.list`/`detail`): -`pay` success response: `{ resultType: 0, data: { payment3DUrl: "" } }`. -`payment3DUrl` is a **browser URL** the SDK returns verbatim — it is one of: - (A) the bank's direct `URL_3DS`, or - (B) `{APP_URL}/api/v3/payments/threeDUrl/` (our endpoint serving the 3DS HTML form). +1. Searches **only** `pat_patients` rows belonging to your company. It never + consults the global user table and **never creates** anything. +2. `identityNumber` (TCKN) is the primary selector. +3. `phoneNumber` is the fallback — used when no TCKN was sent, **or** when the + TCKN missed (a patient you created without a TCKN is findable once you learn + it). It is accepted only when it matches **exactly one** row; the column is not + unique, since family members share numbers. Two matches fail closed. +4. When the fallback fires and the matched row has a *different* non-empty + `identity_number`, the request is rejected — that is a different person. +5. Not found → `501` with the same generic message as "not yours" (§3.2). -The SDK **does not** open, follow, or parse it. 3DS completion ("provizyon -kapatma" / capture) happens browser↔bank↔server via the -`POST /api/v3/threeD/appointmentPaymentComplete/{trxId}/{driver}` callback -(`trxId = "{orderId}.{transactionUuid}.{processId}"`) — outside SDK scope. -If `is3D = false`, `data` is the inline-completed order result (no `payment3DUrl`). +**Write path** (`bookingUser` — every booking call, `measures.addList`/`add`): -### 8.2 Encrypted blobs — passthrough +1. Looks the person up globally by TCKN/phone and creates a password-less shadow + user if absent. +2. Find-or-creates the `pat_patients` row **in your company**. +3. This is why the descriptive fields (`name`, `surname`, `phoneNumber`) are + required here and absent from the read shape. -`connect`'s `data.response` (2FA), `register`'s `response`, `caseDetail`, and the -`case_detail` returned by `skin.analyze` are opaque encrypted blobs. The SDK passes -them through verbatim and never encrypts or decrypts — a `skin.analyze` `case_detail` -may be forwarded unchanged as a payment's `caseDetail`. The clinic/API encryption keys -are never embedded in the SDK. +The company boundary always comes from the authenticated token, never from +request input. There is no parameter that lets a partner read another company's +data — including no `companyId` field to send. -### 8.3 Public vs bearer vs partner +### 8.2 Opaque values — passthrough -- Public (no `Authorization`): `connect`, `connectWithTwoFactor`, `refresh`, `register`, - `confirmRegistrationEmail`, `verifyRegistrationSocial`, `registerSocial`, - `forgotPassword`, `resetPassword`. (`forgotPassword` still requires a browser CAPTCHA - token; the social pair does not.) -- Bearer (access token): everything else (incl. `appointments.*` and the `addresses` group). -- Partner: `partnerHealthInformation` (`scope:teusan`) and `verifyRegistration` - (`auth:apiusers`, no specific scope) use the separately-configured partner token, - not the patient access token. **Only these two** need the partner token — the social - registration verify step is public, unlike the non-social `verifyRegistration`. +`reserve`'s `hash`, the `url` it returns, and `outherProcessId` are server-issued +values. The SDK passes them through verbatim: it never decodes, re-encodes, +shortens or follows them. The clinic/API encryption keys are never embedded in +the SDK. --- @@ -689,13 +586,13 @@ are never embedded in the SDK. 2. **Minimal dependencies** — prefer the platform HTTP client; pin the documented stack per language (see §7 / PLAN.md). 3. **Typed** — public API and `data` payloads typed where the language supports it. -4. **Auto-refresh + retry** per §5.4, concurrency-safe. +4. **Fail fast on a missing token** — raise before dispatching (§5.2). 5. **Pluggable** token store and HTTP client. 6. **Errors** per §4 with full context. -7. **Tested** — unit tests for envelope/error/refresh logic + at least one live - smoke path against `test` env (Faz 1–2). -8. **Examples** — `examples/` with the end-to-end flow: login → search → slot → - reserve → (pay) and a measures example. +7. **Tested** — unit tests for envelope/error/auth/config logic + at least one live + smoke path against `test`. +8. **Examples** — `examples/` with the end-to-end partner flow: check doctor → + slots → reserve → create, and a measures read/write example. 9. **Self-contained repo** — README, LICENSE (MIT), DESIGN.md copy, CI. 10. **Versioning** — semver; tag `vX.Y.Z` per repo. @@ -703,22 +600,15 @@ are never embedded in the SDK. ## 10. Live validation reference (test env) -- Base: `https://apitest.bulutklinik.com/api/v3` -- OAuth client: `Patients_Web_Mobile` — id `96b630b3-f62a-4e67-b33c-b58802dca5af` (secret in the collection / env file). -- Test patient: `hackathon@bulutklinik.test` (`loginMode: email`). -- Bookable `doctorId` examples: `8282` (interview + physical), `168896` (interview). -- Known env limits (request is correct, server/env is the cause): - - `quickSearch` returns HTTP 404 / `resultType 1` on `test` — the search driver - (Elasticsearch) is unavailable there; the controller catches only - `QueryException` so other exceptions surface as a generic 404. `filteredSearch` - (`doctors.search`) works and is the production search path. - - `interviewPayment` may 404 if POS isn't configured for the company; 3DS capture - can't run from a non-browser client. SDK validation asserts the request shape + - `payment3DUrl` return, not the bank capture. -- TS reference live result (2026-06-17, `test`): 8/9 steps OK — `auth.connect`, - `doctors.branches` (136), `doctors.locations` (81), `doctors.search`, - `doctors.detail`, `slots.schedule`, `measures.last`, `auth.disconnect` all pass; - only `quickSearch` fails for the env reason above. +- Base: `https://apitest.bulutklinik.com/api/v3` (or `/v4`). +- Auth: a partner token issued for a test company with the `apiouther` scope. + Unlike the patient surface there is no shared test credential in the Postman + collection — the token is per-integration. +- Smoke path that needs no patient data: `doctors.branches` → `doctors.locations` + → `laboratory.catalog`. All three are `GET`, scope-gated only, and prove the + token and base URL are right. +- Patient-scoped reads need a patient that exists **in the token's company**; + a TCKN that works on the patient surface will not necessarily resolve here. --- @@ -731,3 +621,43 @@ This file is canonical. When it changes: If an SDK must diverge from this spec, fix the spec first (or record the divergence here) — code and SSOT must never silently disagree. + +--- + +## 12. Migration from 0.6.x + +0.6.x shipped two personas: a patient surface at the client root and a partner +surface under `client.partner.*`. 1.0.0 keeps **only** the partner one and lifts +it to the root. + +### 12.1 Mechanical rename + +| 0.6.x | 1.0.0 | +|-------|-------| +| `client.partner.doctors.*` | `client.doctors.*` | +| `client.partner.slots.*` | `client.slots.*` | +| `client.partner.appointments.*` | `client.appointments.*` | +| `client.partner.measures.*` | `client.measures.*` | +| `client.partner.laboratory.*` | `client.laboratory.*` | +| `client.partner.diets.*` | `client.diets.*` | + +Behaviour, paths and payloads of these 28 methods are unchanged. + +### 12.2 Removed with no replacement + +`auth` (all 11 methods) · `payments` (5) · `skin` · `meals` · `addresses` (4) · +and the patient-persona `doctors`/`slots`/`appointments`/`measures`/`laboratory`/`diets` +that lived at the root in 0.6.x. §1.2 explains why each has no partner +equivalent. An application that needs a patient session must talk to the API +directly; the SDK no longer models it. + +### 12.3 Configuration + +| 0.6.x | 1.0.0 | +|-------|-------| +| `clientId`, `clientSecret` | removed | +| `partnerToken` (optional, for 2 endpoints) | **required** credential for the whole client | +| token store held `accessToken` + `refreshToken` | holds one partner token | +| silent refresh + retry on 401/`resultType 4` | removed — `AuthenticationError`, no retry (§5.3) | +| base URL fixed at `/api/v3` | `/api/v3` or `/api/v4` via `apiVersion` | +| escape hatch `auth` default `bearer` | default `partner`; `bearer` no longer exists | diff --git a/README.md b/README.md index d031959..4aa1846 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ # bulutklinik/sdk -Official Bulutklinik API SDK for PHP. Framework-agnostic, PSR-18/PSR-17 based -(bring your own HTTP client, auto-discovered), fully typed, PHP 8.2+. +Official Bulutklinik **partner** API SDK for PHP. Framework-agnostic, +PSR-18/PSR-17 based (bring your own HTTP client, auto-discovered), fully typed, +PHP 8.2+. -Covers the patient flow: **auth, doctor search, slots, appointments, payments, -and health measures**. See [`DESIGN.md`](./DESIGN.md) for the full wire contract. +This is a single-persona SDK: every call runs on the company-scoped `/outher` +surface with the partner token issued for your integration. You act on the +patients of **your own company**, and the patient is named inline on each +request — there is no login and no session. See [`DESIGN.md`](./DESIGN.md) for +the full wire contract. + +> **1.0.0 is a breaking release.** The patient persona (login, registration, +> payments, AI analysis, address book) has been removed and the former +> `$client->partner->…` namespace was lifted to the client root. See +> [CHANGELOG.md](./CHANGELOG.md) and DESIGN.md §12 for the migration. ## Install @@ -12,8 +21,7 @@ and health measures**. See [`DESIGN.md`](./DESIGN.md) for the full wire contract composer require bulutklinik/sdk ``` -You also need any PSR-18 client + PSR-17 factories. They are auto-discovered via -`php-http/discovery`. If you don't have one yet: +You also need a PSR-18 client and PSR-17 factories, e.g.: ```bash composer require guzzlehttp/guzzle @@ -25,101 +33,202 @@ composer require guzzlehttp/guzzle use Bulutklinik\Sdk\BulutklinikClient; use Bulutklinik\Sdk\ClientConfig; use Bulutklinik\Sdk\Environment; +use Bulutklinik\Sdk\ApiVersion; $client = new BulutklinikClient(new ClientConfig( - environment: Environment::Production, // Production | Test | Local - clientId: getenv('BK_CLIENT_ID'), - clientSecret: getenv('BK_CLIENT_SECRET'), + environment: Environment::Production, // Production | Test | Local + apiVersion: ApiVersion::V3, // V3 (default) | V4 + partnerToken: getenv('BK_PARTNER_TOKEN') ?: null, )); -// 1) Log in (tokens are stored automatically) -$login = $client->auth->connect('patient@example.com', '•••••••', 'email'); +// 1) Find a doctor you can book +$result = $client->doctors->search(['withFreeText' => 'kardiyoloji'], 1, ['slot']); +$doctorId = $result['foundDoctors'][0]['doctor_id']; -if ($login->twoFactorRequired) { - // Collect the SMS code, then: - $client->auth->connectWithTwoFactor('123456', $login->twoFactorResponse); -} +// 2) Free slots +$schedule = $client->slots->schedule($doctorId, '2026-08-01'); +$slot = reset($schedule)[0]; -// 2) Find a doctor -$result = $client->doctors->search( - searchParams: ['withFreeText' => 'kardiyoloji'], - orderParams: ['slot'], - otherParams: ['isInterviewable'], - currentPage: 1, -); +// 3) Hold it for a patient — named inline, no session +$held = $client->appointments->reserveWithoutAgreement($slot['slotId'], $doctorId, [ + 'name' => 'Ada', + 'surname' => 'Lovelace', + 'phoneNumber' => '+905551112233', +]); -// 3) Slots, then 4) reserve ("YYYY-MM-DD HH:mm") -$doctorId = $result['foundDoctors'][0]['doctor_id']; -$slots = $client->slots->schedule($doctorId, 'interview'); -$client->appointments->reserveInterview($doctorId, '2026-06-20 14:30'); +// 4) Confirm before $held['reservationExpired'] passes +$client->appointments->create($held['hash'], $held['outherProcessId']); ``` ## Services -| Group | Methods | -|------------------------|---------| -| `$client->auth` | `connect`, `connectWithTwoFactor`, `verifyRegistration`, `confirmRegistrationEmail`, `register`, `verifyRegistrationSocial`, `registerSocial`, `forgotPassword`, `resetPassword`, `refresh`, `disconnect` | -| `$client->doctors` | `branches`, `locations`, `quickSearch`, `search`, `detail` | -| `$client->slots` | `schedule` | -| `$client->appointments` | `reserveInterview`, `addPhysical`, `cancel`, `list`, `reservations` | -| `$client->payments` | `checkDiscountCode`, `getCards`, `saveCard`, `pay`, `deleteCard` | -| `$client->measures` | `addList`, `add`, `update`, `delete`, `last`, `list`, `graph`, `partnerHealthInformation` | -| `$client->skin` | `analyze` | -| `$client->meals` | `analyze` | -| `$client->laboratory` | `results`, `resultDetail`, `catalog`, `catalogDetail`, `order` | -| `$client->diets` | `list`, `detail` | -| `$client->addresses` | `list`, `add`, `update`, `delete` | - -## Authentication & tokens - -- `connect` / `connectWithTwoFactor` / `register` store the access + refresh - tokens automatically. -- On a `401` (or `resultType 4`), the SDK silently refreshes once and retries. -- Provide a custom token store by implementing `Bulutklinik\Sdk\Token\TokenStore` - and passing it via `ClientConfig(tokenStore: …)`. - -## Payments (3-D Secure) - -`payments.pay()` returns an array with `payment3DUrl` on a 3DS flow. Open that URL -in a browser; the bank → server callback completes the capture. The SDK never -opens or parses the URL. +28 endpoints across six groups. + +| Group | Methods | +|--------------------------|---------| +| `$client->doctors` | `search`, `branches`, `detail`, `locations` | +| `$client->slots` | `schedule` | +| `$client->appointments` | `reserve`, `reserveWithoutAgreement`, `instantReserve`, `create`, `createWithoutSlot`, `cancelWithoutSlot`, `list`, `info`, `checkDoctor` | +| `$client->measures` | `last`, `list`, `graph`, `addList`, `add`, `update`, `delete`, `healthInformation` | +| `$client->laboratory` | `catalog`, `catalogDetail`, `results`, `resultDetail` | +| `$client->diets` | `list`, `detail` | + +## Naming a patient + +There is no session, so every patient-scoped call carries the patient in its +body — never in the URL, since a TCKN in a path segment would land in access +logs, proxy logs and error breadcrumbs. + +**Reads** take a light reference. The server looks only inside your own company +and never creates anything: + +```php +$client->measures->last(['identityNumber' => '12345678901']); +$client->diets->list(['phoneNumber' => '+905551112233']); +``` + +`identityNumber` is primary; `phoneNumber` is a fallback accepted only when it +matches exactly one patient (the column is not unique — family members share +numbers). A patient you have never treated resolves to "not found", with the same +message as "not yours" so the endpoint cannot be used to probe for TCKNs. + +**Writes** take the descriptive shape, because the patient is created inside your +company if absent: + +```php +$client->measures->addList( + ['name' => 'Ada', 'surname' => 'Lovelace', 'phoneNumber' => '+905551112233'], + [['type' => 'pulse', 'date_time' => '2026-06-17 09:31', 'pulse' => 72]], +); +``` + +## Booking + +Two flows, depending on who collects the agreements and the payment: + +```php +// (A) Hand off to the patient — returns a browser `url` for agreements + payment. +$held = $client->appointments->reserve($slotId, $doctorId, $user); +echo $held['url']; + +// (B) You already collected them — returns a `hash` to confirm yourself. +$held = $client->appointments->reserveWithoutAgreement($slotId, $doctorId, $user); +$client->appointments->create($held['hash'], $outherProcessId); +``` + +**Payment is never taken through the API.** No partner endpoint produces a +financial record; the browser hand-off in (A) is where payment happens. The SDK +returns `url` verbatim and never opens or follows it. + +`createWithoutSlot` books a free-form range outside the slot grid, for +integrations running their own calendar; `cancelWithoutSlot` 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 token store on **every** request, so a long-running +process can pick up a newly issued one without being rebuilt. Implement +`Bulutklinik\Sdk\Token\TokenStore` and pass it via `tokenStore:`: + +```php +use Bulutklinik\Sdk\Token\TokenStore; + +final class VaultTokenStore implements TokenStore +{ + public function getToken(): ?string { /* … */ } + public function setToken(?string $token): void { /* … */ } + public function clear(): void { /* … */ } +} + +$client = new BulutklinikClient(new ClientConfig(tokenStore: new VaultTokenStore())); + +// …or rotate the default in-memory store in place: +$client->tokenStore->setToken($newlyIssuedToken); +``` + +Pass `partnerToken:` **or** `tokenStore:`, not both — `ClientConfig` throws +`InvalidArgumentException` rather than guessing which one you meant. + +### When the token expires + +Tokens last about 30 days. An expired one comes back as `401` / `resultType 4`; +the SDK throws `AuthenticationException` 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 `AuthorizationException` (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 ```php -$client->measures->addList([ +$ref = ['identityNumber' => '12345678901']; + +// Write several measurements at once (max 200 per call, one transaction) +$client->measures->addList($patient, [ ['type' => 'tension', 'date_time' => '2026-06-17 09:30', 'hypertension' => 120, 'hypotension' => 80], ['type' => 'glucose', 'date_time' => '2026-06-17 09:35', 'glucose' => 95, 'glucose_type' => 0], ]); -$client->measures->last(); -$client->measures->list('glucose', 1, 0); // glucoseType 0=fasting, 1=postprandial -$client->measures->graph('tension', 2, 1); // period 2 = weekly +$client->measures->last($ref); +$client->measures->list($ref, 'glucose', 1, 0); // glucoseType 0=fasting, 1=postprandial +$client->measures->graph($ref, 'tension', 2); // period 2 = weekly ``` -> The partner endpoint (`partnerHealthInformation`) uses a `partnerToken` from -> `ClientConfig`. The API currently matches the patient by `phoneNumber`; send -> both `identity` and `phoneNumber` for forward compatibility. +> Measurements are written to **your own company**. A value you write does not +> appear in the patient's Bulutklinik mobile app, and values they entered there +> are not visible to you. That is tenant isolation working as intended. + +`measures->healthInformation()` is the legacy `teusan` bulk endpoint, kept for +existing integrations: it needs the `teusan` scope instead of `apiouther`, takes +a flat `identity` + `phoneNumber` instead of `patient`, and writes into the +shared consumer tenant. Its patient matching is an **OR**, and it is loose: the lookup is +`identity OR phoneNumber` against the *global* user table and takes the first +row, so a phone number alone can resolve someone whose TCKN differs from the one +you sent. Send both, but do not assume they are checked as a pair — the +`apiouther` reads above do the opposite, scoping to your company and failing +closed on ambiguity. Prefer `addList()` for anything new. -## AI image analysis +## Laboratory & diets ```php -// "Cildimde Neyim Var" — analyze one or more skin photos (base64) -$result = $client->skin->analyze([['image' => $base64Jpeg, 'branch_id' => 42]]); -foreach ($result['status'] as $s) { - echo $s['label'], $s['comment'], PHP_EOL; - // $s['case_detail'] can be forwarded verbatim as a payment's caseDetail -} +$ref = ['identityNumber' => '12345678901']; -// Meal photo → calorie/nutrition estimate -$meal = $client->meals->analyze( - image: $base64Jpeg, - portionSize: 'medium', // small | medium | large | custom - mealType: 'lunch', // breakfast | lunch | dinner | snack - // portionGrams: 300, // required when portionSize is 'custom' - // note: 'az yağlı', -); -echo $meal['status']['comment']; +// Global, static catalogue — no patient context +$catalog = $client->laboratory->catalog(); +$group = $client->laboratory->catalogDetail(7); + +// Results for a patient in your company. Ids may carry a "-lab" suffix; pass them back verbatim. +$results = $client->laboratory->results($ref); // or ->results($ref, 2) +$detail = $client->laboratory->resultDetail($ref, '4821-lab'); + +// Diet lists written by a dietitian. Page size is fixed to 20 server-side. +$diets = $client->diets->list($ref); +$plan = $client->diets->detail($ref, $diets['foundDiets'][0]['list_id']); +``` + +Ordering a lab test is not available to partners — it creates a financial record. + +## Escape hatch + +Not every endpoint has a typed method. `$client->request()` reuses the same +transport, so headers, envelope unwrapping and typed exceptions all still apply: + +```php +$data = $client->request('GET', '/outher/somethingNew'); + +// `public` reaches unauthenticated endpoints outside the partner surface, +// e.g. the city/district catalogue that feeds address forms. +$config = $client->request('GET', '/general/getConfig', 'public'); ``` ## Errors @@ -127,17 +236,17 @@ echo $meal['status']['comment']; All exceptions extend `Bulutklinik\Sdk\Exception\BulutklinikException`: `TransportException` (network) · `ApiException` → `ValidationException` (422), -`AuthenticationException` (401 / logout), `AuthorizationException` (403), -`NotFoundException` (404), `RateLimitException` (429). -Details live on `$e->context` (`httpStatus`, `resultType`, `errorType`, `data`, -`method`, `path`, `retryAfter`). +`AuthenticationException` (401 / revoked / expired), `AuthorizationException` +(403), `NotFoundException` (404), `RateLimitException` (429). +Every `ApiException` carries a `->context` with `httpStatus`, `resultType`, +`errorType`, `data`, `method`, `path`, `retryAfter`. ```php use Bulutklinik\Sdk\Exception\RateLimitException; use Bulutklinik\Sdk\Exception\ValidationException; try { - $client->payments->pay(/* … */); + $client->measures->last($ref); } catch (RateLimitException $e) { echo 'retry after ' . $e->context->retryAfter; } catch (ValidationException $e) { @@ -145,13 +254,18 @@ try { } ``` +Note that `/outher` reports most business-rule failures as HTTP **`501`** with +`resultType 1` — "patient not found in your company", "slot no longer free", +"doctor not bookable through your integration". It is not a server crash; read +the message. + ## Development ```bash composer install -composer cs:check # PHP-CS-Fixer (PSR-12) -composer stan # PHPStan level 6 -composer test # Pest +vendor/bin/pest +vendor/bin/phpstan analyse +vendor/bin/php-cs-fixer fix --dry-run --diff ``` ## License diff --git a/composer.json b/composer.json index 32c8a59..305aa7b 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "bulutklinik/sdk", - "description": "Official Bulutklinik API SDK for PHP", + "description": "Official Bulutklinik partner API SDK for PHP", "type": "library", "license": "MIT", "keywords": ["bulutklinik", "sdk", "api", "telemedicine", "health", "appointments", "hbys"], diff --git a/examples/flow.php b/examples/flow.php index 028d6b5..2f440b3 100644 --- a/examples/flow.php +++ b/examples/flow.php @@ -3,9 +3,11 @@ declare(strict_types=1); /** - * End-to-end example: login -> search -> slots -> reserve, plus health measures. - * Provide credentials via env: BK_CLIENT_ID, BK_CLIENT_SECRET, BK_USERNAME, - * BK_PASSWORD, BK_DOCTOR_ID. Run: php examples/flow.php + * End-to-end partner example: check a doctor -> slots -> reserve, plus a + * health-measures read/write round trip. + * + * Provide credentials via env: BK_PARTNER_TOKEN, BK_DOCTOR_ID, BK_PATIENT_PHONE, + * BK_PATIENT_TCKN. Run: php examples/flow.php */ require __DIR__ . '/../vendor/autoload.php'; @@ -16,34 +18,49 @@ $client = new BulutklinikClient(new ClientConfig( environment: Environment::Test, - clientId: getenv('BK_CLIENT_ID') ?: '', - clientSecret: getenv('BK_CLIENT_SECRET') ?: '', + partnerToken: getenv('BK_PARTNER_TOKEN') ?: null, )); -$login = $client->auth->connect( - getenv('BK_USERNAME') ?: '', - getenv('BK_PASSWORD') ?: '', - 'email', -); +// 1. Discovery. Needs no patient data, so it is the fastest way to prove the +// token and base URL are right. +$branches = $client->doctors->branches(); +echo 'branches: ' . count($branches) . "\n"; -if ($login->twoFactorRequired) { - echo "2FA required. Collect the SMS code, then call:\n"; - echo " \$client->auth->connectWithTwoFactor(\$smsCode, \$login->twoFactorResponse);\n"; +$doctorId = (int) (getenv('BK_DOCTOR_ID') ?: '8282'); +$bookable = $client->appointments->checkDoctor($doctorId, 0); +echo 'bookable through this integration: ' . json_encode($bookable, JSON_UNESCAPED_UNICODE) . "\n"; - return; -} +// 2. Availability. `slotId` from here feeds the reservation. +$schedule = $client->slots->schedule($doctorId, '2026-08-01'); +echo 'slots: ' . json_encode($schedule, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"; -$search = $client->doctors->quickSearch('kardiyo', 'interview'); -echo 'quickSearch: ' . json_encode($search, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"; +// 3. Booking. The patient is named inline — there is no session. +// +// `reserve()` returns a `url` to hand to the patient for agreements and +// payment. `reserveWithoutAgreement()` returns a `hash` for you to confirm +// yourself, as below. +$user = [ + 'name' => 'Ada', + 'surname' => 'Lovelace', + 'phoneNumber' => getenv('BK_PATIENT_PHONE') ?: '+905551112233', + 'identityNumber' => getenv('BK_PATIENT_TCKN') ?: null, +]; -$doctorId = (int) (getenv('BK_DOCTOR_ID') ?: '8282'); -$slots = $client->slots->schedule($doctorId, 'interview'); -echo 'slots: ' . json_encode($slots, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"; +$firstDay = is_array($schedule) ? (reset($schedule) ?: []) : []; +if ($firstDay !== []) { + $held = $client->appointments->reserveWithoutAgreement($firstDay[0]['slotId'], $doctorId, $user); + echo 'held until ' . ($held['reservationExpired'] ?? '?') . "\n"; + // `outherProcessId` arrives alongside `hash` in the same response: + // $client->appointments->create($held['hash'], $outherProcessId); +} -$client->measures->addList([ +// 4. Measurements. Writes create the patient in your company if absent; reads +// only ever look inside your company. +$client->measures->addList($user, [ ['type' => 'tension', 'date_time' => '2026-06-17 09:30', 'hypertension' => 120, 'hypotension' => 80], ['type' => 'pulse', 'date_time' => '2026-06-17 09:31', 'pulse' => 72], ]); echo "measures submitted\n"; -$client->auth->disconnect(); +$latest = $client->measures->last(['phoneNumber' => $user['phoneNumber']]); +echo 'latest: ' . json_encode($latest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"; diff --git a/scripts/live-check.php b/scripts/live-check.php index 461b367..045fbf0 100644 --- a/scripts/live-check.php +++ b/scripts/live-check.php @@ -4,21 +4,35 @@ /** * Live smoke test against the Bulutklinik test environment (apitest). - * Read-only flow; each step is independent. Credentials default to the repo's - * Postman collection (test account). Run: php scripts/live-check.php + * + * Read-only flow; each step is independent. Needs a partner token issued for a + * test company with the `apiouther` scope: + * + * BK_PARTNER_TOKEN=... php scripts/live-check.php + * + * Unlike the patient surface there is no shared test credential — the token is + * per-integration. Steps that touch a patient need one that exists inside the + * token's own company; set BK_PATIENT_TCKN or BK_PATIENT_PHONE to run them. */ require __DIR__ . '/../vendor/autoload.php'; +use Bulutklinik\Sdk\ApiVersion; use Bulutklinik\Sdk\BulutklinikClient; use Bulutklinik\Sdk\ClientConfig; use Bulutklinik\Sdk\Environment; use Bulutklinik\Sdk\Exception\ApiException; +$partnerToken = getenv('BK_PARTNER_TOKEN') ?: ''; +if ($partnerToken === '') { + fwrite(STDERR, "BK_PARTNER_TOKEN is required.\n"); + exit(2); +} + $client = new BulutklinikClient(new ClientConfig( environment: Environment::Test, - clientId: getenv('BK_CLIENT_ID') ?: '96b630b3-f62a-4e67-b33c-b58802dca5af', - clientSecret: getenv('BK_CLIENT_SECRET') ?: 'KPgmEavOSomEl8mQu1ZZMoyZaVXBSuuKxrrzMAkX', + apiVersion: ApiVersion::from(getenv('BK_API_VERSION') ?: 'v3'), + partnerToken: $partnerToken, )); $results = []; @@ -45,36 +59,46 @@ } }; -$login = $step('auth.connect', fn () => $client->auth->connect( - getenv('BK_USERNAME') ?: 'hackathon@bulutklinik.test', - getenv('BK_PASSWORD') ?: 'Hackathon2026', - 'email', -)); -echo ' twoFactorRequired=' . var_export($login?->twoFactorRequired, true) - . ' accessTokenStored=' . var_export($client->tokenStore->getAccessToken() !== null, true) . "\n"; - +// --- Scope-only steps: prove the token and base URL without any patient. $branches = $step('doctors.branches', fn () => $client->doctors->branches()); echo ' branches=' . (is_array($branches) ? count($branches) : 'n/a') . "\n"; $locations = $step('doctors.locations', fn () => $client->doctors->locations()); echo ' locations=' . (is_array($locations) ? count($locations) : 'n/a') . "\n"; -$step('doctors.quickSearch', fn () => $client->doctors->quickSearch('kardiyo', 'interview')); +$catalog = $step('laboratory.catalog', fn () => $client->laboratory->catalog()); +echo ' catalog=' . (is_array($catalog) ? count($catalog) : 'n/a') . "\n"; -$found = $step('doctors.search', fn () => $client->doctors->search(['withFreeText' => 'kardiyoloji'], ['slot'], ['isInterviewable'], 1, 10)); +$found = $step('doctors.search', fn () => $client->doctors->search(['withFreeText' => 'kardiyoloji'], 1, ['slot'])); echo ' foundDoctorsCount=' . (is_array($found) ? var_export($found['foundDoctorsCount'] ?? 'n/a', true) : 'n/a') . "\n"; $doctorId = (int) (getenv('BK_DOCTOR_ID') ?: '8282'); $detail = $step('doctors.detail', fn () => $client->doctors->detail($doctorId)); echo ' detailKeys=' . (is_array($detail) ? count($detail) : 'n/a') . "\n"; -$slots = $step('slots.schedule', fn () => $client->slots->schedule($doctorId, 'interview')); +$step('appointments.checkDoctor', fn () => $client->appointments->checkDoctor($doctorId, 0)); + +$slots = $step('slots.schedule', fn () => $client->slots->schedule($doctorId)); echo ' slotDays=' . (is_array($slots) ? count($slots) : 'n/a') . "\n"; -$last = $step('measures.last', fn () => $client->measures->last()); -echo ' measuresLastKeys=' . (is_array($last) ? count($last) : 'n/a') . "\n"; +// --- Patient-scoped steps. A TCKN that works on the patient surface will not +// necessarily resolve here: the patient must exist in the token's company. +$patient = null; +if ($tckn = getenv('BK_PATIENT_TCKN')) { + $patient = ['identityNumber' => $tckn]; +} elseif ($phone = getenv('BK_PATIENT_PHONE')) { + $patient = ['phoneNumber' => $phone]; +} + +if ($patient !== null) { + $last = $step('measures.last', fn () => $client->measures->last($patient)); + echo ' measuresLastKeys=' . (is_array($last) ? count($last) : 'n/a') . "\n"; -$step('auth.disconnect', fn () => $client->auth->disconnect()); + $step('diets.list', fn () => $client->diets->list($patient)); + $step('laboratory.results', fn () => $client->laboratory->results($patient)); +} else { + echo "-- skipped patient-scoped steps (set BK_PATIENT_TCKN or BK_PATIENT_PHONE)\n"; +} $passed = count(array_filter($results, fn ($r) => $r[1] === true)); echo "\nSUMMARY: {$passed}/" . count($results) . " steps OK\n"; diff --git a/src/ApiVersion.php b/src/ApiVersion.php new file mode 100644 index 0000000..6d725d3 --- /dev/null +++ b/src/ApiVersion.php @@ -0,0 +1,15 @@ +auth->connect('patient@example.com', '•••', 'email'); - * $result = $client->doctors->quickSearch('kardiyo'); + * $branches = $client->doctors->branches(); + * $latest = $client->measures->last(['identityNumber' => '12345678901']); */ final class BulutklinikClient { - public readonly AuthResource $auth; + /** Doctor discovery: search, branches, detail, city list. */ public readonly DoctorsResource $doctors; + /** Doctor availability (materialized slots). */ public readonly SlotsResource $slots; + /** Reserve, confirm, free-form booking, cancel, list, lookup. */ public readonly AppointmentsResource $appointments; - public readonly PaymentsResource $payments; + /** Health measurements for a named patient, read and write. */ public readonly MeasuresResource $measures; - public readonly SkinResource $skin; - public readonly MealsResource $meals; + /** Lab results for a named patient + the orderable test catalog. */ public readonly LaboratoryResource $laboratory; + /** Diet lists written by a dietitian, for a named patient. */ public readonly DietsResource $diets; - public readonly AddressesResource $addresses; + /** + * The active token store. Write a newly issued partner token here to rotate + * the credential without rebuilding the client. + */ public readonly TokenStore $tokenStore; private readonly HttpClient $http; @@ -54,38 +58,34 @@ public function __construct(?ClientConfig $config = null) $this->http = new HttpClient($config); $this->tokenStore = $this->http->tokenStore; - $this->auth = new AuthResource($this->http); $this->doctors = new DoctorsResource($this->http); $this->slots = new SlotsResource($this->http); $this->appointments = new AppointmentsResource($this->http); - $this->payments = new PaymentsResource($this->http); $this->measures = new MeasuresResource($this->http); - $this->skin = new SkinResource($this->http); - $this->meals = new MealsResource($this->http); $this->laboratory = new LaboratoryResource($this->http); $this->diets = new DietsResource($this->http); - $this->addresses = new AddressesResource($this->http); } /** * Escape hatch: call any Bulutklinik API endpoint that does not yet have a * typed resource method. The request goes through the same shared transport * as the resource methods, so default headers, the chosen `$auth` mode - * (`bearer` by default), silent token refresh + retry, envelope unwrapping - * and the typed error hierarchy all still apply. Returns the unwrapped - * `data` payload. Prefer a typed resource method when one exists. + * (`partner` by default), envelope unwrapping and the typed error hierarchy + * all still apply. Returns the unwrapped `data` payload. Prefer a typed + * resource method when one exists. * - * @param string $method `GET` | `POST` | `PUT` | `DELETE` - * @param string $path relative to the base URL, e.g. `/patients/allBranches` - * @param string $auth `public` | `bearer` | `partner` (default `bearer`) - * @param array|null $body optional JSON payload (omitted on `GET`) - * @param string|null $lang optional per-request `lang` override + * @param string $method `GET` | `POST` | `PUT` | `DELETE` + * @param string $path relative to the base URL, e.g. `/outher/branches` + * @param string $auth `partner` (default) | `public` + * @param array|null $body optional JSON payload (omitted on `GET`) + * @param string|null $lang optional per-request `lang` override * * @example - * $branches = $client->request('GET', '/patients/allBranches'); - * $created = $client->request('POST', '/patients/someNewEndpoint', 'bearer', ['foo' => 'bar']); + * $branches = $client->request('GET', '/outher/branches'); + * // `public` reaches unauthenticated endpoints outside the partner surface + * $config = $client->request('GET', '/general/getConfig', 'public'); */ - public function request(string $method, string $path, string $auth = 'bearer', ?array $body = null, ?string $lang = null): mixed + public function request(string $method, string $path, string $auth = 'partner', ?array $body = null, ?string $lang = null): mixed { return $this->http->request($method, $path, $auth, $body, $lang); } diff --git a/src/ClientConfig.php b/src/ClientConfig.php index fdad447..0a0b4ca 100644 --- a/src/ClientConfig.php +++ b/src/ClientConfig.php @@ -4,6 +4,7 @@ namespace Bulutklinik\Sdk; +use Bulutklinik\Sdk\Token\InMemoryTokenStore; use Bulutklinik\Sdk\Token\TokenStore; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; @@ -13,25 +14,40 @@ * Immutable client configuration. All transport pieces (PSR-18 client, PSR-17 * factories) are optional and auto-discovered via php-http/discovery when null. * Request timeouts are a property of the injected PSR-18 client. + * + * Pass `$partnerToken` **or** `$tokenStore`, 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. */ final class ClientConfig { public function __construct( public readonly Environment $environment = Environment::Production, + public readonly ApiVersion $apiVersion = ApiVersion::V3, public readonly ?string $baseUrl = null, public readonly string $lang = 'tr', - public readonly ?string $clientId = null, - public readonly ?string $clientSecret = null, public readonly ?string $partnerToken = null, public readonly ?TokenStore $tokenStore = null, public readonly ?ClientInterface $httpClient = null, public readonly ?RequestFactoryInterface $requestFactory = null, public readonly ?StreamFactoryInterface $streamFactory = null, ) { + if ($partnerToken !== null && $tokenStore !== null) { + throw new \InvalidArgumentException( + 'Pass either $partnerToken or $tokenStore, not both. ' + . 'Seed your own store with the token if you need custom persistence.', + ); + } } public function resolveBaseUrl(): string { - return rtrim($this->baseUrl ?? $this->environment->baseUrl(), '/'); + return rtrim($this->baseUrl ?? $this->environment->baseUrl($this->apiVersion), '/'); + } + + /** The configured store, or an in-memory one seeded with `$partnerToken`. */ + public function resolveTokenStore(): TokenStore + { + return $this->tokenStore ?? new InMemoryTokenStore($this->partnerToken); } } diff --git a/src/Environment.php b/src/Environment.php index b1e32d0..fe474e3 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -10,12 +10,18 @@ enum Environment: string case Test = 'test'; case Local = 'local'; - public function baseUrl(): string + /** API root for this environment. The base URL is `/`. */ + public function apiRoot(): string { return match ($this) { - self::Production => 'https://api.bulutklinik.com/api/v3', - self::Test => 'https://apitest.bulutklinik.com/api/v3', - self::Local => 'https://api-bulutklinik.test/api/v3', + self::Production => 'https://api.bulutklinik.com/api', + self::Test => 'https://apitest.bulutklinik.com/api', + self::Local => 'https://api-bulutklinik.test/api', }; } + + public function baseUrl(ApiVersion $apiVersion = ApiVersion::V3): string + { + return $this->apiRoot() . '/' . $apiVersion->value; + } } diff --git a/src/Exception/ApiException.php b/src/Exception/ApiException.php index 7d54a82..80c6067 100644 --- a/src/Exception/ApiException.php +++ b/src/Exception/ApiException.php @@ -14,7 +14,8 @@ public function __construct(string $message, public readonly ApiErrorContext $co /** * Map an API failure to the most specific exception type. - * Precedence: logout (resultType 2) -> string errorType "validation" -> HTTP status. + * Precedence: revoked/expired token (resultType 2 or 4) -> string errorType + * "validation" -> HTTP status. */ public static function fromContext(ApiErrorContext $context, string $message): self { @@ -22,6 +23,16 @@ public static function fromContext(ApiErrorContext $context, string $message): s return new AuthenticationException($message, $context); } + // `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 ($context->resultType === 4) { + return new AuthenticationException( + $message . ' The partner token is expired or invalid — install a newly issued token;' + . ' the SDK cannot refresh it.', + $context, + ); + } + $type = \is_string($context->errorType) ? \strtolower($context->errorType) : null; if ($type === 'validation' || $context->httpStatus === 422) { return new ValidationException($message, $context); diff --git a/src/Exception/AuthenticationException.php b/src/Exception/AuthenticationException.php index 820f267..ae73306 100644 --- a/src/Exception/AuthenticationException.php +++ b/src/Exception/AuthenticationException.php @@ -4,7 +4,7 @@ namespace Bulutklinik\Sdk\Exception; -/** 401, a logout (resultType 2), or a failed token refresh. */ +/** 401, a revoked token (resultType 2), or an expired one (resultType 4). */ final class AuthenticationException extends ApiException { } diff --git a/src/Exception/AuthorizationException.php b/src/Exception/AuthorizationException.php index 1cfe12b..7f56bff 100644 --- a/src/Exception/AuthorizationException.php +++ b/src/Exception/AuthorizationException.php @@ -4,7 +4,12 @@ namespace Bulutklinik\Sdk\Exception; -/** 403 — authenticated but not permitted / out of scope. */ +/** + * 403 — the token authenticated but is not permitted. Either it lacks the + * `apiouther` scope or it resolves to a user with no company. The company + * boundary comes from the token, never from request input, so retrying with + * different body parameters will not help. + */ final class AuthorizationException extends ApiException { } diff --git a/src/Http/HttpClient.php b/src/Http/HttpClient.php index b28f342..3428558 100644 --- a/src/Http/HttpClient.php +++ b/src/Http/HttpClient.php @@ -9,7 +9,6 @@ use Bulutklinik\Sdk\Exception\ApiException; use Bulutklinik\Sdk\Exception\AuthenticationException; use Bulutklinik\Sdk\Exception\TransportException; -use Bulutklinik\Sdk\Token\InMemoryTokenStore; use Bulutklinik\Sdk\Token\TokenStore; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; @@ -20,19 +19,19 @@ use Psr\Http\Message\StreamFactoryInterface; /** - * Low-level transport: builds PSR-7 requests, unwraps the response envelope, - * maps failures to typed exceptions, and performs a single silent token - * refresh + retry on a 401 / `resultType 4`. + * Low-level transport: builds PSR-7 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 + * `AuthenticationException` instead of being retried. */ final class HttpClient { public readonly TokenStore $tokenStore; - public readonly ?string $clientId; - public readonly ?string $clientSecret; private readonly string $baseUrl; private readonly string $lang; - private readonly ?string $partnerToken; private readonly ClientInterface $httpClient; private readonly RequestFactoryInterface $requestFactory; private readonly StreamFactoryInterface $streamFactory; @@ -41,10 +40,7 @@ public function __construct(ClientConfig $config) { $this->baseUrl = $config->resolveBaseUrl(); $this->lang = $config->lang; - $this->clientId = $config->clientId; - $this->clientSecret = $config->clientSecret; - $this->partnerToken = $config->partnerToken; - $this->tokenStore = $config->tokenStore ?? new InMemoryTokenStore(); + $this->tokenStore = $config->resolveTokenStore(); $this->httpClient = $config->httpClient ?? Psr18ClientDiscovery::find(); $this->requestFactory = $config->requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory = $config->streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); @@ -54,22 +50,6 @@ public function __construct(ClientConfig $config) * @param array|null $body */ public function request(string $method, string $path, string $auth, ?array $body = null, ?string $lang = null): mixed - { - return $this->send($method, $path, $auth, $body, $lang, false); - } - - /** Force a token refresh using the stored refresh token. Throws on failure. */ - public function refresh(): void - { - if (!$this->tryRefresh()) { - throw new AuthenticationException('Token refresh failed', new ApiErrorContext(httpStatus: 401)); - } - } - - /** - * @param array|null $body - */ - private function send(string $method, string $path, string $auth, ?array $body, ?string $lang, bool $isRetry): mixed { [$status, $envelope, $response] = $this->dispatch($method, $path, $auth, $body, $lang); @@ -77,11 +57,8 @@ private function send(string $method, string $path, string $auth, ?array $body, return $envelope['data'] ?? null; } - $expired = $status === 401 || ($envelope['resultType'] ?? null) === 4; - if ($auth === 'bearer' && $expired && !$isRetry && $this->tryRefresh()) { - return $this->send($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 (($envelope['resultType'] ?? null) === 2) { $this->tokenStore->clear(); } @@ -107,13 +84,16 @@ private function dispatch(string $method, string $path, string $auth, ?array $bo ->withBody($this->streamFactory->createStream($json)); } - if ($auth === 'bearer') { - $token = $this->tokenStore->getAccessToken(); - if ($token !== null) { - $request = $request->withHeader('Authorization', 'Bearer ' . $token); + if ($auth === 'partner') { + $token = $this->tokenStore->getToken(); + if ($token === null || $token === '') { + // Dispatching anyway would only come back as an opaque 401. + throw new AuthenticationException( + 'No partner token configured.', + new ApiErrorContext(httpStatus: 0, method: $method, path: $path), + ); } - } elseif ($auth === 'partner' && $this->partnerToken !== null) { - $request = $request->withHeader('Authorization', 'Bearer ' . $this->partnerToken); + $request = $request->withHeader('Authorization', 'Bearer ' . $token); } try { @@ -146,37 +126,6 @@ private function decode(string $contents): array return \is_array($decoded) ? $decoded : ['data' => $decoded]; } - private function tryRefresh(): bool - { - $refreshToken = $this->tokenStore->getRefreshToken(); - if ($refreshToken === null || $this->clientId === null || $this->clientSecret === null) { - return false; - } - - try { - [$status, $envelope] = $this->dispatch('POST', '/general/refreshApi', 'public', [ - 'refreshToken' => $refreshToken, - 'clientId' => $this->clientId, - 'clientSecretKey' => $this->clientSecret, - ], null); - } catch (TransportException) { - return false; - } - - $data = $envelope['data'] ?? null; - if ($status < 200 || $status >= 300 || ($envelope['resultType'] ?? null) !== 0 - || !\is_array($data) || !isset($data['access_token'])) { - $this->tokenStore->clear(); - - return false; - } - - $newRefresh = isset($data['refresh_token']) ? (string) $data['refresh_token'] : $refreshToken; - $this->tokenStore->setTokens((string) $data['access_token'], $newRefresh); - - return true; - } - /** * @param array $envelope */ diff --git a/src/LoginResult.php b/src/LoginResult.php deleted file mode 100644 index c3d3288..0000000 --- a/src/LoginResult.php +++ /dev/null @@ -1,18 +0,0 @@ -http->request('GET', '/patients/userAddress', 'bearer'); - } - - /** - * Add an address. Success → `{ addressId }`. The first address is always the default. - * - * @return array{addressId: int|string}|array - */ - public function add( - string $title, - int|string $cityId, - int|string $districtId, - string $address, - string $locationLat, - string $locationLng, - ?string $description = null, - ?int $isDefault = null, - ): array { - $body = [ - 'title' => $title, - 'cityId' => $cityId, - 'districtId' => $districtId, - 'address' => $address, - 'locationLat' => $locationLat, - 'locationLng' => $locationLng, - ]; - if ($description !== null) { - $body['description'] = $description; - } - if ($isDefault !== null) { - $body['isDefault'] = $isDefault; - } - - return $this->asArray($this->http->request('POST', '/patients/userAddress', 'bearer', $body)); - } - - /** - * Update an address by `id`. Send only `$id` + `$isDefault` to flip the default - * flag, or the other fields to edit it. - * - * @param array $fields Optional keys: title, description, cityId, - * districtId, address, locationLat, locationLng, isDefault. - */ - public function update(int|string $id, array $fields = []): mixed - { - return $this->http->request('PUT', '/patients/userAddress', 'bearer', ['id' => $id] + $fields); - } - - /** - * Delete an address by `id` (sent in the body). The default address cannot be - * deleted — reassign the default via {@see update()} first; an address already - * used on an order cannot be deleted either. - */ - public function delete(int|string $id): mixed - { - return $this->http->request('DELETE', '/patients/userAddress', 'bearer', ['id' => $id]); - } -} diff --git a/src/Resource/AppointmentsResource.php b/src/Resource/AppointmentsResource.php index ccb8257..7452711 100644 --- a/src/Resource/AppointmentsResource.php +++ b/src/Resource/AppointmentsResource.php @@ -4,50 +4,131 @@ namespace Bulutklinik\Sdk\Resource; -/** Online reservation, physical appointment and cancellation. */ +/** + * The appointment lifecycle. + * + * The patient is supplied inline as `$user` — there is no patient login in this + * mode. The server materialises the patient inside your company on write. + * + * **Payment is not taken through the API.** `reserve()` returns a process that is + * settled through the hosted web checkout; see the reservation response. + * + * `$user` shape (write form): + * `['name' => string, 'surname' => string, 'phoneNumber' => string, + * 'identityNumber' => ?string, 'email' => ?string, 'birthdate' => ?string, + * 'nationality' => ?string, 'price' => ?float]` + */ final class AppointmentsResource extends AbstractResource { - /** Reserve an online (interview) slot. Returns null on success. */ - public function reserveInterview(int|string $doctorId, string $appointmentDate, string $appointmentType = 'interview'): mixed + /** + * Hold an online slot for the given patient. + * + * @param array $user + */ + public function reserve(int|string $slotId, int|string $doctorId, array $user): mixed { - return $this->http->request('POST', '/patients/addInterviewDateReservation', 'bearer', [ + return $this->http->request('POST', '/outher/reservation', 'partner', [ + 'slotId' => $slotId, 'doctorId' => $doctorId, - 'appointmentDate' => $appointmentDate, - 'appointmentType' => $appointmentType, + 'user' => $user, ]); } - /** Create a physical appointment. */ - public function addPhysical(int|string $doctorId, string $appointmentDate): mixed + /** + * Same as `reserve()`, for integrations that collect the agreements themselves. + * + * @param array $user + */ + public function reserveWithoutAgreement(int|string $slotId, int|string $doctorId, array $user): mixed { - return $this->http->request('POST', '/patients/addNewAppointment', 'bearer', [ + return $this->http->request('POST', '/outher/reservationWithoutAgreement', 'partner', [ + 'slotId' => $slotId, 'doctorId' => $doctorId, - 'appointmentDate' => $appointmentDate, + 'user' => $user, ]); } - /** Cancel an appointment by event id (`cln_events.id`). */ - public function cancel(int|string $eventId): mixed + /** + * Instant (no slot) reservation. + * + * @param array $user + */ + public function instantReserve(array $user): mixed { - return $this->http->request('DELETE', "/patients/deleteUserAppointment/{$eventId}", 'bearer'); + return $this->http->request('POST', '/outher/instantReservation', 'partner', ['user' => $user]); + } + + /** Turn a reservation into a confirmed appointment. */ + public function create(string $hash, int|string $outherProcessId): mixed + { + return $this->http->request('POST', '/outher/appointment', 'partner', [ + 'hash' => $hash, + 'outherProcessId' => $outherProcessId, + ]); + } + + /** + * Book a free-form time range without going through a slot. + * + * @param string $startDate `Y-m-d H:i`, today or later + * @param string $finishDate `Y-m-d H:i`, after `$startDate` + * @param array $user + */ + public function createWithoutSlot( + int|string $doctorId, + string $startDate, + string $finishDate, + array $user, + ?int $isOutherDoctor = null, + ): mixed { + return $this->http->request('POST', '/outher/appointmentWithoutSlot', 'partner', [ + 'doctorId' => $doctorId, + 'startDate' => $startDate, + 'finishDate' => $finishDate, + 'isOutherDoctor' => $isOutherDoctor, + 'user' => $user, + ]); } /** - * The patient's appointments (`{ foundAppointmentsCount, foundAppointments }`). - * Each item's `event_id` is the id for {@see cancel()}; rows with `event_id` "0" - * are paid-order/refund entries and are not cancellable. Server paging is - * disabled, so page 1 (the default) returns the full list. + * Cancel an appointment created with `createWithoutSlot()`. + * + * Address it either by process (`hash` + `outherProcessId`) or by coordinates + * (`doctorId` + `appointmentDate` + `isOutherDoctor`). + * + * @param array $lookup */ - public function list(int|string|null $page = null): mixed + public function cancelWithoutSlot(array $lookup): mixed { - $path = $page !== null ? "/patients/userAppointments/{$page}" : '/patients/userAppointments'; + return $this->http->request('DELETE', '/outher/appointmentWithoutSlot', 'partner', $lookup); + } - return $this->http->request('GET', $path, 'bearer'); + /** Appointments you created for the given phone number. */ + public function list(string $phoneNumber, int|string|null $page = null, ?string $type = null): mixed + { + return $this->http->request('POST', '/outher/appointments', 'partner', [ + 'phoneNumber' => $phoneNumber, + 'page' => $page, + 'type' => $type, + ]); } - /** The patient's active online-slot reservation holds (with a `minute_diff`/`second_diff` countdown). */ - public function reservations(): mixed + /** + * A single appointment, addressed by process or by coordinates. + * + * @param array $lookup + */ + public function info(array $lookup): mixed + { + return $this->http->request('POST', '/outher/appointmentInfo', 'partner', $lookup); + } + + /** Whether a doctor is bookable through your integration. */ + public function checkDoctor(int|string $doctorId, int $isOutherDoctor): mixed { - return $this->http->request('GET', '/patients/userReservations', 'bearer'); + return $this->http->request('POST', '/outher/checkDoctor', 'partner', [ + 'doctorId' => $doctorId, + 'isOutherDoctor' => $isOutherDoctor, + ]); } } diff --git a/src/Resource/AuthResource.php b/src/Resource/AuthResource.php deleted file mode 100644 index 859070f..0000000 --- a/src/Resource/AuthResource.php +++ /dev/null @@ -1,293 +0,0 @@ - $apiUserName, - 'apiUserPassword' => $apiUserPassword, - 'apiClientId' => $clientId ?? $this->http->clientId, - 'apiSecretKey' => $clientSecret ?? $this->http->clientSecret, - 'loginMode' => $loginMode, - ]; - if ($withPhoneNumber !== null) { - $body['withPhoneNumber'] = $withPhoneNumber; - } - - return $this->finishLogin($this->http->request('POST', '/general/connectApi', 'public', $body)); - } - - /** Complete a 2FA login with the SMS code and the challenge blob. */ - public function connectWithTwoFactor(string $smsVerificationCode, string $response): void - { - $data = $this->http->request('POST', '/general/connectApiWithTwoFactor', 'public', [ - 'smsVerificationCode' => $smsVerificationCode, - 'response' => $response, - ]); - $this->storeTokens($data); - } - - /** - * Step 1 of registration: send the SMS/e-mail verification code and return the - * encrypted `response` blob. Uses the configured **partner** token (the endpoint - * is behind `auth:apiusers`, not public). A CAPTCHA token (`$recaptchaV2` or - * `$captcha`), minted by a browser/human, is required by the server. Feed the - * returned `response` (and the code the user receives) into {@see register()}. - * - * @param list|null $userAgreements - * @return array{response: string, confirmationType: string}|array - */ - public function verifyRegistration( - string $name, - string $surname, - string $phoneNumber, - string $phoneCode, - string $email, - string $password, - int $acceptUserAgreement = 1, - ?string $recaptchaV2 = null, - ?string $captcha = null, - ?array $userAgreements = null, - ): array { - $body = [ - 'name' => $name, - 'surname' => $surname, - 'phoneNumber' => $phoneNumber, - 'phone_code' => $phoneCode, - 'email' => $email, - 'password' => $password, - 'passwordAgain' => $password, - 'acceptUserAgreement' => $acceptUserAgreement, - ]; - if ($recaptchaV2 !== null) { - $body['g-recaptcha-response-v2'] = $recaptchaV2; - } - if ($captcha !== null) { - $body['captcha'] = $captcha; - } - if ($userAgreements !== null) { - $body['userAgreements'] = $userAgreements; - } - - return $this->http->request('POST', '/patients/verifyAddingNewPatient', 'partner', $body); - } - - /** - * Step 2 of e-mail-branch registration. When {@see verifyRegistration()} returns - * confirmationType "email", confirm the e-mailed code here with the same - * `response` blob. The server verifies it, sends an SMS code, and returns a fresh - * `response` blob — feed that + the SMS code into {@see register()}. Public. - * - * @param list|null $userAgreements - * @return array{response: string, confirmationType: string}|array - */ - public function confirmRegistrationEmail( - string $verificationCode, - string $response, - ?array $userAgreements = null, - ): array { - $body = [ - 'verificationCode' => $verificationCode, - 'response' => $response, - ]; - if ($userAgreements !== null) { - $body['userAgreements'] = $userAgreements; - } - - return $this->http->request('POST', '/patients/emailConfirmationRegister', 'public', $body); - } - - /** Register a new patient (afterRegister auto-login). Stores tokens on success. */ - public function register( - string $name, - string $surname, - string $apiUserName, - string $phoneNumber, - string $password, - string $smsVerificationCode, - string $response, - int $acceptUserAgreement = 1, - ?string $clientId = null, - ?string $clientSecret = null, - ): void { - $data = $this->http->request('POST', '/patients/addNewPatient', 'public', [ - 'name' => $name, - 'surname' => $surname, - 'apiUserName' => $apiUserName, - 'phoneNumber' => $phoneNumber, - 'password' => $password, - 'smsVerificationCode' => $smsVerificationCode, - 'response' => $response, - 'acceptUserAgreement' => $acceptUserAgreement, - 'apiClientId' => $clientId ?? $this->http->clientId, - 'apiSecretKey' => $clientSecret ?? $this->http->clientSecret, - ]); - $this->storeTokens($data); - } - - /** - * Step 1 of social sign-up. Sends the SMS code and returns a `response` blob. - * Public — no CAPTCHA and no partner token. Feed `response` + the SMS code into - * {@see registerSocial()}. - * - * @param list|null $userAgreements - * @return array{response: string}|array - */ - public function verifyRegistrationSocial( - string $name, - string $surname, - string $phoneNumber, - string $password, - string $socialType, - string $key, - ?string $email = null, - int $acceptUserAgreement = 1, - ?array $userAgreements = null, - ): array { - $body = [ - 'name' => $name, - 'surname' => $surname, - 'phoneNumber' => $phoneNumber, - 'password' => $password, - 'passwordAgain' => $password, - 'socialType' => $socialType, - 'key' => $key, - 'acceptUserAgreement' => $acceptUserAgreement, - ]; - if ($email !== null) { - $body['email'] = $email; - } - if ($userAgreements !== null) { - $body['userAgreements'] = $userAgreements; - } - - return $this->http->request('POST', '/patients/verifyAddingNewPatientSocial', 'public', $body); - } - - /** - * Step 2 of social sign-up: create the social patient. Does NOT auto-login; - * call {@see connect()} with loginMode "social" afterwards. Public. - * - * @param list|null $userAgreements - */ - public function registerSocial( - string $smsVerificationCode, - string $response, - ?array $userAgreements = null, - ): void { - $body = [ - 'smsVerificationCode' => $smsVerificationCode, - 'response' => $response, - ]; - if ($userAgreements !== null) { - $body['userAgreements'] = $userAgreements; - } - - $this->http->request('POST', '/patients/addNewPatientWithSocial', 'public', $body); - } - - /** - * Step 1 of password reset: send the SMS confirm code to a registered phone and - * return a `response` blob. A CAPTCHA token (`$recaptchaV2` or `$captcha`) is - * required outside the local environment. Feed `response` + the SMS code into - * {@see resetPassword()}. - * - * @return array{response: string}|array - */ - public function forgotPassword( - string $phoneNumber, - ?string $birthdate = null, - ?string $recaptchaV2 = null, - ?string $captcha = null, - ): array { - $body = ['phoneNumber' => $phoneNumber]; - if ($birthdate !== null) { - $body['birthdate'] = $birthdate; - } - if ($recaptchaV2 !== null) { - $body['g-recaptcha-response-v2'] = $recaptchaV2; - } - if ($captcha !== null) { - $body['captcha'] = $captcha; - } - - return $this->http->request('POST', '/patients/forgotPassword', 'public', $body); - } - - /** - * Step 2 of password reset: set the new password using the SMS confirm code and - * the `response` blob from {@see forgotPassword()}. Public. - */ - public function resetPassword( - string $smsConfirmCode, - string $response, - string $password, - ): void { - $this->http->request('PUT', '/patients/forgotPassword', 'public', [ - 'smsConfirmCode' => $smsConfirmCode, - 'response' => $response, - 'password' => $password, - 'passwordAgain' => $password, - ]); - } - - /** Manually refresh the access token using the stored refresh token. */ - public function refresh(): void - { - $this->http->refresh(); - } - - /** Revoke the current tokens server-side and clear the local token store. */ - public function disconnect(): void - { - try { - $this->http->request('POST', '/general/disconnectApi', 'bearer', []); - } finally { - $this->http->tokenStore->clear(); - } - } - - private function finishLogin(mixed $data): LoginResult - { - if (\is_array($data) && isset($data['access_token']) && \is_string($data['access_token'])) { - $this->storeTokens($data); - - return new LoginResult(false); - } - if (\is_array($data) && isset($data['response']) && \is_string($data['response'])) { - return new LoginResult(true, $data['response']); - } - - return new LoginResult(false); - } - - private function storeTokens(mixed $data): void - { - if (!\is_array($data) || !isset($data['access_token']) || !\is_string($data['access_token'])) { - throw new BulutklinikException('Login response did not contain an access token'); - } - $refresh = isset($data['refresh_token']) && \is_string($data['refresh_token']) ? $data['refresh_token'] : null; - $this->http->tokenStore->setTokens($data['access_token'], $refresh); - } -} diff --git a/src/Resource/DietsResource.php b/src/Resource/DietsResource.php index eeb022b..1a3ba52 100644 --- a/src/Resource/DietsResource.php +++ b/src/Resource/DietsResource.php @@ -4,25 +4,39 @@ namespace Bulutklinik\Sdk\Resource; -/** Diet lists — the patient's dietitian-written "Diyet Listesi" (JSON only, no PDF export). */ +/** + * Diet lists recorded for a patient **inside your own company**. + * Lists written by other clinics are not visible here. + * + * `$patient` shape (read form): `['identityNumber' => ?string, 'phoneNumber' => ?string]`. + * `identityNumber` is primary; `phoneNumber` is accepted only when it matches + * exactly one patient. + */ final class DietsResource extends AbstractResource { /** - * The patient's diet lists (paginated). `$page` defaults to 1 server-side - * when omitted; page size is fixed to 10. + * Paginated diet lists — `['foundDietsCount' => int, 'foundDiets' => array]`. + * + * @param array $patient */ - public function list(int|string|null $page = null): mixed + public function list(array $patient, int|string|null $page = null): mixed { - $path = $page !== null - ? "/patients/dietLists/{$page}" - : '/patients/dietLists'; - - return $this->http->request('GET', $path, 'bearer'); + return $this->http->request('POST', '/outher/dietLists', 'partner', [ + 'patient' => $patient, + 'currentPage' => $page, + ]); } - /** One diet list's detail (an array of meal-time groups). `$listId` = a `list_id` from a `list` item. */ - public function detail(int|string $listId): mixed + /** + * Meal breakdown of one diet list. `$listId` comes from `list()`. + * + * @param array $patient + */ + public function detail(array $patient, int|string $listId): mixed { - return $this->http->request('GET', "/patients/diet/{$listId}", 'bearer'); + return $this->http->request('POST', '/outher/diet', 'partner', [ + 'patient' => $patient, + 'listId' => $listId, + ]); } } diff --git a/src/Resource/DoctorsResource.php b/src/Resource/DoctorsResource.php index cb4c9dc..e643dd3 100644 --- a/src/Resource/DoctorsResource.php +++ b/src/Resource/DoctorsResource.php @@ -4,61 +4,57 @@ namespace Bulutklinik\Sdk\Resource; -/** Branches, locations, quick/filtered doctor search and doctor detail. */ +/** + * Doctor discovery. + * + * Results are scoped to the doctors enabled for your integration (the server + * filters on your partner slug), so a doctor returned here is one you can + * actually book. + */ final class DoctorsResource extends AbstractResource { - /** @return array */ - public function branches(): array + /** + * Filtered doctor search. + * + * `$searchParams` 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. + * + * @param array $searchParams non-empty + * @param list $orderParams any of `name`, `order`, `slot` + */ + public function search(array $searchParams, int $currentPage = 1, array $orderParams = []): mixed { - return $this->asArray($this->http->request('GET', '/patients/allBranches', 'bearer')); + return $this->http->request('POST', '/outher/search', 'partner', [ + 'searchParams' => $searchParams, + 'orderParams' => $orderParams, + 'currentPage' => $currentPage, + ]); } - /** @return array */ - public function locations(): array + /** + * Branches available through your integration. + * + * @return array + */ + public function branches(): array { - return $this->asArray($this->http->request('GET', '/patients/allLocations', 'bearer')); + return $this->asArray($this->http->request('GET', '/outher/branches', 'partner')); } - /** @return array */ - public function quickSearch(string $searchText, ?string $listType = null, ?string $location = null): array + /** Detail of a single doctor. */ + public function detail(int|string $doctorId): mixed { - return $this->asArray($this->http->request('POST', '/patients/quickSearch', 'bearer', [ - 'searchText' => $searchText, - 'listType' => $listType, - 'location' => $location, - ])); + return $this->http->request('GET', "/outher/doctorInfos/{$doctorId}", 'partner'); } /** - * @param array $searchParams - * @param list $orderParams - * @param list $otherParams + * City list. Global catalogue — not scoped to your company. * * @return array */ - public function search( - array $searchParams = [], - array $orderParams = [], - array $otherParams = [], - int $currentPage = 1, - int $perPageLimit = 20, - ): array { - return $this->asArray($this->http->request('POST', '/patients/filteredSearch', 'bearer', [ - 'searchParams' => $searchParams, - 'orderParams' => $orderParams, - 'otherParams' => $otherParams, - 'currentPage' => $currentPage, - 'perPageLimit' => $perPageLimit, - ])); - } - - /** @return array */ - public function detail(int|string $id, int|string|null $corporate = null): array + public function locations(): array { - $path = $corporate !== null - ? "/patients/doctorDetail/{$id}/{$corporate}" - : "/patients/doctorDetail/{$id}"; - - return $this->asArray($this->http->request('GET', $path, 'bearer')); + return $this->asArray($this->http->request('GET', '/outher/locations', 'partner')); } } diff --git a/src/Resource/LaboratoryResource.php b/src/Resource/LaboratoryResource.php index e65df7d..51f4973 100644 --- a/src/Resource/LaboratoryResource.php +++ b/src/Resource/LaboratoryResource.php @@ -4,51 +4,56 @@ namespace Bulutklinik\Sdk\Resource; -/** Laboratory: the patient's own results, the orderable test catalog, and test pre-ordering. */ +/** + * Laboratory catalogue and results. + * + * `catalog()` / `catalogDetail()` are global, static package definitions. + * `results()` / `resultDetail()` are scoped to your own company and merge two + * sources: the clinic's HBYS lab requests and TmcLab order groups. Results + * recorded by other clinics are not visible. + */ final class LaboratoryResource extends AbstractResource { - /** - * The patient's completed/in-progress lab results (paginated). `$page` - * defaults to 1 server-side when omitted. - */ - public function results(int|string|null $page = null): mixed + /** Orderable test packages. Static catalogue, no patient context. */ + public function catalog(): mixed { - $path = $page !== null - ? "/patients/userLabTestList/{$page}" - : '/patients/userLabTestList'; - - return $this->http->request('GET', $path, 'bearer'); + return $this->http->request('GET', '/outher/laboratoryCatalog', 'partner'); } /** - * One result's detail. `$testId` is a **string**: a plain id (`"123"`) or a - * TMC-lab id with a `-lab` suffix (`"123-lab"`) — pass it verbatim from a - * `results` item. + * One catalogue package. Prices are the plain list prices — the patient-side + * discount pass does not apply here. */ - public function resultDetail(string $testId): mixed + public function catalogDetail(int|string $testId): mixed { - return $this->http->request('GET', "/patients/userLabTestDetail/{$testId}", 'bearer'); + return $this->http->request('GET', "/outher/laboratoryCatalog/{$testId}", 'partner'); } - /** The orderable test-group catalog. */ - public function catalog(): mixed - { - return $this->http->request('GET', '/patients/allLaboratoryTests', 'bearer'); - } - - /** One catalog group by id. */ - public function catalogDetail(int|string $id): mixed + /** + * Paginated results — `['foundTestsCount' => int, 'foundTests' => array]`. + * Each item's `id` is accepted verbatim by `resultDetail()` (a `-lab` suffix + * marks a TmcLab group). + * + * @param array $patient + */ + public function results(array $patient, int|string|null $page = null): mixed { - return $this->http->request('GET', "/patients/laboratoryTestDetail/{$id}", 'bearer'); + return $this->http->request('POST', '/outher/laboratoryResults', 'partner', [ + 'patient' => $patient, + 'currentPage' => $page, + ]); } - /** Pre-order a lab test. Success → `data: { preOrderId }`. */ - public function order(int|string $testId, int|string $addressId, int|string $laboratoryId): mixed + /** + * One result. Pass the `id` from `results()` unchanged. + * + * @param array $patient + */ + public function resultDetail(array $patient, int|string $testId): mixed { - return $this->http->request('POST', '/patients/addNewLaboratoryTest', 'bearer', [ - 'testId' => $testId, - 'addressId' => $addressId, - 'laboratoryId' => $laboratoryId, + return $this->http->request('POST', '/outher/laboratoryResult', 'partner', [ + 'patient' => $patient, + 'testId' => (string) $testId, ]); } } diff --git a/src/Resource/MealsResource.php b/src/Resource/MealsResource.php deleted file mode 100644 index f11b002..0000000 --- a/src/Resource/MealsResource.php +++ /dev/null @@ -1,41 +0,0 @@ - $image, - 'portion_size' => $portionSize, - 'meal_type' => $mealType, - ]; - if ($portionGrams !== null) { - $body['portion_grams'] = $portionGrams; - } - if ($note !== null) { - $body['note'] = $note; - } - - return $this->http->request('POST', '/patients/imageAnalyzeMeal', 'bearer', $body); - } -} diff --git a/src/Resource/MeasuresResource.php b/src/Resource/MeasuresResource.php index 1e33393..79a4763 100644 --- a/src/Resource/MeasuresResource.php +++ b/src/Resource/MeasuresResource.php @@ -4,78 +4,123 @@ namespace Bulutklinik\Sdk\Resource; -/** Health measurements: CRUD, latest, history list, graph and partner submission. */ +/** + * Health measurements. + * + * **Scope:** measurements are written into and read from **your own company**. + * Values the patient entered in the Bulutklinik mobile app live in the consumer + * tenant and are *not* visible here — that is a consequence of tenant isolation, + * not a bug. + * + * Writes take the full patient shape (`name`, `surname`, `phoneNumber` required; + * the patient is created inside your company if absent); reads and edits take the + * lighter reference shape (`identityNumber` or `phoneNumber`). + */ final class MeasuresResource extends AbstractResource { /** - * Submit multiple measurements of any types in one call (primary entrypoint). + * Most recent value of every measurement type. * - * @param list> $records + * @param array $patient */ - public function addList(array $records): mixed + public function last(array $patient): mixed { - return $this->http->request('POST', '/patients/addNewUserMeasures', 'bearer', ['data' => $records]); + return $this->http->request('POST', '/outher/lastMeasures', 'partner', ['patient' => $patient]); } /** - * Submit a single measurement of one type. + * Paginated history of one type. * - * @param array $fields date_time + the type's own fields + * @param array $patient */ - public function add(string $type, array $fields): mixed + public function list(array $patient, string $type, int|string|null $page = null, ?int $glucoseType = null): mixed { - return $this->http->request('POST', "/patients/addNewUserMeasures/{$type}", 'bearer', $fields); + return $this->http->request('POST', "/outher/measuresList/{$type}", 'partner', [ + 'patient' => $patient, + 'currentPage' => $page, + 'glucoseType' => $glucoseType, + ]); } /** - * @param array $input id + date_time + the type's own fields + * Time-bucketed series. `$period`: 1=day, 2=week, 3=month, 4=year. + * + * @param array $patient */ - public function update(string $type, array $input): mixed - { - return $this->http->request('PUT', "/patients/updateUserMeasures/{$type}", 'bearer', $input); + public function graph( + array $patient, + string $type, + int $period, + int|string|null $page = null, + ?int $glucoseType = null, + ): mixed { + return $this->http->request('POST', "/outher/measuresGraph/{$type}/{$period}", 'partner', [ + 'patient' => $patient, + 'currentPage' => $page, + 'glucoseType' => $glucoseType, + ]); } - public function delete(string $type, int|string $id): mixed + /** + * Write several measurements of mixed types in one transaction. Max 200 rows. + * + * @param array $patient + * @param list> $data each row needs `type` plus that type's own fields + */ + public function addList(array $patient, array $data): mixed { - return $this->http->request('DELETE', "/patients/deleteUserMeasures/{$type}", 'bearer', ['id' => $id]); + return $this->http->request('POST', '/outher/measures', 'partner', [ + 'patient' => $patient, + 'data' => $data, + ]); } /** - * Latest value of each measurement type. + * Write a single measurement. * - * @return array + * @param array $patient + * @param array $fields `date_time` plus the type's own fields */ - public function last(): array + public function add(array $patient, string $type, array $fields): mixed { - return $this->asArray($this->http->request('GET', '/patients/measuresList', 'bearer')); + return $this->http->request('POST', "/outher/measure/{$type}", 'partner', ['patient' => $patient] + $fields); } - /** Paginated history for one type. `glucoseType` (0/1) applies only to glucose. */ - public function list(string $type, int|string $page, ?int $glucoseType = null): mixed + /** + * Update one measurement row. `$id` comes from `list()`. + * + * @param array $patient + * @param array $fields + */ + public function update(array $patient, string $type, int|string $id, array $fields): mixed { - $path = $glucoseType !== null - ? "/patients/userMeasuresList/{$type}/{$page}/{$glucoseType}" - : "/patients/userMeasuresList/{$type}/{$page}"; - - return $this->http->request('GET', $path, 'bearer'); + return $this->http->request('PUT', "/outher/measure/{$type}", 'partner', ['patient' => $patient, 'id' => $id] + $fields); } - /** Grouped graph data. `period`: 1=day, 2=week, 3=month, 4=year. */ - public function graph(string $type, int $period, int|string $page, ?int $glucoseType = null): mixed + /** + * Delete one measurement row. + * + * @param array $patient + */ + public function delete(array $patient, string $type, int|string $id): mixed { - $path = $glucoseType !== null - ? "/patients/userMeasuresGraph/{$type}/{$period}/{$page}/{$glucoseType}" - : "/patients/userMeasuresGraph/{$type}/{$period}/{$page}"; - - return $this->http->request('GET', $path, 'bearer'); + return $this->http->request('DELETE', "/outher/measure/{$type}", 'partner', [ + 'patient' => $patient, + 'id' => $id, + ]); } /** - * Partner (teusan) submission — uses the configured partner token. + * Legacy bulk submission for `teusan` integrations. + * + * @deprecated Requires the `teusan` scope instead of `apiouther`, takes a flat + * `identity` + `phoneNumber` instead of `patient`, and writes into the shared + * consumer tenant rather than your own company — so the values are not + * readable through `last()` / `list()`. Prefer `addList()`. * * @param list> $data */ - public function partnerHealthInformation(?string $identity, ?string $phoneNumber, array $data): mixed + public function healthInformation(?string $identity, ?string $phoneNumber, array $data): mixed { return $this->http->request('POST', '/outher/healthInformation', 'partner', [ 'identity' => $identity, diff --git a/src/Resource/PaymentsResource.php b/src/Resource/PaymentsResource.php deleted file mode 100644 index c5ceb16..0000000 --- a/src/Resource/PaymentsResource.php +++ /dev/null @@ -1,109 +0,0 @@ - - */ - public function checkDiscountCode( - string $checkType, - string $discountCode, - int|string|null $doctorId = null, - int|string|null $orderId = null, - int|string|null $specialServiceId = null, - ?string $programSlug = null, - ): array { - $body = ['checkType' => $checkType, 'discountCode' => $discountCode]; - if ($doctorId !== null) { - $body['doctorId'] = $doctorId; - } - if ($orderId !== null) { - $body['orderId'] = $orderId; - } - if ($specialServiceId !== null) { - $body['specialServiceId'] = $specialServiceId; - } - if ($programSlug !== null) { - $body['programSlug'] = $programSlug; - } - - return $this->asArray($this->http->request('POST', '/patients/checkDiscountCode', 'bearer', $body)); - } - - /** @return array */ - public function getCards(): array - { - return $this->asArray($this->http->request('GET', '/payments/getCards', 'bearer')); - } - - public function saveCard( - string $cardHolder, - string $cardNumber, - string $cardExpMonth, - string $cardExpYear, - string $cardCvv, - ): mixed { - return $this->http->request('POST', '/payments/saveCard', 'bearer', [ - 'cardHolder' => $cardHolder, - 'cardNumber' => $cardNumber, - 'cardExpMonth' => $cardExpMonth, - 'cardExpYear' => $cardExpYear, - 'cardCvv' => $cardCvv, - ]); - } - - /** - * Start an appointment payment. The amount is computed server-side. On a 3DS - * flow the result carries `payment3DUrl` — a browser URL to open; the SDK does - * not follow it. - * - * @param array{cardHolder: string, cardNumber: string, cardExpMonth: string, cardExpYear: string, cardCvv: string}|null $cardInfo - * - * @return array - */ - public function pay( - int|string $doctorId, - string $appointmentDate, - bool $is3D, - bool $termsAccept, - string $appointmentType = 'interview', - ?array $cardInfo = null, - int|string|null $cardId = null, - int $saveCard = 0, - string $discountCode = '', - ?string $caseDetail = null, - ): array { - $body = [ - 'doctorId' => $doctorId, - 'appointmentDate' => $appointmentDate, - 'appointmentType' => $appointmentType, - 'is3D' => $is3D, - 'termsAccept' => $termsAccept, - 'saveCard' => $saveCard, - 'discountCode' => $discountCode, - ]; - if ($cardId !== null) { - $body['cardId'] = $cardId; - } - if ($cardInfo !== null) { - $body['cardInfo'] = $cardInfo; - } - if ($caseDetail !== null) { - $body['caseDetail'] = $caseDetail; - } - - return $this->asArray($this->http->request('POST', '/payments/interviewPayment', 'bearer', $body)); - } - - public function deleteCard(int|string $cardId): mixed - { - return $this->http->request('DELETE', "/payments/deleteCard/{$cardId}", 'bearer'); - } -} diff --git a/src/Resource/SkinResource.php b/src/Resource/SkinResource.php deleted file mode 100644 index 6674e1a..0000000 --- a/src/Resource/SkinResource.php +++ /dev/null @@ -1,22 +0,0 @@ - $images base64 image + optional branch id - */ - public function analyze(array $images): mixed - { - return $this->http->request('POST', '/patients/imageCheck', 'bearer', ['images' => $images]); - } -} diff --git a/src/Resource/SlotsResource.php b/src/Resource/SlotsResource.php index 1b0139c..48569bf 100644 --- a/src/Resource/SlotsResource.php +++ b/src/Resource/SlotsResource.php @@ -4,28 +4,27 @@ namespace Bulutklinik\Sdk\Resource; -/** Doctor availability (materialized slots). */ +/** Doctor availability. */ final class SlotsResource extends AbstractResource { /** - * Fetch a doctor's free slots — returns a date-keyed map of slots. Build the - * next step's `appointmentDate` as `" "` (drop the seconds). + * Bookable slots for a doctor. Either pass `$scheduleDate`, or page through + * with `$scheduleStep` + `$schedulePage`; the server requires one of the two + * forms. * - * @return array + * @param string|null $scheduleDate `Y-m-d` */ public function schedule( int|string $doctorId, - string $listType, ?string $scheduleDate = null, - int|string $scheduleStep = 7, - int|string $schedulePage = 1, - ): array { - return $this->asArray($this->http->request('POST', '/patients/doctorScheduler', 'bearer', [ + ?int $scheduleStep = null, + ?int $schedulePage = null, + ): mixed { + return $this->http->request('POST', '/outher/doctorSlots', 'partner', [ 'doctorId' => $doctorId, 'scheduleDate' => $scheduleDate, 'scheduleStep' => $scheduleStep, 'schedulePage' => $schedulePage, - 'listType' => $listType, - ])); + ]); } } diff --git a/src/Token/InMemoryTokenStore.php b/src/Token/InMemoryTokenStore.php index f4c226c..8bf22c0 100644 --- a/src/Token/InMemoryTokenStore.php +++ b/src/Token/InMemoryTokenStore.php @@ -4,34 +4,25 @@ namespace Bulutklinik\Sdk\Token; -/** In-memory token store (default). Tokens live for the lifetime of the object. */ +/** In-memory token store (default). The token lives for the lifetime of the object. */ final class InMemoryTokenStore implements TokenStore { - public function __construct( - private ?string $accessToken = null, - private ?string $refreshToken = null, - ) { - } - - public function getAccessToken(): ?string + public function __construct(private ?string $token = null) { - return $this->accessToken; } - public function getRefreshToken(): ?string + public function getToken(): ?string { - return $this->refreshToken; + return $this->token; } - public function setTokens(string $accessToken, ?string $refreshToken): void + public function setToken(?string $token): void { - $this->accessToken = $accessToken; - $this->refreshToken = $refreshToken; + $this->token = $token; } public function clear(): void { - $this->accessToken = null; - $this->refreshToken = null; + $this->token = null; } } diff --git a/src/Token/TokenStore.php b/src/Token/TokenStore.php index feed493..393c98f 100644 --- a/src/Token/TokenStore.php +++ b/src/Token/TokenStore.php @@ -5,16 +5,17 @@ namespace Bulutklinik\Sdk\Token; /** - * Pluggable token persistence. The default is in-memory; provide a custom - * implementation to persist tokens to a file, cache, database or session. + * 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. */ interface TokenStore { - public function getAccessToken(): ?string; + public function getToken(): ?string; - public function getRefreshToken(): ?string; - - public function setTokens(string $accessToken, ?string $refreshToken): void; + public function setToken(?string $token): void; public function clear(): void; } diff --git a/tests/AiTest.php b/tests/AiTest.php deleted file mode 100644 index dce27a9..0000000 --- a/tests/AiTest.php +++ /dev/null @@ -1,64 +0,0 @@ -analyze posts images to /patients/imageCheck with a bearer token', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['status' => [ - ['id' => 1, 'label' => 'nevus', 'comment' => '…', 'case_detail' => 'blob'], - ]]]), - new InMemoryTokenStore('abc'), - ); - - $res = $client->skin->analyze([['image' => 'BASE64', 'branch_id' => 42]]); - - expect($res['status'][0]['label'])->toBe('nevus'); - $req = $mock->requests[0]; - expect((string) $req->getUri())->toBe('https://apitest.bulutklinik.com/api/v3/patients/imageCheck'); - expect($req->getMethod())->toBe('POST'); - expect($req->getHeaderLine('Authorization'))->toBe('Bearer abc'); - expect(json_decode((string) $req->getBody(), true)) - ->toBe(['images' => [['image' => 'BASE64', 'branch_id' => 42]]]); -}); - -it('meals->analyze maps parameters to the snake_case body', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['status' => ['comment' => '{}']]]), - new InMemoryTokenStore('abc'), - ); - - $client->meals->analyze( - image: 'BASE64', - portionSize: 'custom', - mealType: 'lunch', - portionGrams: 300, - note: 'az yağlı', - ); - - $req = $mock->requests[0]; - expect((string) $req->getUri())->toBe('https://apitest.bulutklinik.com/api/v3/patients/imageAnalyzeMeal'); - expect(json_decode((string) $req->getBody(), true))->toBe([ - 'image' => 'BASE64', - 'portion_size' => 'custom', - 'meal_type' => 'lunch', - 'portion_grams' => 300, - 'note' => 'az yağlı', - ]); -}); - -it('meals->analyze omits optional fields when not provided', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['status' => ['comment' => '{}']]]), - new InMemoryTokenStore('abc'), - ); - - $client->meals->analyze(image: 'BASE64', portionSize: 'medium', mealType: 'snack'); - - expect(json_decode((string) $mock->requests[0]->getBody(), true))->toBe([ - 'image' => 'BASE64', - 'portion_size' => 'medium', - 'meal_type' => 'snack', - ]); -}); diff --git a/tests/AuthTest.php b/tests/AuthTest.php deleted file mode 100644 index 3ea3439..0000000 --- a/tests/AuthTest.php +++ /dev/null @@ -1,55 +0,0 @@ - jsonResponse(['resultType' => 0, 'data' => ['access_token' => 't', 'refresh_token' => 'r', 'password_policy' => []]]), - $store, - 'c', - 's', - ); - - $result = $client->auth->connect('u', 'p', 'email'); - - expect($result->twoFactorRequired)->toBeFalse(); - expect($store->getAccessToken())->toBe('t'); - expect($store->getRefreshToken())->toBe('r'); - - $body = json_decode((string) $mock->requests[0]->getBody(), true); - expect($body['apiClientId'])->toBe('c'); - expect($body['apiSecretKey'])->toBe('s'); - expect($body['loginMode'])->toBe('email'); -}); - -it('connect surfaces a 2FA challenge instead of throwing', function () { - [$client] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['response' => 'BLOB']]), - new InMemoryTokenStore(), - 'c', - 's', - ); - - $result = $client->auth->connect('u', 'p', 'email'); - - expect($result->twoFactorRequired)->toBeTrue(); - expect($result->twoFactorResponse)->toBe('BLOB'); -}); - -it('disconnect clears the store even when the request fails', function () { - $store = new InMemoryTokenStore('a', 'r'); - [$client] = makeClient(fn () => jsonResponse(['resultType' => 1, 'errorMessage' => 'fail'], 500), $store); - - $threw = false; - try { - $client->auth->disconnect(); - } catch (\Throwable) { - $threw = true; - } - - expect($threw)->toBeTrue(); - expect($store->getAccessToken())->toBeNull(); -}); diff --git a/tests/DietsTest.php b/tests/DietsTest.php deleted file mode 100644 index 243c45a..0000000 --- a/tests/DietsTest.php +++ /dev/null @@ -1,46 +0,0 @@ -list omits the page segment when page is null', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['foundDietsCount' => 0, 'foundDiets' => []]]), - new InMemoryTokenStore('abc'), - ); - - $res = $client->diets->list(); - - expect($res)->toBe(['foundDietsCount' => 0, 'foundDiets' => []]); - $req = $mock->requests[0]; - expect((string) $req->getUri())->toBe('https://apitest.bulutklinik.com/api/v3/patients/dietLists'); - expect($req->getMethod())->toBe('GET'); - expect($req->getHeaderLine('Authorization'))->toBe('Bearer abc'); -}); - -it('diets->list appends the page segment when provided', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['foundDietsCount' => 0, 'foundDiets' => []]]), - new InMemoryTokenStore('abc'), - ); - - $client->diets->list(2); - - expect((string) $mock->requests[0]->getUri()) - ->toBe('https://apitest.bulutklinik.com/api/v3/patients/dietLists/2'); -}); - -it('diets->detail GETs /patients/diet/{listId} with a bearer token', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => [['time' => '08:00', 'meals' => []]]]), - new InMemoryTokenStore('abc'), - ); - - $client->diets->detail(42); - - $req = $mock->requests[0]; - expect((string) $req->getUri())->toBe('https://apitest.bulutklinik.com/api/v3/patients/diet/42'); - expect($req->getMethod())->toBe('GET'); - expect($req->getHeaderLine('Authorization'))->toBe('Bearer abc'); -}); diff --git a/tests/LaboratoryTest.php b/tests/LaboratoryTest.php deleted file mode 100644 index 0679b5e..0000000 --- a/tests/LaboratoryTest.php +++ /dev/null @@ -1,91 +0,0 @@ -results omits the page segment when page is null', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['foundTestsCount' => 0, 'foundTests' => []]]), - new InMemoryTokenStore('abc'), - ); - - $res = $client->laboratory->results(); - - expect($res)->toBe(['foundTestsCount' => 0, 'foundTests' => []]); - $req = $mock->requests[0]; - expect((string) $req->getUri())->toBe('https://apitest.bulutklinik.com/api/v3/patients/userLabTestList'); - expect($req->getMethod())->toBe('GET'); - expect($req->getHeaderLine('Authorization'))->toBe('Bearer abc'); -}); - -it('laboratory->results appends the page segment when provided', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['foundTestsCount' => 0, 'foundTests' => []]]), - new InMemoryTokenStore('abc'), - ); - - $client->laboratory->results(3); - - expect((string) $mock->requests[0]->getUri()) - ->toBe('https://apitest.bulutklinik.com/api/v3/patients/userLabTestList/3'); -}); - -it('laboratory->resultDetail interpolates a string testId verbatim (plain and -lab)', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['id' => '123', 'test_name' => 'Hemogram']]), - new InMemoryTokenStore('abc'), - ); - - $client->laboratory->resultDetail('123'); - $client->laboratory->resultDetail('4821-lab'); - - expect((string) $mock->requests[0]->getUri()) - ->toBe('https://apitest.bulutklinik.com/api/v3/patients/userLabTestDetail/123'); - expect($mock->requests[0]->getMethod())->toBe('GET'); - expect((string) $mock->requests[1]->getUri()) - ->toBe('https://apitest.bulutklinik.com/api/v3/patients/userLabTestDetail/4821-lab'); -}); - -it('laboratory->catalog GETs /patients/allLaboratoryTests with a bearer token', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['test_groups' => []]]), - new InMemoryTokenStore('abc'), - ); - - $client->laboratory->catalog(); - - $req = $mock->requests[0]; - expect((string) $req->getUri())->toBe('https://apitest.bulutklinik.com/api/v3/patients/allLaboratoryTests'); - expect($req->getMethod())->toBe('GET'); - expect($req->getHeaderLine('Authorization'))->toBe('Bearer abc'); -}); - -it('laboratory->catalogDetail GETs one group by id', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['id' => 7, 'name' => 'Grup']]), - new InMemoryTokenStore('abc'), - ); - - $client->laboratory->catalogDetail(7); - - expect((string) $mock->requests[0]->getUri()) - ->toBe('https://apitest.bulutklinik.com/api/v3/patients/laboratoryTestDetail/7'); -}); - -it('laboratory->order POSTs the required body to /patients/addNewLaboratoryTest', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['preOrderId' => 55]]), - new InMemoryTokenStore('abc'), - ); - - $res = $client->laboratory->order(101, 202, 303); - - expect($res)->toBe(['preOrderId' => 55]); - $req = $mock->requests[0]; - expect((string) $req->getUri())->toBe('https://apitest.bulutklinik.com/api/v3/patients/addNewLaboratoryTest'); - expect($req->getMethod())->toBe('POST'); - expect($req->getHeaderLine('Authorization'))->toBe('Bearer abc'); - expect(json_decode((string) $req->getBody(), true)) - ->toBe(['testId' => 101, 'addressId' => 202, 'laboratoryId' => 303]); -}); diff --git a/tests/Pest.php b/tests/Pest.php index d37b442..fdc7dad 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Bulutklinik\Sdk\ApiVersion; use Bulutklinik\Sdk\BulutklinikClient; use Bulutklinik\Sdk\ClientConfig; use Bulutklinik\Sdk\Environment; @@ -11,9 +12,13 @@ use GuzzleHttp\Psr7\Response; use Psr\Http\Message\RequestInterface; +const TEST_BASE = 'https://apitest.bulutklinik.com/api/v3'; + /** * Build a client wired to a mock PSR-18 client (test environment). * + * Pass a `$store` to control the credential; otherwise it is seeded with `PT`. + * * @param callable(RequestInterface): Response $handler * * @return array{0: BulutklinikClient, 1: MockClient, 2: InMemoryTokenStore} @@ -21,19 +26,15 @@ function makeClient( callable $handler, ?InMemoryTokenStore $store = null, - ?string $clientId = null, - ?string $clientSecret = null, - ?string $partnerToken = null, + ApiVersion $apiVersion = ApiVersion::V3, ): array { $mock = new MockClient($handler); $factory = new HttpFactory(); - $store ??= new InMemoryTokenStore(); + $store ??= new InMemoryTokenStore('PT'); $client = new BulutklinikClient(new ClientConfig( environment: Environment::Test, - clientId: $clientId, - clientSecret: $clientSecret, - partnerToken: $partnerToken, + apiVersion: $apiVersion, tokenStore: $store, httpClient: $mock, requestFactory: $factory, @@ -55,3 +56,9 @@ function jsonResponse(array $body, int $status = 200, array $headers = []): Resp json_encode($body, JSON_THROW_ON_ERROR), ); } + +/** Decode a recorded request body. */ +function bodyOf(RequestInterface $request): mixed +{ + return json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR); +} diff --git a/tests/ResourcesTest.php b/tests/ResourcesTest.php new file mode 100644 index 0000000..b93a0c5 --- /dev/null +++ b/tests/ResourcesTest.php @@ -0,0 +1,173 @@ + '12345678901']; +const WRITE_PATIENT = ['name' => 'Ada', 'surname' => 'Lovelace', 'phoneNumber' => '+905551112233']; + +/** @return array{0: \Bulutklinik\Sdk\BulutklinikClient, 1: \Bulutklinik\Sdk\Tests\MockClient} */ +function partnerClient(): array +{ + [$client, $mock] = makeClient(fn () => jsonResponse(['resultType' => 0, 'data' => null])); + + return [$client, $mock]; +} + +/** @return list */ +function urls(\Bulutklinik\Sdk\Tests\MockClient $mock): array +{ + return array_map(fn (RequestInterface $r) => (string) $r->getUri(), $mock->requests); +} + +/** @return list */ +function verbsAndUrls(\Bulutklinik\Sdk\Tests\MockClient $mock): array +{ + return array_map(fn (RequestInterface $r) => [$r->getMethod(), (string) $r->getUri()], $mock->requests); +} + +it('builds the discovery paths', function () { + [$client, $mock] = partnerClient(); + + $client->doctors->branches(); + $client->doctors->locations(); + $client->doctors->detail(42); + $client->laboratory->catalog(); + $client->laboratory->catalogDetail(18246); + $client->slots->schedule(7, '2026-08-01'); + + expect(urls($mock))->toBe([ + TEST_BASE . '/outher/branches', + TEST_BASE . '/outher/locations', + TEST_BASE . '/outher/doctorInfos/42', + TEST_BASE . '/outher/laboratoryCatalog', + TEST_BASE . '/outher/laboratoryCatalog/18246', + TEST_BASE . '/outher/doctorSlots', + ]); +}); + +it('carries the patient reference in the body, not the path', function () { + [$client, $mock] = partnerClient(); + + $client->diets->list(PATIENT_REF, 2); + $client->measures->list(PATIENT_REF, 'glucose', 1, 0); + $client->laboratory->results(PATIENT_REF); + + // The identity number must never leak into a URL — it would land in access + // logs, proxy logs and error breadcrumbs. + foreach (urls($mock) as $url) { + expect($url)->not->toContain('12345678901'); + } + + expect(urls($mock)[0])->toBe(TEST_BASE . '/outher/dietLists'); + expect(bodyOf($mock->requests[0]))->toBe(['patient' => PATIENT_REF, 'currentPage' => 2]); + + expect(urls($mock)[1])->toBe(TEST_BASE . '/outher/measuresList/glucose'); + expect(bodyOf($mock->requests[1])) + ->toBe(['patient' => PATIENT_REF, 'currentPage' => 1, 'glucoseType' => 0]); + + expect(urls($mock)[2])->toBe(TEST_BASE . '/outher/laboratoryResults'); +}); + +it('builds the measures graph path from type and period', function () { + [$client, $mock] = partnerClient(); + + $client->measures->graph(['phoneNumber' => '+905551112233'], 'weight', 3); + + expect(urls($mock)[0])->toBe(TEST_BASE . '/outher/measuresGraph/weight/3'); +}); + +it('passes a lab result id through unchanged, suffix and all', function () { + [$client, $mock] = partnerClient(); + + $client->laboratory->resultDetail(PATIENT_REF, '1234-lab'); + $client->laboratory->resultDetail(PATIENT_REF, 1234); + + expect(bodyOf($mock->requests[0])['testId'])->toBe('1234-lab'); + expect(bodyOf($mock->requests[1])['testId'])->toBe('1234'); +}); + +it('uses the right verb and path for the measure write endpoints', function () { + [$client, $mock] = partnerClient(); + + $client->measures->addList(WRITE_PATIENT, [ + ['type' => 'pulse', 'date_time' => '2026-06-17 09:00', 'pulse' => 72], + ]); + $client->measures->add(WRITE_PATIENT, 'tension', [ + 'date_time' => '2026-06-17 09:00', + 'hypertension' => 120, + 'hypotension' => 80, + ]); + $client->measures->update(PATIENT_REF, 'tension', 9, [ + 'date_time' => '2026-06-17 10:00', + 'hypertension' => 125, + ]); + $client->measures->delete(PATIENT_REF, 'tension', 9); + + expect(verbsAndUrls($mock))->toBe([ + ['POST', TEST_BASE . '/outher/measures'], + ['POST', TEST_BASE . '/outher/measure/tension'], + ['PUT', TEST_BASE . '/outher/measure/tension'], + ['DELETE', TEST_BASE . '/outher/measure/tension'], + ]); + + // Measure fields are flattened alongside `patient`, matching the server shape. + expect(bodyOf($mock->requests[1]))->toBe([ + 'patient' => WRITE_PATIENT, + 'date_time' => '2026-06-17 09:00', + 'hypertension' => 120, + 'hypotension' => 80, + ]); + expect(bodyOf($mock->requests[3]))->toBe(['patient' => PATIENT_REF, 'id' => 9]); +}); + +it('books through the appointment lifecycle', function () { + [$client, $mock] = partnerClient(); + + $client->appointments->checkDoctor(2, 0); + $client->appointments->reserveWithoutAgreement(1, 2, WRITE_PATIENT); + $client->appointments->create('h', 5); + $client->appointments->list('+905551112233'); + $client->appointments->cancelWithoutSlot(['hash' => 'h', 'outherProcessId' => 5]); + + expect(verbsAndUrls($mock))->toBe([ + ['POST', TEST_BASE . '/outher/checkDoctor'], + ['POST', TEST_BASE . '/outher/reservationWithoutAgreement'], + ['POST', TEST_BASE . '/outher/appointment'], + ['POST', TEST_BASE . '/outher/appointments'], + ['DELETE', TEST_BASE . '/outher/appointmentWithoutSlot'], + ]); + expect(bodyOf($mock->requests[1]))->toBe(['slotId' => 1, 'doctorId' => 2, 'user' => WRITE_PATIENT]); +}); + +it('sends the hand-off reservation and the instant one to their own paths', function () { + [$client, $mock] = partnerClient(); + + $client->appointments->reserve(1, 2, WRITE_PATIENT); + $client->appointments->instantReserve(WRITE_PATIENT); + $client->appointments->createWithoutSlot(2, '2026-08-01 09:00', '2026-08-01 09:30', WRITE_PATIENT); + + expect(urls($mock))->toBe([ + TEST_BASE . '/outher/reservation', + TEST_BASE . '/outher/instantReservation', + TEST_BASE . '/outher/appointmentWithoutSlot', + ]); + expect(bodyOf($mock->requests[1]))->toBe(['user' => WRITE_PATIENT]); +}); + +it('keeps the legacy teusan contract flat', function () { + [$client, $mock] = partnerClient(); + + $client->measures->healthInformation('12345678901', '+905551112233', [ + ['type' => 'pulse', 'date_time' => '2026-06-17 09:00', 'pulse' => 72], + ]); + + expect(urls($mock)[0])->toBe(TEST_BASE . '/outher/healthInformation'); + // No `patient` wrapper here — this endpoint predates that contract. + expect(bodyOf($mock->requests[0]))->toBe([ + 'identity' => '12345678901', + 'phoneNumber' => '+905551112233', + 'data' => [['type' => 'pulse', 'date_time' => '2026-06-17 09:00', 'pulse' => 72]], + ]); +}); diff --git a/tests/TokenStoreTest.php b/tests/TokenStoreTest.php index d7be9ff..c171968 100644 --- a/tests/TokenStoreTest.php +++ b/tests/TokenStoreTest.php @@ -4,22 +4,23 @@ use Bulutklinik\Sdk\Token\InMemoryTokenStore; -it('seeds, sets and clears tokens', function () { - $store = new InMemoryTokenStore('a', 'r'); - expect($store->getAccessToken())->toBe('a'); - expect($store->getRefreshToken())->toBe('r'); +it('seeds, sets and clears the partner token', function () { + $store = new InMemoryTokenStore('a'); + expect($store->getToken())->toBe('a'); - $store->setTokens('a2', 'r2'); - expect($store->getAccessToken())->toBe('a2'); - expect($store->getRefreshToken())->toBe('r2'); + $store->setToken('b'); + expect($store->getToken())->toBe('b'); $store->clear(); - expect($store->getAccessToken())->toBeNull(); - expect($store->getRefreshToken())->toBeNull(); + expect($store->getToken())->toBeNull(); }); it('defaults to null when unseeded', function () { - $store = new InMemoryTokenStore(); - expect($store->getAccessToken())->toBeNull(); - expect($store->getRefreshToken())->toBeNull(); + expect((new InMemoryTokenStore())->getToken())->toBeNull(); +}); + +it('accepts an explicit null unset', function () { + $store = new InMemoryTokenStore('a'); + $store->setToken(null); + expect($store->getToken())->toBeNull(); }); diff --git a/tests/TransportTest.php b/tests/TransportTest.php index 5230e5b..5866c98 100644 --- a/tests/TransportTest.php +++ b/tests/TransportTest.php @@ -2,7 +2,11 @@ declare(strict_types=1); +use Bulutklinik\Sdk\ApiVersion; +use Bulutklinik\Sdk\BulutklinikClient; +use Bulutklinik\Sdk\ClientConfig; use Bulutklinik\Sdk\Exception\AuthenticationException; +use Bulutklinik\Sdk\Exception\AuthorizationException; use Bulutklinik\Sdk\Exception\NotFoundException; use Bulutklinik\Sdk\Exception\RateLimitException; use Bulutklinik\Sdk\Exception\TransportException; @@ -11,42 +15,95 @@ use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Message\RequestInterface; -it('unwraps data on success and sends bearer + lang headers', function () { +const REF = ['identityNumber' => '12345678901']; + +it('unwraps data on success and sends the partner token + lang header', function () { [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['searchedDoctors' => []]]), - new InMemoryTokenStore('abc'), + fn () => jsonResponse(['resultType' => 0, 'data' => ['foundDoctorsCount' => 0, 'foundDoctors' => []]]), ); - $res = $client->doctors->quickSearch('kardiyo'); + $res = $client->doctors->search(['withFreeText' => 'kardiyoloji'], 1, ['slot']); - expect($res)->toBe(['searchedDoctors' => []]); + expect($res)->toBe(['foundDoctorsCount' => 0, 'foundDoctors' => []]); $req = $mock->requests[0]; - expect((string) $req->getUri())->toBe('https://apitest.bulutklinik.com/api/v3/patients/quickSearch'); - expect($req->getHeaderLine('Authorization'))->toBe('Bearer abc'); + expect((string) $req->getUri())->toBe(TEST_BASE . '/outher/search'); + expect($req->getHeaderLine('Authorization'))->toBe('Bearer PT'); expect($req->getHeaderLine('lang'))->toBe('tr'); - expect(json_decode((string) $req->getBody(), true)) - ->toBe(['searchText' => 'kardiyo', 'listType' => null, 'location' => null]); + expect(bodyOf($req))->toBe([ + 'searchParams' => ['withFreeText' => 'kardiyoloji'], + 'orderParams' => ['slot'], + 'currentPage' => 1, + ]); }); -it('request() escape hatch calls an arbitrary path with a bearer token', function () { +it('targets v4 when asked, without changing any path', function () { [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['ok' => true]]), - new InMemoryTokenStore('abc'), + fn () => jsonResponse(['resultType' => 0, 'data' => null]), + apiVersion: ApiVersion::V4, ); - $res = $client->request('GET', '/patients/customEndpoint'); + $client->doctors->branches(); + + expect((string) $mock->requests[0]->getUri()) + ->toBe('https://apitest.bulutklinik.com/api/v4/outher/branches'); +}); + +it('refuses to dispatch without a token instead of sending an anonymous request', function () { + $dispatched = 0; + [$client, $mock] = makeClient(function () use (&$dispatched) { + ++$dispatched; + + return jsonResponse(['resultType' => 0, 'data' => null]); + }, new InMemoryTokenStore()); + + $caught = null; + try { + $client->doctors->branches(); + } catch (AuthenticationException $e) { + $caught = $e; + } + + expect($caught)->toBeInstanceOf(AuthenticationException::class); + expect($dispatched)->toBe(0); + expect($mock->requests)->toBe([]); +}); + +it('rejects partnerToken and tokenStore together', function () { + new ClientConfig(partnerToken: 'PT', tokenStore: new InMemoryTokenStore('OTHER')); +})->throws(InvalidArgumentException::class, 'not both'); + +it('seeds the default store from partnerToken', function () { + $client = new BulutklinikClient(new ClientConfig(partnerToken: 'PT')); + + expect($client->tokenStore->getToken())->toBe('PT'); +}); + +it('reads the token from the store on every call, so rotation takes effect', function () { + $store = new InMemoryTokenStore('first'); + [$client, $mock] = makeClient(fn () => jsonResponse(['resultType' => 0, 'data' => null]), $store); + + $client->doctors->branches(); + $store->setToken('second'); + $client->doctors->branches(); + + expect(array_map(fn (RequestInterface $r) => $r->getHeaderLine('Authorization'), $mock->requests)) + ->toBe(['Bearer first', 'Bearer second']); +}); + +it('request() escape hatch defaults to the partner token', function () { + [$client, $mock] = makeClient(fn () => jsonResponse(['resultType' => 0, 'data' => ['ok' => true]])); + + $res = $client->request('GET', '/outher/customEndpoint'); expect($res)->toBe(['ok' => true]); $req = $mock->requests[0]; - expect((string) $req->getUri())->toBe('https://apitest.bulutklinik.com/api/v3/patients/customEndpoint'); + expect((string) $req->getUri())->toBe(TEST_BASE . '/outher/customEndpoint'); expect($req->getMethod())->toBe('GET'); - expect($req->getHeaderLine('Authorization'))->toBe('Bearer abc'); + expect($req->getHeaderLine('Authorization'))->toBe('Bearer PT'); }); -it('request() escape hatch sends a public POST body and unwraps data', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => ['id' => 7]]), - ); +it('request() can still reach a public endpoint', function () { + [$client, $mock] = makeClient(fn () => jsonResponse(['resultType' => 0, 'data' => ['id' => 7]])); $res = $client->request('POST', '/general/somePublicEndpoint', 'public', ['foo' => 'bar']); @@ -54,30 +111,30 @@ $req = $mock->requests[0]; expect($req->getMethod())->toBe('POST'); expect($req->getHeaderLine('Authorization'))->toBe(''); - expect(json_decode((string) $req->getBody(), true))->toBe(['foo' => 'bar']); + expect(bodyOf($req))->toBe(['foo' => 'bar']); }); it('maps 422 to ValidationException', function () { [$client] = makeClient( fn () => jsonResponse(['resultType' => 1, 'errorType' => 'validation', 'errorMessage' => 'bad'], 422), - new InMemoryTokenStore('a'), ); $client->doctors->branches(); })->throws(ValidationException::class); +it('maps 403 to AuthorizationException — wrong scope or no company on the token', function () { + [$client] = makeClient(fn () => jsonResponse(['resultType' => 1], 403)); + $client->doctors->branches(); +})->throws(AuthorizationException::class); + it('maps a numeric errorType 404 (live-found) to NotFoundException', function () { [$client] = makeClient( fn () => jsonResponse(['resultType' => 1, 'errorType' => 1, 'errorMessage' => 'Bilinmeyen bir hata.'], 404), - new InMemoryTokenStore('a'), ); - $client->doctors->quickSearch('kardiyo'); + $client->doctors->branches(); })->throws(NotFoundException::class); it('maps 429 to RateLimitException with retryAfter', function () { - [$client] = makeClient( - fn () => jsonResponse(['resultType' => 1], 429, ['Retry-After' => '30']), - new InMemoryTokenStore('a'), - ); + [$client] = makeClient(fn () => jsonResponse(['resultType' => 1], 429, ['Retry-After' => '30'])); $caught = null; try { @@ -90,64 +147,48 @@ expect($caught->context->retryAfter)->toBe(30); }); -it('refreshes once on 401 then retries with the new token', function () { - $dataCalls = 0; - $store = new InMemoryTokenStore('old', 'r'); - [$client, $mock] = makeClient(function (RequestInterface $req) use (&$dataCalls) { - if (str_contains((string) $req->getUri(), '/general/refreshApi')) { - return jsonResponse(['resultType' => 0, 'data' => ['access_token' => 'new', 'refresh_token' => 'newr']]); - } - ++$dataCalls; +it('surfaces an expired token (resultType 4) without retrying', function () { + $attempts = 0; + $store = new InMemoryTokenStore('expired'); + [$client] = makeClient(function () use (&$attempts) { + ++$attempts; - return $dataCalls === 1 - ? jsonResponse(['resultType' => 4], 401) - : jsonResponse(['resultType' => 0, 'data' => ['ok' => true]]); - }, $store, 'c', 's'); + return jsonResponse(['resultType' => 4, 'errorMessage' => 'You must log in.'], 401); + }, $store); - $res = $client->measures->last(); + $caught = null; + try { + $client->measures->last(REF); + } catch (AuthenticationException $e) { + $caught = $e; + } - expect($res)->toBe(['ok' => true]); - expect($store->getAccessToken())->toBe('new'); - $last = $mock->requests[count($mock->requests) - 1]; - expect($last->getHeaderLine('Authorization'))->toBe('Bearer new'); + expect($caught)->toBeInstanceOf(AuthenticationException::class); + expect($caught->getMessage())->toContain('cannot refresh it'); + expect($attempts)->toBe(1); + // An expired token is kept: the caller may want to inspect it while + // installing the replacement. Only a revoked one is cleared. + expect($store->getToken())->toBe('expired'); }); it('clears the store and throws on logout (resultType 2)', function () { - $store = new InMemoryTokenStore('a', 'r'); + $store = new InMemoryTokenStore('revoked'); [$client] = makeClient(fn () => jsonResponse(['resultType' => 2, 'errorMessage' => 'logged out']), $store); $caught = null; try { - $client->measures->last(); + $client->measures->last(REF); } catch (AuthenticationException $e) { $caught = $e; } expect($caught)->toBeInstanceOf(AuthenticationException::class); - expect($store->getAccessToken())->toBeNull(); + expect($store->getToken())->toBeNull(); }); it('wraps network failures in TransportException', function () { [$client] = makeClient(function (): never { throw new class ('boom') extends RuntimeException implements ClientExceptionInterface {}; - }, new InMemoryTokenStore('a')); + }); $client->doctors->branches(); })->throws(TransportException::class); - -it('builds the measure list path and uses the partner token', function () { - [$client, $mock] = makeClient( - fn () => jsonResponse(['resultType' => 0, 'data' => null]), - new InMemoryTokenStore('a'), - partnerToken: 'PT', - ); - - $client->measures->list('glucose', 1, 0); - expect((string) $mock->requests[0]->getUri()) - ->toBe('https://apitest.bulutklinik.com/api/v3/patients/userMeasuresList/glucose/1/0'); - - $client->measures->partnerHealthInformation(null, '5551112233', [ - ['type' => 'pulse', 'date_time' => '2026-06-17 09:00', 'pulse' => 72], - ]); - $last = $mock->requests[count($mock->requests) - 1]; - expect($last->getHeaderLine('Authorization'))->toBe('Bearer PT'); -});