tmdb_client 1.0.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 +7 -0
- data/CHANGELOG.md +63 -0
- data/LICENSE +21 -0
- data/README.md +305 -0
- data/lib/tmdb/certification.rb +24 -0
- data/lib/tmdb/change.rb +21 -0
- data/lib/tmdb/client.rb +164 -0
- data/lib/tmdb/collection.rb +27 -0
- data/lib/tmdb/company.rb +23 -0
- data/lib/tmdb/config.rb +71 -0
- data/lib/tmdb/configuration.rb +36 -0
- data/lib/tmdb/credit.rb +11 -0
- data/lib/tmdb/discover.rb +15 -0
- data/lib/tmdb/errors.rb +83 -0
- data/lib/tmdb/find.rb +44 -0
- data/lib/tmdb/genre.rb +21 -0
- data/lib/tmdb/job.rb +13 -0
- data/lib/tmdb/keyword.rb +15 -0
- data/lib/tmdb/movie.rb +117 -0
- data/lib/tmdb/network.rb +19 -0
- data/lib/tmdb/object.rb +138 -0
- data/lib/tmdb/page.rb +140 -0
- data/lib/tmdb/person.rb +55 -0
- data/lib/tmdb/resource.rb +90 -0
- data/lib/tmdb/review.rb +11 -0
- data/lib/tmdb/search.rb +43 -0
- data/lib/tmdb/tv/episode.rb +62 -0
- data/lib/tmdb/tv/season.rb +54 -0
- data/lib/tmdb/tv.rb +108 -0
- data/lib/tmdb/version.rb +5 -0
- data/lib/tmdb_client.rb +72 -0
- metadata +105 -0
data/lib/tmdb/config.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TMDb
|
|
4
|
+
# Settings for a client. Held on a plain object rather than in a class
|
|
5
|
+
# variable, so a process can run more than one (a different language, a
|
|
6
|
+
# different key) without them treading on each other.
|
|
7
|
+
#
|
|
8
|
+
# Not to be confused with TMDb::Configuration, which is the `/configuration`
|
|
9
|
+
# endpoint.
|
|
10
|
+
class Config
|
|
11
|
+
DEFAULT_BASE_URL = 'https://api.themoviedb.org/3'
|
|
12
|
+
DEFAULT_TIMEOUT = 10
|
|
13
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
14
|
+
DEFAULT_RETRIES = 2
|
|
15
|
+
DEFAULT_RETRY_INTERVAL = 0.5
|
|
16
|
+
# TMDb sends Retry-After on a 429, and the retry middleware honours it.
|
|
17
|
+
# Waiting on it is right; waiting on it for minutes inside a web request is
|
|
18
|
+
# not, so a wait longer than this gives up instead of sleeping.
|
|
19
|
+
DEFAULT_MAX_RETRY_INTERVAL = 10
|
|
20
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
21
|
+
|
|
22
|
+
# A v3 API key, sent as a query parameter.
|
|
23
|
+
attr_accessor :api_key
|
|
24
|
+
# A v4 read access token, sent as a bearer header. Takes precedence over
|
|
25
|
+
# api_key when both are set.
|
|
26
|
+
attr_accessor :access_token
|
|
27
|
+
attr_accessor :language, :region, :base_url, :timeout, :open_timeout,
|
|
28
|
+
:retries, :retry_interval, :max_retry_interval, :adapter, :logger, :user_agent
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@base_url = DEFAULT_BASE_URL
|
|
32
|
+
@timeout = DEFAULT_TIMEOUT
|
|
33
|
+
@open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
34
|
+
@retries = DEFAULT_RETRIES
|
|
35
|
+
@retry_interval = DEFAULT_RETRY_INTERVAL
|
|
36
|
+
@max_retry_interval = DEFAULT_MAX_RETRY_INTERVAL
|
|
37
|
+
@adapter = Faraday.default_adapter
|
|
38
|
+
@user_agent = "tmdb_client/#{TMDb::VERSION} (+https://github.com/cineol-gems/tmdb-client)"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def bearer_auth?
|
|
42
|
+
!access_token.nil? && !access_token.to_s.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def credentials?
|
|
46
|
+
bearer_auth? || !api_key.to_s.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The query parameters every request starts from. A per-call filter of the
|
|
50
|
+
# same name overrides these, which is how `language: 'en'` works on one call
|
|
51
|
+
# while the client stays configured for Spanish.
|
|
52
|
+
def default_params
|
|
53
|
+
params = { language: language, region: region }
|
|
54
|
+
params[:api_key] = api_key unless bearer_auth?
|
|
55
|
+
|
|
56
|
+
params.compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def headers
|
|
60
|
+
base = { 'Accept' => 'application/json', 'User-Agent' => user_agent }
|
|
61
|
+
base['Authorization'] = "Bearer #{access_token}" if bearer_auth?
|
|
62
|
+
|
|
63
|
+
base
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize_copy(other)
|
|
67
|
+
super
|
|
68
|
+
@adapter = other.adapter.dup if other.adapter.is_a?(::Array)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TMDb
|
|
4
|
+
# The `/configuration` endpoints -- image base URLs, the country and language
|
|
5
|
+
# lists, the job list. Not to be confused with TMDb::Config, which is this
|
|
6
|
+
# gem's own settings.
|
|
7
|
+
class Configuration < Resource
|
|
8
|
+
def get(filters = {})
|
|
9
|
+
object('/configuration', filters)
|
|
10
|
+
end
|
|
11
|
+
alias detail get
|
|
12
|
+
|
|
13
|
+
def countries(filters = {})
|
|
14
|
+
objects(fetch('/configuration/countries', filters))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def languages(filters = {})
|
|
18
|
+
objects(fetch('/configuration/languages', filters))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def jobs(filters = {})
|
|
22
|
+
objects(fetch('/configuration/jobs', filters))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def timezones(filters = {})
|
|
26
|
+
objects(fetch('/configuration/timezones', filters))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# An Array of locale strings, not objects.
|
|
30
|
+
def primary_translations(filters = {})
|
|
31
|
+
fetch('/configuration/primary_translations', filters)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
expose_class_methods
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/tmdb/credit.rb
ADDED
data/lib/tmdb/errors.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TMDb
|
|
4
|
+
# Base class for everything this gem raises.
|
|
5
|
+
#
|
|
6
|
+
# `rescue TMDb::Error` catches every failure, transport ones included: no
|
|
7
|
+
# Faraday, socket or JSON exception is ever allowed to escape the client, so
|
|
8
|
+
# callers never have to know what the client is built on.
|
|
9
|
+
#
|
|
10
|
+
# Where the old `themoviedb-api` gem collapsed every failure into one class
|
|
11
|
+
# carrying only a message string, an error here knows its HTTP `status`,
|
|
12
|
+
# TMDb's own numeric `code`, and the parsed `body` -- so a missing id can be
|
|
13
|
+
# told from a bad key without matching on prose.
|
|
14
|
+
class Error < StandardError
|
|
15
|
+
attr_reader :status, :code, :body, :retry_after
|
|
16
|
+
|
|
17
|
+
def initialize(message = nil, status: nil, code: nil, body: nil, retry_after: nil)
|
|
18
|
+
@status = status
|
|
19
|
+
@code = code
|
|
20
|
+
@body = body
|
|
21
|
+
@retry_after = retry_after
|
|
22
|
+
|
|
23
|
+
super(message || 'TMDb request failed')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Maps an HTTP status onto the class raised for it. Unknown 4xx and 5xx
|
|
27
|
+
# statuses still land under ClientError/ServerError rather than the bare
|
|
28
|
+
# base, so `rescue TMDb::ServerError` keeps meaning "their side".
|
|
29
|
+
def self.class_for(status)
|
|
30
|
+
STATUS_ERRORS.fetch(status) do
|
|
31
|
+
if status.to_i >= 500 then ServerError
|
|
32
|
+
elsif status.to_i >= 400 then ClientError
|
|
33
|
+
else Error
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The gem was asked to make a request it has no credentials for. Raised
|
|
40
|
+
# before any HTTP happens, so a forgotten `TMDb.configure` reads as a
|
|
41
|
+
# configuration mistake rather than as a 401 from TMDb.
|
|
42
|
+
class ConfigurationError < Error; end
|
|
43
|
+
|
|
44
|
+
# TMDb answered, but with a failing status.
|
|
45
|
+
class HTTPError < Error; end
|
|
46
|
+
|
|
47
|
+
# 4xx -- we asked wrongly.
|
|
48
|
+
class ClientError < HTTPError; end
|
|
49
|
+
class BadRequest < ClientError; end
|
|
50
|
+
class Unauthorized < ClientError; end
|
|
51
|
+
class Forbidden < ClientError; end
|
|
52
|
+
class NotFound < ClientError; end
|
|
53
|
+
class MethodNotAllowed < ClientError; end
|
|
54
|
+
class NotAcceptable < ClientError; end
|
|
55
|
+
class UnprocessableEntity < ClientError; end
|
|
56
|
+
|
|
57
|
+
# 429. `retry_after` carries the header TMDb sent, in seconds, when it sent one.
|
|
58
|
+
class RateLimited < ClientError; end
|
|
59
|
+
|
|
60
|
+
# 5xx -- their side.
|
|
61
|
+
class ServerError < HTTPError; end
|
|
62
|
+
class ServiceUnavailable < ServerError; end
|
|
63
|
+
|
|
64
|
+
# We never reached TMDb, or could not read what came back.
|
|
65
|
+
class ConnectionError < Error; end
|
|
66
|
+
class TimeoutError < ConnectionError; end
|
|
67
|
+
class ParsingError < Error; end
|
|
68
|
+
|
|
69
|
+
Error::STATUS_ERRORS = {
|
|
70
|
+
400 => BadRequest,
|
|
71
|
+
401 => Unauthorized,
|
|
72
|
+
403 => Forbidden,
|
|
73
|
+
404 => NotFound,
|
|
74
|
+
405 => MethodNotAllowed,
|
|
75
|
+
406 => NotAcceptable,
|
|
76
|
+
422 => UnprocessableEntity,
|
|
77
|
+
429 => RateLimited,
|
|
78
|
+
500 => ServerError,
|
|
79
|
+
502 => ServiceUnavailable,
|
|
80
|
+
503 => ServiceUnavailable,
|
|
81
|
+
504 => ServiceUnavailable
|
|
82
|
+
}.freeze
|
|
83
|
+
end
|
data/lib/tmdb/find.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TMDb
|
|
4
|
+
# Looks a title up by an id from somewhere else -- IMDb, TVDB, Wikidata:
|
|
5
|
+
#
|
|
6
|
+
# TMDb::Find.movie('tt0137523', external_source: 'imdb_id')
|
|
7
|
+
#
|
|
8
|
+
# Each method returns a bare Array of the matches for that media type, which
|
|
9
|
+
# is nearly always empty or one long.
|
|
10
|
+
class Find < Resource
|
|
11
|
+
def movie(external_id, filters = {})
|
|
12
|
+
list("/find/#{external_id}", :movie_results, filters)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def people(external_id, filters = {})
|
|
16
|
+
list("/find/#{external_id}", :person_results, filters)
|
|
17
|
+
end
|
|
18
|
+
alias person people
|
|
19
|
+
|
|
20
|
+
def tv_series(external_id, filters = {})
|
|
21
|
+
list("/find/#{external_id}", :tv_results, filters)
|
|
22
|
+
end
|
|
23
|
+
# themoviedb-api spelled it `tv_serie`. Kept so a migration is a rename.
|
|
24
|
+
alias tv_serie tv_series
|
|
25
|
+
|
|
26
|
+
# themoviedb-api read `tv_episode_results` here and `tv_season_results` in
|
|
27
|
+
# tv_episode -- the two were swapped. These read the right keys.
|
|
28
|
+
def tv_season(external_id, filters = {})
|
|
29
|
+
list("/find/#{external_id}", :tv_season_results, filters)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tv_episode(external_id, filters = {})
|
|
33
|
+
list("/find/#{external_id}", :tv_episode_results, filters)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Every bucket from a single request, where asking for a film and then a
|
|
37
|
+
# series costs two.
|
|
38
|
+
def all(external_id, filters = {})
|
|
39
|
+
object("/find/#{external_id}", filters)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
expose_class_methods
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/tmdb/genre.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TMDb
|
|
4
|
+
class Genre < Resource
|
|
5
|
+
def movie_list(filters = {})
|
|
6
|
+
list('/genre/movie/list', :genres, filters)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def tv_list(filters = {})
|
|
10
|
+
list('/genre/tv/list', :genres, filters)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Deprecated by TMDb; TMDb::Discover.movie(with_genres: id) is the
|
|
14
|
+
# supported way to ask this. Kept for parity with themoviedb-api.
|
|
15
|
+
def movies(genre_id, filters = {})
|
|
16
|
+
page("/genre/#{genre_id}/movies", filters)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
expose_class_methods
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/tmdb/job.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TMDb
|
|
4
|
+
# Deprecated by TMDb in favour of TMDb::Configuration.jobs. Kept for parity
|
|
5
|
+
# with themoviedb-api.
|
|
6
|
+
class Job < Resource
|
|
7
|
+
def list(filters = {})
|
|
8
|
+
objects(fetch('/job/list', filters)[:jobs])
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
expose_class_methods
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/tmdb/keyword.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TMDb
|
|
4
|
+
class Keyword < Resource
|
|
5
|
+
def detail(id, filters = {})
|
|
6
|
+
object("/keyword/#{id}", filters)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def movies(id, filters = {})
|
|
10
|
+
page("/keyword/#{id}/movies", filters)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
expose_class_methods
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/tmdb/movie.rb
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TMDb
|
|
4
|
+
class Movie < Resource
|
|
5
|
+
def detail(id, filters = {})
|
|
6
|
+
object("/movie/#{id}", filters)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def alternative_titles(id, filters = {})
|
|
10
|
+
list("/movie/#{id}/alternative_titles", :titles, filters)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def credits(id, filters = {})
|
|
14
|
+
object("/movie/#{id}/credits", filters)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cast(id, filters = {})
|
|
18
|
+
list("/movie/#{id}/credits", :cast, filters)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def crew(id, filters = {})
|
|
22
|
+
list("/movie/#{id}/credits", :crew, filters)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def director(id, filters = {})
|
|
26
|
+
crew(id, filters).select { |member| member[:job] == 'Director' }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def external_ids(id, filters = {})
|
|
30
|
+
object("/movie/#{id}/external_ids", filters)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def images(id, filters = {})
|
|
34
|
+
object("/movie/#{id}/images", filters)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def backdrops(id, filters = {})
|
|
38
|
+
list("/movie/#{id}/images", :backdrops, filters)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def posters(id, filters = {})
|
|
42
|
+
list("/movie/#{id}/images", :posters, filters)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def logos(id, filters = {})
|
|
46
|
+
list("/movie/#{id}/images", :logos, filters)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def videos(id, filters = {})
|
|
50
|
+
list("/movie/#{id}/videos", :results, filters)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def keywords(id, filters = {})
|
|
54
|
+
list("/movie/#{id}/keywords", :keywords, filters)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def watch_providers(id, filters = {})
|
|
58
|
+
object("/movie/#{id}/watch/providers", filters)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def release_dates(id, filters = {})
|
|
62
|
+
list("/movie/#{id}/release_dates", :results, filters)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Deprecated by TMDb in favour of `release_dates`; kept for parity with
|
|
66
|
+
# themoviedb-api, which only had this one.
|
|
67
|
+
def releases(id, filters = {})
|
|
68
|
+
list("/movie/#{id}/releases", :countries, filters)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def translations(id, filters = {})
|
|
72
|
+
list("/movie/#{id}/translations", :translations, filters)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def changes(id, filters = {})
|
|
76
|
+
list("/movie/#{id}/changes", :changes, filters)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def similar(id, filters = {})
|
|
80
|
+
page("/movie/#{id}/similar", filters)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def recommendations(id, filters = {})
|
|
84
|
+
page("/movie/#{id}/recommendations", filters)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def reviews(id, filters = {})
|
|
88
|
+
page("/movie/#{id}/reviews", filters)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def lists(id, filters = {})
|
|
92
|
+
page("/movie/#{id}/lists", filters)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def latest(filters = {})
|
|
96
|
+
object('/movie/latest', filters)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def upcoming(filters = {})
|
|
100
|
+
page('/movie/upcoming', filters)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def now_playing(filters = {})
|
|
104
|
+
page('/movie/now_playing', filters)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def popular(filters = {})
|
|
108
|
+
page('/movie/popular', filters)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def top_rated(filters = {})
|
|
112
|
+
page('/movie/top_rated', filters)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
expose_class_methods
|
|
116
|
+
end
|
|
117
|
+
end
|
data/lib/tmdb/network.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TMDb
|
|
4
|
+
class Network < Resource
|
|
5
|
+
def detail(id, filters = {})
|
|
6
|
+
object("/network/#{id}", filters)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def alternative_names(id, filters = {})
|
|
10
|
+
list("/network/#{id}/alternative_names", :results, filters)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def images(id, filters = {})
|
|
14
|
+
object("/network/#{id}/images", filters)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
expose_class_methods
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/tmdb/object.rb
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TMDb
|
|
4
|
+
# An immutable, hash-backed view over one TMDb JSON object.
|
|
5
|
+
#
|
|
6
|
+
# TMDb's payloads are wide, deeply nested, and change shape between endpoints
|
|
7
|
+
# and `append_to_response` combinations, so this gem does not try to declare a
|
|
8
|
+
# field list per resource -- it wraps whatever came back and lets you read it
|
|
9
|
+
# three ways, whichever suits the call site:
|
|
10
|
+
#
|
|
11
|
+
# movie.title # => "Fight Club"
|
|
12
|
+
# movie[:title] # => "Fight Club"
|
|
13
|
+
# movie['title'] # => "Fight Club"
|
|
14
|
+
#
|
|
15
|
+
# Nested objects and arrays are wrapped recursively, so `movie.credits.cast`
|
|
16
|
+
# is an Array of TMDb::Object. A key TMDb did not send reads as `nil` rather
|
|
17
|
+
# than raising: half of TMDb's fields are conditional (a credit has no
|
|
18
|
+
# `imdb_id`, a film in production has no `release_date`), and forcing every
|
|
19
|
+
# caller to guard each read would be worse than the typo it would catch.
|
|
20
|
+
#
|
|
21
|
+
# This replaces the old gem's OpenStruct subclass. Unlike OpenStruct it is
|
|
22
|
+
# frozen, allocates no singleton class per instance, and cannot be mutated
|
|
23
|
+
# into disagreeing with the response it came from.
|
|
24
|
+
class Object
|
|
25
|
+
# Ruby calls these implicitly to convert an object. Answering `nil` to them
|
|
26
|
+
# would make Ruby raise a confusing TypeError deep inside a splat or an
|
|
27
|
+
# interpolation, so they must miss loudly instead.
|
|
28
|
+
COERCION_METHODS = %i[to_ary to_a to_hash to_str to_int to_io to_proc coerce].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :attributes
|
|
31
|
+
|
|
32
|
+
def initialize(attributes = {})
|
|
33
|
+
@attributes = self.class.normalize(attributes)
|
|
34
|
+
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Symbolizes keys one level deep and wraps the values. JSON keys arrive as
|
|
40
|
+
# strings, hand-built test doubles as symbols; both end up identical.
|
|
41
|
+
def normalize(attributes)
|
|
42
|
+
case attributes
|
|
43
|
+
when Object then attributes.attributes
|
|
44
|
+
when nil then {}.freeze
|
|
45
|
+
when ::Hash
|
|
46
|
+
attributes.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = wrap(value) }.freeze
|
|
47
|
+
else
|
|
48
|
+
raise ArgumentError, "TMDb::Object expects a Hash, got #{attributes.class}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Anything already wrapped, and every scalar, falls through untouched.
|
|
53
|
+
def wrap(value)
|
|
54
|
+
case value
|
|
55
|
+
when ::Hash then new(value)
|
|
56
|
+
when ::Array then value.map { |element| wrap(element) }.freeze
|
|
57
|
+
else value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def [](key)
|
|
63
|
+
@attributes[key.to_sym]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fetch(key, *default, &)
|
|
67
|
+
@attributes.fetch(key.to_sym, *default, &)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def dig(key, *rest)
|
|
71
|
+
value = self[key]
|
|
72
|
+
|
|
73
|
+
rest.empty? ? value : value&.dig(*rest)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def key?(key)
|
|
77
|
+
@attributes.key?(key.to_sym)
|
|
78
|
+
end
|
|
79
|
+
alias has_key? key?
|
|
80
|
+
alias member? key?
|
|
81
|
+
|
|
82
|
+
def keys
|
|
83
|
+
@attributes.keys
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def each_pair(&block)
|
|
87
|
+
return enum_for(:each_pair) unless block
|
|
88
|
+
|
|
89
|
+
@attributes.each_pair(&block)
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A plain, deeply-unwrapped Hash with symbol keys -- the inverse of `new`.
|
|
94
|
+
def to_h
|
|
95
|
+
@attributes.transform_values { |value| self.class.unwrap(value) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.unwrap(value)
|
|
99
|
+
case value
|
|
100
|
+
when Object then value.to_h
|
|
101
|
+
when ::Array then value.map { |element| unwrap(element) }
|
|
102
|
+
else value
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ==(other)
|
|
107
|
+
other.is_a?(Object) && attributes == other.attributes
|
|
108
|
+
end
|
|
109
|
+
alias eql? ==
|
|
110
|
+
|
|
111
|
+
def hash
|
|
112
|
+
[self.class, @attributes].hash
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def inspect
|
|
116
|
+
"#<TMDb::Object #{@attributes.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')}>"
|
|
117
|
+
end
|
|
118
|
+
alias to_s inspect
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def method_missing(name, *args)
|
|
123
|
+
return super unless args.empty?
|
|
124
|
+
return super if COERCION_METHODS.include?(name)
|
|
125
|
+
return super if name.end_with?('=', '?', '!')
|
|
126
|
+
|
|
127
|
+
@attributes[name]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Deliberately narrower than `method_missing`: only keys actually present
|
|
131
|
+
# answer true. Ruby's implicit conversion checks (`to_ary` and friends) and
|
|
132
|
+
# any `respond_to?`-based duck typing therefore get an honest answer, while
|
|
133
|
+
# a plain read of an absent key still returns nil.
|
|
134
|
+
def respond_to_missing?(name, include_private = false)
|
|
135
|
+
@attributes.key?(name) || super
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|