toc_doc 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6c39bc2d41f1be6ae2b12b23074de967614fc1c6067d217316c7cb8bb9f0c31
4
- data.tar.gz: 6c3a8732a25916bb75ebb18c7f53a79ff272a0c8b646477160a8381890404dcf
3
+ metadata.gz: beef9501f028dfc80d31fb212f9ff876b20b952801ce7712702144bfa572fb32
4
+ data.tar.gz: ade0bafa23b88557931944c4199e64b9f45c972814d5b7af4b6b343602ae32eb
5
5
  SHA512:
6
- metadata.gz: dde234124b736d0149ac1f45bb6ab654487fb4e0c91bffb6c1b2462f51fa50a94cb59c5e9d4c4c5ef688eb12b9a8f8f321a0b5384b7d4bebe375506f81001b87
7
- data.tar.gz: 1fd5632c3e5c73f89ae8db6f844269d5674141332ea9fd1956691695c3ae7242a7d7f706b3b0742f361df6b7a6a8ed931dc25f6d4b2c01426c6cc0ee379dacde
6
+ metadata.gz: 5fcf8a1d6b3e3ef1e200bbd34c91922859f1c31ebb1ed208c6810d75470e56a63030c1f325f2ec9c39bbb54a73b97f831b5503d26528e34f62863f8a63cbedda
7
+ data.tar.gz: 20b38a89845bdf86a3b6effae5f47fbfc97e1d461b309a95a0c1a5b072bce2e9b6c58ee627719a07b02751b93e0edcfb309e552d6c6516998c3e34a8fb1ab2ef
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.5.0] - 2026-03-28
4
+
5
+ ### Added
6
+
7
+ - **`TocDoc::BookingInfo`** — new envelope class for the slot-selection funnel info endpoint (`/online_booking/api/slot_selection_funnel/v1/info.json`); `BookingInfo.find(identifier)` fetches booking context by profile slug or numeric ID and returns a typed `BookingInfo` instance
8
+ - **`TocDoc::BookingInfo#profile`** — returns the typed profile (`Practitioner` or `Organization`) via `Profile.build`
9
+ - **`TocDoc::BookingInfo#specialities`** — returns an array of `TocDoc::Speciality` objects
10
+ - **`TocDoc::BookingInfo#visit_motives`** — returns an array of `TocDoc::VisitMotive` objects
11
+ - **`TocDoc::BookingInfo#agendas`** — returns an array of `TocDoc::Agenda` objects, each pre-resolved with its matching `VisitMotive` objects via `visit_motive_ids`
12
+ - **`TocDoc::BookingInfo#places`** — returns an array of `TocDoc::Place` objects
13
+ - **`TocDoc::BookingInfo#practitioners`** — returns an array of `TocDoc::Profile::Practitioner` objects (marked `partial: true`)
14
+ - **`TocDoc::BookingInfo#organization?`** — delegates to the inner profile
15
+ - **`TocDoc::VisitMotive`** — new `Resource`-based model representing a visit motive (reason for consultation); exposes `#id` and `#name` via dot-notation
16
+ - **`TocDoc::Agenda`** — new `Resource`-based model representing an agenda (calendar); exposes `#id` and `#practice_id` via dot-notation; supports pre-resolved `#visit_motives` when built through `BookingInfo`
17
+ - **`TocDoc.booking_info`** — top-level shortcut delegating to `TocDoc::BookingInfo.find`
18
+
19
+ ## [1.4.0] - 2026-03-19
20
+
21
+ ### Added
22
+
23
+ - **`TocDoc::Place`** — new `Resource`-based model representing a practice location inside a profile response; exposes address/geo fields (`#address`, `#zipcode`, `#city`, `#full_address`, `#landline_number`, `#latitude`, `#longitude`, `#elevator`, `#handicap`, `#formal_name`) via dot-notation and adds `#coordinates` returning `[latitude, longitude]`
24
+ - **`TocDoc::Profile.find`** — class method that fetches a full profile page by slug or numeric ID from `/profiles/:identifier.json`; returns a typed `Profile::Practitioner` or `Profile::Organization` with `partial: false`
25
+ - **`TocDoc::Profile#skills`** — returns all skills across every practice as an array of `TocDoc::Resource` objects
26
+ - **`TocDoc::Profile#skills_for(practice_id)`** — returns skills for a single practice
27
+ - **`TocDoc.profile`** — top-level shortcut delegating to `TocDoc::Profile.find`
28
+ - **`TocDoc::Resource.main_attrs`** — class macro declaring which attribute keys appear in `#inspect`; inheritable by subclasses
29
+ - **`TocDoc::Resource.normalize_attrs`** — extracted as a public class method (string-key normalisation)
30
+ - **`TocDoc::Resource#inspect`** — custom implementation using `main_attrs` when declared, falling back to all keys
31
+
32
+ ### Changed
33
+
34
+ - **`TocDoc::Profile.build`** — updated to resolve full profile responses via boolean flags (`is_practitioner` / `organization`) in addition to the existing `owner_type` path used for search results; profiles built from search results are now tagged `partial: true`, full fetches `partial: false`; a `force_full_profile` flag on a search result transparently delegates to `Profile.find`
35
+ - **`TocDoc::Profile`** — `main_attrs :id, :partial` declared so inspect output stays concise
36
+
3
37
  ## [1.3.0] - 2026-03-15
4
38
 
5
39
  ### Added
data/README.md CHANGED
@@ -28,6 +28,8 @@ A Ruby gem for interacting with the (unofficial) Doctolib API. A thin, Faraday-b
28
28
  4. [Endpoints](#endpoints)
29
29
  - [Availabilities](#availabilities)
30
30
  - [Search](#search)
31
+ - [Profile](#profile)
32
+ - [BookingInfo](#bookinginfo)
31
33
  5. [Response objects](#response-objects)
32
34
  6. [Pagination](#pagination)
33
35
  7. [Error handling](#error-handling)
@@ -128,7 +130,7 @@ client.get('/availabilities.json', query: { visit_motive_ids: '123', agenda_ids:
128
130
  | Option | Default | Description |
129
131
  |---|---|---|
130
132
  | `api_endpoint` | `https://www.doctolib.fr` | Base URL. Change to `.de` / `.it` for other countries. |
131
- | `user_agent` | `TocDoc Ruby Gem 1.3.0` | `User-Agent` header sent with every request. |
133
+ | `user_agent` | `TocDoc Ruby Gem 1.5.0` | `User-Agent` header sent with every request. |
132
134
  | `default_media_type` | `application/json` | `Accept` and `Content-Type` headers. |
133
135
  | `per_page` | `15` | Default number of availability dates per request (capped at `15`). |
134
136
  | `middleware` | Retry + RaiseError + JSON + adapter | Full Faraday middleware stack. Override to customise completely. |
@@ -209,6 +211,66 @@ Valid `type:` values: `'profile'` (all profiles), `'practitioner'`, `'organizati
209
211
 
210
212
  **Return value:** a `TocDoc::Search::Result` when `type:` is omitted, or a filtered `Array` otherwise (see [Response objects](#response-objects)).
211
213
 
214
+ ### Profile
215
+
216
+ Fetch a full practitioner or organization profile page by slug or numeric ID.
217
+
218
+ ```ruby
219
+ # by slug
220
+ profile = TocDoc::Profile.find('jane-doe-bordeaux')
221
+
222
+ # by numeric ID
223
+ profile = TocDoc::Profile.find(1_542_899)
224
+
225
+ # module-level shortcut
226
+ profile = TocDoc.profile('jane-doe-bordeaux')
227
+ ```
228
+
229
+ `Profile.find` returns a typed `TocDoc::Profile::Practitioner` or `TocDoc::Profile::Organization` instance with `partial: false` (i.e. full profile data).
230
+
231
+ ```ruby
232
+ profile.name # => "Dr. Jane Doe"
233
+ profile.partial # => false
234
+ profile.practitioner? # => true
235
+
236
+ profile.skills # => [#<TocDoc::Resource ...>, ...]
237
+ profile.skills_for(377_272) # => skills for a specific practice
238
+
239
+ profile.places.first.city # => "Bordeaux"
240
+ profile.places.first.coordinates # => [44.8386722, -0.5780466]
241
+ ```
242
+
243
+ **Return value:** a `TocDoc::Profile::Practitioner` or `TocDoc::Profile::Organization` (see [Response objects](#response-objects)).
244
+
245
+ ### BookingInfo
246
+
247
+ Fetch the slot-selection funnel context for a practitioner or organization by slug or numeric ID.
248
+
249
+ ```ruby
250
+ # by slug
251
+ info = TocDoc::BookingInfo.find('jane-doe-bordeaux')
252
+
253
+ # by numeric ID
254
+ info = TocDoc::BookingInfo.find(1_542_899)
255
+
256
+ # module-level shortcut
257
+ info = TocDoc.booking_info('jane-doe-bordeaux')
258
+ ```
259
+
260
+ `BookingInfo.find` hits `/online_booking/api/slot_selection_funnel/v1/info.json` and returns a `TocDoc::BookingInfo` instance containing the full booking context needed to drive the appointment-booking funnel.
261
+
262
+ ```ruby
263
+ info.profile # => #<TocDoc::Profile::Practitioner ...>
264
+ info.specialities # => [#<TocDoc::Speciality ...>, ...]
265
+ info.visit_motives # => [#<TocDoc::VisitMotive id=..., name="...">, ...]
266
+ info.agendas # => [#<TocDoc::Agenda id=..., practice_id=...>, ...]
267
+ info.places # => [#<TocDoc::Place ...>, ...]
268
+ info.practitioners # => [#<TocDoc::Profile::Practitioner partial=true>, ...]
269
+ info.organization? # => false
270
+ ```
271
+
272
+ **Return value:** a `TocDoc::BookingInfo` (see [Response objects](#response-objects)).
273
+
212
274
  ---
213
275
 
214
276
  ## Response objects
@@ -270,15 +332,73 @@ Returned by `TocDoc::Search.where` when `type:` is omitted.
270
332
 
271
333
  ### `TocDoc::Profile`
272
334
 
273
- Represents a search profile result (practitioner or organization). Use `Profile.build(attrs)` to obtain the correctly typed subclass instance.
335
+ Represents a practitioner or organization profile. Can be a lightweight search result (`partial: true`) or a full profile page (`partial: false`).
274
336
 
275
337
  | Method | Type | Description |
276
338
  |---|---|---|
277
- | `Profile.build(attrs)` | `Profile::Practitioner \| Profile::Organization` | Factory: returns `Practitioner` when `owner_type` is `"Account"`, `Organization` otherwise. |
339
+ | `Profile.find(identifier)` | `Profile::Practitioner \| Profile::Organization` | Fetches a full profile by slug or numeric ID. Returns `partial: false`. |
340
+ | `Profile.build(attrs)` | `Profile::Practitioner \| Profile::Organization` | Factory used internally by `Search::Result`; resolves type from `owner_type` or boolean flags. Returns `partial: true` for search results. |
341
+ | `#id` | `String \| Integer` | Profile identifier. |
342
+ | `#partial` | `Boolean` | `true` when built from a search result, `false` when fetched via `Profile.find`. |
278
343
  | `#practitioner?` | `Boolean` | `true` when this is a `Profile::Practitioner`. |
279
344
  | `#organization?` | `Boolean` | `true` when this is a `Profile::Organization`. |
345
+ | `#places` | `Array<TocDoc::Place>` | Practice locations (available on full profiles). |
346
+ | `#skills` | `Array<TocDoc::Resource>` | All skills across every practice (available on full profiles). |
347
+ | `#skills_for(practice_id)` | `Array<TocDoc::Resource>` | Skills for a single practice by its ID. |
348
+
349
+ `TocDoc::Profile::Practitioner` and `TocDoc::Profile::Organization` are typed subclasses that inherit dot-notation attribute access from `TocDoc::Resource`.
350
+
351
+ ### `TocDoc::Place`
352
+
353
+ Represents a practice location returned inside a full profile response. Inherits dot-notation attribute access from `TocDoc::Resource`.
354
+
355
+ | Method | Type | Description |
356
+ |---|---|---|
357
+ | `#id` | `String` | Practice identifier (e.g. `"practice-125055"`). |
358
+ | `#address` | `String` | Street address. |
359
+ | `#zipcode` | `String` | Postal code. |
360
+ | `#city` | `String` | City name. |
361
+ | `#full_address` | `String` | Combined address string. |
362
+ | `#landline_number` | `String \| nil` | Phone number, if available. |
363
+ | `#latitude` | `Float` | Latitude. |
364
+ | `#longitude` | `Float` | Longitude. |
365
+ | `#elevator` | `Boolean` | Whether the practice has elevator access. |
366
+ | `#handicap` | `Boolean` | Whether the practice is handicap-accessible. |
367
+ | `#formal_name` | `String \| nil` | Formal practice name, if available. |
368
+ | `#coordinates` | `Array<Float>` | Convenience method returning `[latitude, longitude]`. |
369
+
370
+ ### `TocDoc::BookingInfo`
371
+
372
+ Returned by `TocDoc::BookingInfo.find`; also accessible via the `TocDoc.booking_info` module-level shortcut.
373
+
374
+ | Method | Type | Description |
375
+ |---|---|---|
376
+ | `#profile` | `Profile::Practitioner \| Profile::Organization` | Typed profile built via `Profile.build`. |
377
+ | `#specialities` | `Array<TocDoc::Speciality>` | Specialities associated with the booking context. |
378
+ | `#visit_motives` | `Array<TocDoc::VisitMotive>` | Available visit motives (reasons for consultation). |
379
+ | `#agendas` | `Array<TocDoc::Agenda>` | Agendas, each pre-resolved with their matching `VisitMotive` objects. |
380
+ | `#places` | `Array<TocDoc::Place>` | Practice locations. |
381
+ | `#practitioners` | `Array<TocDoc::Profile::Practitioner>` | Practitioners associated with this booking context (`partial: true`). |
382
+ | `#organization?` | `Boolean` | Delegates to the inner profile. |
383
+
384
+ ### `TocDoc::VisitMotive`
280
385
 
281
- `TocDoc::Profile::Practitioner` and `TocDoc::Profile::Organization` are thin subclasses that inherit dot-notation attribute access from `TocDoc::Resource`.
386
+ Represents a visit motive (reason for consultation) returned inside a `BookingInfo` response. Inherits dot-notation attribute access from `TocDoc::Resource`.
387
+
388
+ | Method | Type | Description |
389
+ |---|---|---|
390
+ | `#id` | `Integer` | Visit motive identifier. |
391
+ | `#name` | `String` | Human-readable name of the visit motive. |
392
+
393
+ ### `TocDoc::Agenda`
394
+
395
+ Represents an agenda (calendar) returned inside a `BookingInfo` response. Inherits dot-notation attribute access from `TocDoc::Resource`.
396
+
397
+ | Method | Type | Description |
398
+ |---|---|---|
399
+ | `#id` | `Integer` | Agenda identifier. |
400
+ | `#practice_id` | `Integer` | ID of the associated practice. |
401
+ | `#visit_motives` | `Array<TocDoc::VisitMotive>` | Visit motives pre-resolved via `visit_motive_ids` when built through `BookingInfo`. |
282
402
 
283
403
  ### `TocDoc::Speciality`
284
404
 
data/TODO.md CHANGED
@@ -2,17 +2,6 @@
2
2
 
3
3
  [POTENTIAL_ENDPOINTS][POTENTIAL_ENDPOINTS.md]
4
4
 
5
- ## 1.4
6
-
7
- - [ ] Profile
8
- - slug : https://www.doctolib.fr/profiles/mathilde-devun-lesparre-medoc.json
9
- - id : https://www.doctolib.fr/profiles/926388.json
10
-
11
- ## 1.5
12
-
13
- - [ ] Booking context
14
- - https://www.doctolib.fr/online_booking/api/slot_selection_funnel/v1/info.json?profile_slug=926388
15
-
16
5
  ## 1.6
17
6
 
18
7
  ### Better API usage
@@ -33,6 +22,18 @@
33
22
 
34
23
  # DONE & RELEASED
35
24
 
25
+ ## 1.5
26
+
27
+ - [x] Booking context
28
+ - practitioner : https://www.doctolib.fr/online_booking/api/slot_selection_funnel/v1/info.json?profile_slug=926388
29
+ - organization : https://www.doctolib.fr/online_booking/api/slot_selection_funnel/v1/info.json?profile_slug=325629
30
+
31
+ ## 1.4
32
+
33
+ - [x] Profile
34
+ - from slug : https://www.doctolib.fr/profiles/jane-doe-bordeaux.json
35
+ - from id : https://www.doctolib.fr/profiles/926388.json
36
+
36
37
  ## 1.3
37
38
 
38
39
  - [x] Search (autocomplete)
@@ -4,5 +4,5 @@ module TocDoc
4
4
  # The current version of the TocDoc gem.
5
5
  #
6
6
  # @return [String]
7
- VERSION = '1.3.0'
7
+ VERSION = '1.5.0'
8
8
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TocDoc
4
+ # Represents an agenda (calendar) returned by the booking info endpoint.
5
+ #
6
+ # The +id+ and +practice_id+ fields are the primary attributes. Additional
7
+ # fields such as +visit_motive_ids+ and +visit_motive_ids_by_practice_id+ are
8
+ # accessible via dot-notation inherited from {TocDoc::Resource}.
9
+ #
10
+ # @example
11
+ # agenda = TocDoc::Agenda.new(
12
+ # 'id' => 42,
13
+ # 'practice_id' => 'practice-125055',
14
+ # 'visit_motive_ids' => [1, 2]
15
+ # )
16
+ # agenda.id #=> 42
17
+ # agenda.practice_id #=> "practice-125055"
18
+ class Agenda < Resource
19
+ main_attrs :id, :practice_id
20
+ end
21
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'toc_doc/models/profile'
4
+ require 'toc_doc/models/speciality'
5
+ require 'toc_doc/models/place'
6
+ require 'toc_doc/models/visit_motive'
7
+ require 'toc_doc/models/agenda'
8
+
9
+ module TocDoc
10
+ # Envelope returned by the slot-selection funnel info endpoint.
11
+ #
12
+ # Wraps the raw API response and exposes typed collections for the booking
13
+ # context: profile, specialities, visit motives, agendas, places, and
14
+ # practitioners.
15
+ #
16
+ # Unlike {TocDoc::Profile}, this class is NOT a {Resource} subclass — it is
17
+ # a plain envelope class, similar in role to {TocDoc::Search::Result}.
18
+ #
19
+ # @example
20
+ # info = TocDoc::BookingInfo.find('jane-doe-bordeaux')
21
+ # info.profile #=> #<TocDoc::Profile::Practitioner>
22
+ # info.visit_motives #=> [#<TocDoc::VisitMotive>, ...]
23
+ # info.organization? #=> false
24
+ class BookingInfo
25
+ PATH = '/online_booking/api/slot_selection_funnel/v1/info.json'
26
+
27
+ class << self
28
+ # Fetches booking info for a profile slug or numeric ID.
29
+ #
30
+ # @param identifier [String, Integer] profile slug or numeric ID,
31
+ # forwarded as the +profile_slug+ query parameter
32
+ # @return [BookingInfo]
33
+ # @raise [ArgumentError] if +identifier+ is +nil+
34
+ #
35
+ # @example
36
+ # TocDoc::BookingInfo.find('jane-doe-bordeaux')
37
+ # TocDoc::BookingInfo.find(325629)
38
+ def find(identifier)
39
+ raise ArgumentError, 'identifier cannot be nil' if identifier.nil?
40
+
41
+ data = TocDoc.client.get(PATH, query: { profile_slug: identifier })['data']
42
+ new(data)
43
+ end
44
+ end
45
+
46
+ # @param data [Hash] the +data+ value from the API response body
47
+ def initialize(data)
48
+ @data = data
49
+ end
50
+
51
+ # The profile associated with this booking context, typed via
52
+ # {TocDoc::Profile.build}.
53
+ #
54
+ # @return [TocDoc::Profile::Practitioner, TocDoc::Profile::Organization]
55
+ def profile
56
+ @profile ||= Profile.build(@data['profile'])
57
+ end
58
+
59
+ # All specialities for this booking context.
60
+ #
61
+ # @return [Array<TocDoc::Speciality>]
62
+ def specialities
63
+ @specialities ||= Array(@data['specialities']).map do |speciality_attrs|
64
+ Speciality.new(speciality_attrs)
65
+ end
66
+ end
67
+
68
+ # All visit motives for this booking context.
69
+ #
70
+ # @return [Array<TocDoc::VisitMotive>]
71
+ def visit_motives
72
+ @visit_motives ||= Array(@data['visit_motives']).map do |visit_motive_attrs|
73
+ VisitMotive.new(visit_motive_attrs)
74
+ end
75
+ end
76
+
77
+ # All agendas for this booking context.
78
+ #
79
+ # @return [Array<TocDoc::Agenda>]
80
+ def agendas
81
+ @agendas ||= Array(@data['agendas']).map do |agenda_attrs|
82
+ agenda_visit_motives = visit_motives.select do |vm|
83
+ agenda_attrs['visit_motive_ids'].include?(vm.id)
84
+ end
85
+
86
+ Agenda.new(agenda_attrs.merge('visit_motives' => agenda_visit_motives))
87
+ end
88
+ end
89
+
90
+ # All practice locations for this booking context.
91
+ #
92
+ # @return [Array<TocDoc::Place>]
93
+ def places
94
+ @places ||= Array(@data['places']).map do |place_attrs|
95
+ Place.new(place_attrs)
96
+ end
97
+ end
98
+
99
+ # All practitioners associated with this booking context.
100
+ #
101
+ # Always constructed as {TocDoc::Profile::Practitioner} since the
102
+ # +practitioners+ array in this endpoint exclusively contains practitioners.
103
+ # Marked as partial since the data is a summary, not a full profile page.
104
+ #
105
+ # @return [Array<TocDoc::Profile::Practitioner>]
106
+ def practitioners
107
+ @practitioners ||= Array(@data['practitioners']).map do |practitioner_attrs|
108
+ Profile::Practitioner.new(practitioner_attrs.merge('partial' => true))
109
+ end
110
+ end
111
+
112
+ # Returns +true+ when the top-level profile is an organization.
113
+ #
114
+ # Delegates to {TocDoc::Profile#organization?}.
115
+ #
116
+ # @return [Boolean]
117
+ def organization?
118
+ profile.organization?
119
+ end
120
+
121
+ # Returns the raw data hash.
122
+ #
123
+ # @return [Hash]
124
+ def to_h
125
+ @data
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TocDoc
4
+ # Represents a practice location returned inside a profile response.
5
+ #
6
+ # All fields are accessible via dot-notation inherited from {TocDoc::Resource}.
7
+ # Nested arrays (+opening_hours+, +stations+) are returned as raw arrays of hashes.
8
+ #
9
+ # @example
10
+ # place = TocDoc::Place.new(
11
+ # 'id' => 'practice-125055',
12
+ # 'address' => '1 Rue Anonyme',
13
+ # 'zipcode' => '33000',
14
+ # 'city' => 'Bordeaux',
15
+ # 'full_address' => '1 Rue Anonyme, 33000 Bordeaux',
16
+ # 'landline_number' => '05 23 45 67 89',
17
+ # 'latitude' => 44.8386722,
18
+ # 'longitude' => -0.5780466,
19
+ # 'elevator' => true,
20
+ # 'handicap' => true,
21
+ # 'formal_name' => 'Centre de santé - Anonyme'
22
+ # )
23
+ # place.id #=> "practice-125055"
24
+ # place.city #=> "Bordeaux"
25
+ # place.full_address #=> "1 Rue Anonyme, 33000 Bordeaux"
26
+ # place.landline_number #=> "05 23 45 67 89"
27
+ # place.latitude #=> 44.8386722
28
+ # place.elevator #=> true
29
+ class Place < Resource
30
+ def coordinates
31
+ [latitude, longitude]
32
+ end
33
+ end
34
+ end
@@ -3,6 +3,12 @@
3
3
  module TocDoc
4
4
  class Profile
5
5
  # A practitioner profile (raw +owner_type: "Account"+).
6
- class Practitioner < Profile; end
6
+ class Practitioner < Profile
7
+ main_attrs :name_with_title
8
+
9
+ def to_s
10
+ (respond_to?(:name_with_title) && name_with_title) || name
11
+ end
12
+ end
7
13
  end
8
14
  end
@@ -12,26 +12,154 @@ module TocDoc
12
12
  # profile.practitioner? #=> true
13
13
  # profile.name #=> "Dr Smith"
14
14
  class Profile < Resource
15
- # Factory — returns a +Profile::Practitioner+ or +Profile::Organization+
16
- # depending on the +owner_type+ field of the raw attribute hash.
17
- #
18
- # @param attrs [Hash] raw attribute hash from the API response
19
- # @return [Profile::Practitioner, Profile::Organization]
20
- def self.build(attrs = {})
21
- case attrs['owner_type'] || attrs[:owner_type]
22
- when 'Account'
23
- Practitioner.new(attrs)
24
- else
25
- Organization.new(attrs)
15
+ PATH = '/profiles/%<identifier>s.json'
16
+
17
+ main_attrs :id, :partial
18
+
19
+ class << self
20
+ # Factory returns a +Profile::Practitioner+ or +Profile::Organization+.
21
+ #
22
+ # Resolves type via +owner_type+ first (autocomplete context), then falls back
23
+ # to the boolean flags present on profile-page responses.
24
+ #
25
+ # @param attrs [Hash] raw attribute hash from the API response
26
+ # @return [Profile::Practitioner, Profile::Organization]
27
+ # @raise [ArgumentError] if +attrs+ contains an unknown +owner_type+ or the
28
+ # profile type cannot be determined from the available flags
29
+ #
30
+ # @example Build from an autocomplete result
31
+ # TocDoc::Profile.build('owner_type' => 'Account', 'name' => 'Dr Smith')
32
+ # @example Build from a full profile response
33
+ # TocDoc::Profile.build('is_practitioner' => true, 'name' => 'Dr Smith')
34
+ def build(attrs = {})
35
+ attrs = normalize_attrs(attrs)
36
+
37
+ return find(attrs['value']) if attrs['force_full_profile']
38
+
39
+ build_from_autocomplete(attrs) ||
40
+ build_from_booking_info(attrs) ||
41
+ build_from_full_profile(attrs)
42
+ end
43
+
44
+ # Fetches a full profile page by slug or numeric ID.
45
+ #
46
+ # @param identifier [String, Integer] profile slug or numeric ID
47
+ # @return [Profile::Practitioner, Profile::Organization]
48
+ # @raise [ArgumentError] if +identifier+ is +nil+
49
+ #
50
+ # @example
51
+ # TocDoc::Profile.find('jane-doe-bordeaux')
52
+ # TocDoc::Profile.find(1542899)
53
+ def find(identifier)
54
+ raise ArgumentError, 'identifier cannot be nil' if identifier.nil?
55
+
56
+ data = TocDoc.client.get(format(PATH, identifier: identifier))['data']
57
+ build(profile_attrs(data))
58
+ end
59
+
60
+ private
61
+
62
+ # Autocomplete results carry an +owner_type+ key that tells us the
63
+ # profile type directly. When +force_full_profile+ is set, fetches
64
+ # the full profile page instead of building a partial.
65
+ #
66
+ # @param attrs [Hash] normalized attribute hash
67
+ # @return [Profile::Practitioner, Profile::Organization, nil] +nil+ when
68
+ # +owner_type+ is absent
69
+ # @raise [ArgumentError] if +owner_type+ is present but unrecognised
70
+ def build_from_autocomplete(attrs)
71
+ return unless attrs['owner_type']
72
+
73
+ case attrs['owner_type']
74
+ when 'Account' then Practitioner.new(attrs.merge('partial' => true))
75
+ when 'Organization' then Organization.new(attrs.merge('partial' => true))
76
+ else raise ArgumentError, "Unknown owner_type: #{attrs['owner_type']}"
77
+ end
78
+ end
79
+
80
+ # Builds a profile from a full profile-page response.
81
+ #
82
+ # @param attrs [Hash] normalized attribute hash from a full profile page
83
+ # @return [Profile::Practitioner, Profile::Organization]
84
+ # @raise [ArgumentError] if neither +is_practitioner+ nor +organization+
85
+ # flag is present in +attrs+
86
+ def build_from_full_profile(attrs)
87
+ if attrs['is_practitioner']
88
+ Practitioner.new(attrs.merge('partial' => false))
89
+ elsif attrs['organization']
90
+ Organization.new(attrs.merge('partial' => false))
91
+ else
92
+ raise ArgumentError, "Unable to determine profile type from attributes: #{attrs.inspect}"
93
+ end
94
+ end
95
+
96
+ # Booking-info profiles carry `organization` (true/false) but lack
97
+ # `is_practitioner`. Returns nil when the shape doesn't match so the
98
+ # caller can fall through to build_from_full_profile.
99
+ #
100
+ # @param attrs [Hash] normalized attribute hash
101
+ # @return [Profile::Practitioner, Profile::Organization, nil] +nil+ when
102
+ # the booking-info shape is not matched
103
+ def build_from_booking_info(attrs)
104
+ return unless attrs.key?('organization') && !attrs.key?('is_practitioner')
105
+
106
+ return Organization.new(attrs.merge('partial' => true)) if attrs['organization']
107
+
108
+ Practitioner.new(attrs.merge('partial' => true))
109
+ end
110
+
111
+ # Extracts and coerces profile attributes from the raw API response.
112
+ #
113
+ # @param data [Hash] the +data+ key from the API response body
114
+ # @return [Hash] merged attribute hash with +speciality+ and +places+
115
+ # coerced into model objects
116
+ def profile_attrs(data)
117
+ data['profile'].merge(
118
+ 'speciality' => TocDoc::Speciality.new(data['profile']['speciality'] || {}),
119
+ 'places' => Array(data['places']).map { |p| TocDoc::Place.new(p) },
120
+ 'legals' => data['legals'],
121
+ 'details' => data['details'],
122
+ 'fees' => data['fees'],
123
+ 'bookable' => data['bookable']
124
+ )
26
125
  end
27
126
  end
28
127
 
128
+ # Returns all skills across all practices as an array of {TocDoc::Resource}.
129
+ #
130
+ # @return [Array<TocDoc::Resource>]
131
+ #
132
+ # @example
133
+ # profile.skills #=> [#<TocDoc::Resource ...>, ...]
134
+ def skills
135
+ hash = self['skills_by_practice'] || {}
136
+ hash.values.flatten.map { |s| TocDoc::Resource.new(s) }
137
+ end
138
+
139
+ # Returns skills for a single practice as an array of {TocDoc::Resource}.
140
+ #
141
+ # @param practice_id [Integer, String] the practice ID
142
+ # @return [Array<TocDoc::Resource>]
143
+ #
144
+ # @example
145
+ # profile.skills_for(123) #=> [#<TocDoc::Resource ...>, ...]
146
+ def skills_for(practice_id)
147
+ hash = self['skills_by_practice'] || {}
148
+ Array(hash[practice_id.to_s]).map { |s| TocDoc::Resource.new(s) }
149
+ end
150
+
29
151
  # @return [Boolean] true when this profile is a practitioner
152
+ #
153
+ # @example
154
+ # profile.practitioner? #=> true
30
155
  def practitioner?
31
156
  is_a?(Practitioner)
32
157
  end
33
158
 
34
159
  # @return [Boolean] true when this profile is an organization
160
+ #
161
+ # @example
162
+ # profile.organization? #=> false
35
163
  def organization?
36
164
  is_a?(Organization)
37
165
  end
@@ -14,9 +14,43 @@ module TocDoc
14
14
  # resource[:date] #=> "2026-02-28"
15
15
  # resource.to_h #=> { "date" => "2026-02-28", "slots" => [] }
16
16
  class Resource
17
+ class << self
18
+ # Normalises a raw attribute hash to string keys, mirroring what
19
+ # {#initialize} does internally. Useful in class-level factory methods
20
+ # that need to inspect attrs before wrapping them in a Resource instance.
21
+ #
22
+ # @param attrs [Hash] raw hash with string or symbol keys
23
+ # @return [Hash{String => Object}]
24
+ def normalize_attrs(attrs)
25
+ attrs.transform_keys(&:to_s)
26
+ end
27
+
28
+ # Declares which attribute keys are shown in +#inspect+.
29
+ # When called with arguments, sets the list for this class.
30
+ # When called with no arguments, returns the list (or +nil+ if unset),
31
+ # walking the ancestor chain so subclasses inherit the declaration.
32
+ #
33
+ # Subclasses that never call +main_attrs+ fall back to showing all attrs.
34
+ #
35
+ # @example
36
+ # main_attrs :id, :name, :slug
37
+ #
38
+ # @param keys [Array<Symbol, String>]
39
+ # @return [Array<String>, nil]
40
+ def main_attrs(*keys)
41
+ if keys.empty?
42
+ ancestor = ancestors.find { |a| a.instance_variable_defined?(:@main_attrs) }
43
+ ancestor&.instance_variable_get(:@main_attrs)
44
+ else
45
+ inherited = superclass.respond_to?(:main_attrs) ? (superclass.main_attrs || []) : []
46
+ @main_attrs = (inherited + keys.map(&:to_s)).uniq
47
+ end
48
+ end
49
+ end
50
+
17
51
  # @param attrs [Hash] the raw attribute hash (string or symbol keys)
18
52
  def initialize(attrs = {})
19
- @attrs = attrs.transform_keys(&:to_s)
53
+ @attrs = self.class.normalize_attrs(attrs)
20
54
  end
21
55
 
22
56
  # Read an attribute by name.
@@ -56,7 +90,7 @@ module TocDoc
56
90
  def ==(other)
57
91
  case other
58
92
  when Resource then @attrs == other.to_h
59
- when Hash then @attrs == other.transform_keys(&:to_s)
93
+ when Hash then @attrs == self.class.normalize_attrs(other)
60
94
  else false
61
95
  end
62
96
  end
@@ -78,5 +112,12 @@ module TocDoc
78
112
  key = method_name.to_s
79
113
  @attrs.key?(key) ? @attrs[key] : super
80
114
  end
115
+
116
+ # @!visibility private
117
+ def inspect
118
+ keys = self.class.main_attrs || @attrs.keys
119
+ pairs = keys.map { |k| "@#{k}=#{@attrs[k.to_s].inspect}" }.join(', ')
120
+ "#<#{self.class} #{pairs}>"
121
+ end
81
122
  end
82
123
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TocDoc
4
+ # Represents a visit motive (reason for consultation) returned by the booking
5
+ # info endpoint.
6
+ #
7
+ # The +id+ and +name+ fields are the primary attributes. Additional fields
8
+ # such as +restrictions+ are accessible via dot-notation inherited from
9
+ # {TocDoc::Resource}.
10
+ #
11
+ # @example
12
+ # motive = TocDoc::VisitMotive.new('id' => 1, 'name' => 'Consultation', 'restrictions' => [])
13
+ # motive.id #=> 1
14
+ # motive.name #=> "Consultation"
15
+ class VisitMotive < Resource
16
+ main_attrs :id, :name
17
+ end
18
+ end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'toc_doc/models/resource'
4
+ require 'toc_doc/models/speciality'
5
+ require 'toc_doc/models/place'
6
+ require 'toc_doc/models/visit_motive'
7
+ require 'toc_doc/models/agenda'
8
+ require 'toc_doc/models/booking_info'
4
9
  require 'toc_doc/models/search'
5
10
 
6
11
  require 'toc_doc/models/availability'
7
12
  require 'toc_doc/models/availability/collection'
8
13
 
9
14
  require 'toc_doc/models/profile'
10
- require 'toc_doc/models/speciality'
data/lib/toc_doc.rb CHANGED
@@ -89,6 +89,28 @@ module TocDoc
89
89
  TocDoc::Search.where(**)
90
90
  end
91
91
 
92
+ # Fetches a full profile page by slug or numeric ID.
93
+ #
94
+ # Delegates to {TocDoc::Profile.find} — see that method for full
95
+ # parameter documentation.
96
+ #
97
+ # @param identifier [String, Integer] profile slug or numeric ID
98
+ # @return [TocDoc::Profile::Practitioner, TocDoc::Profile::Organization]
99
+ def profile(identifier)
100
+ TocDoc::Profile.find(identifier)
101
+ end
102
+
103
+ # Fetches booking context data for a profile by slug or numeric ID.
104
+ #
105
+ # Delegates to {TocDoc::BookingInfo.find} — see that method for full
106
+ # parameter documentation.
107
+ #
108
+ # @param identifier [String, Integer] profile slug or numeric ID
109
+ # @return [TocDoc::BookingInfo]
110
+ def booking_info(identifier)
111
+ TocDoc::BookingInfo.find(identifier)
112
+ end
113
+
92
114
  # @!visibility private
93
115
  def method_missing(method_name, ...)
94
116
  if client.respond_to?(method_name)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toc_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - 01max
@@ -74,8 +74,11 @@ files:
74
74
  - lib/toc_doc/http/response/base_middleware.rb
75
75
  - lib/toc_doc/middleware/.keep
76
76
  - lib/toc_doc/models.rb
77
+ - lib/toc_doc/models/agenda.rb
77
78
  - lib/toc_doc/models/availability.rb
78
79
  - lib/toc_doc/models/availability/collection.rb
80
+ - lib/toc_doc/models/booking_info.rb
81
+ - lib/toc_doc/models/place.rb
79
82
  - lib/toc_doc/models/profile.rb
80
83
  - lib/toc_doc/models/profile/organization.rb
81
84
  - lib/toc_doc/models/profile/practitioner.rb
@@ -83,6 +86,7 @@ files:
83
86
  - lib/toc_doc/models/search.rb
84
87
  - lib/toc_doc/models/search/result.rb
85
88
  - lib/toc_doc/models/speciality.rb
89
+ - lib/toc_doc/models/visit_motive.rb
86
90
  - sig/toc_doc.rbs
87
91
  homepage: https://github.com/01max/toc_doc
88
92
  licenses: