ed_fi_client 0.1.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +78 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +5 -0
  7. data/.yardopts +1 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +65 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +113 -0
  12. data/Rakefile +6 -0
  13. data/bin/console +14 -0
  14. data/bin/setup +8 -0
  15. data/docs/EdFi.html +127 -0
  16. data/docs/EdFi/Client.html +1204 -0
  17. data/docs/EdFi/Client/AccessToken.html +541 -0
  18. data/docs/EdFi/Client/ArgumentError.html +143 -0
  19. data/docs/EdFi/Client/Auth.html +440 -0
  20. data/docs/EdFi/Client/Error.html +139 -0
  21. data/docs/EdFi/Client/Proxy.html +521 -0
  22. data/docs/EdFi/Client/Response.html +479 -0
  23. data/docs/EdFi/Client/UnableToAuthenticateError.html +145 -0
  24. data/docs/_index.html +203 -0
  25. data/docs/class_list.html +51 -0
  26. data/docs/css/common.css +1 -0
  27. data/docs/css/full_list.css +58 -0
  28. data/docs/css/style.css +499 -0
  29. data/docs/file.README.html +124 -0
  30. data/docs/file_list.html +56 -0
  31. data/docs/frames.html +17 -0
  32. data/docs/index.html +124 -0
  33. data/docs/js/app.js +248 -0
  34. data/docs/js/full_list.js +216 -0
  35. data/docs/js/jquery.js +4 -0
  36. data/docs/method_list.html +235 -0
  37. data/docs/top-level-namespace.html +110 -0
  38. data/ed_fi_client.gemspec +32 -0
  39. data/lib/ed_fi/client.rb +244 -0
  40. data/lib/ed_fi/client/access_token.rb +63 -0
  41. data/lib/ed_fi/client/auth.rb +99 -0
  42. data/lib/ed_fi/client/errors.rb +19 -0
  43. data/lib/ed_fi/client/proxy.rb +65 -0
  44. data/lib/ed_fi/client/response.rb +150 -0
  45. data/lib/ed_fi/client/version.rb +18 -0
  46. data/lib/ed_fi_client.rb +1 -0
  47. metadata +201 -0
@@ -0,0 +1,110 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>
7
+ Top Level Namespace
8
+
9
+ &mdash; Documentation by YARD 0.9.12
10
+
11
+ </title>
12
+
13
+ <link rel="stylesheet" href="css/style.css" type="text/css" charset="utf-8" />
14
+
15
+ <link rel="stylesheet" href="css/common.css" type="text/css" charset="utf-8" />
16
+
17
+ <script type="text/javascript" charset="utf-8">
18
+ pathId = "";
19
+ relpath = '';
20
+ </script>
21
+
22
+
23
+ <script type="text/javascript" charset="utf-8" src="js/jquery.js"></script>
24
+
25
+ <script type="text/javascript" charset="utf-8" src="js/app.js"></script>
26
+
27
+
28
+ </head>
29
+ <body>
30
+ <div class="nav_wrap">
31
+ <iframe id="nav" src="class_list.html?1"></iframe>
32
+ <div id="resizer"></div>
33
+ </div>
34
+
35
+ <div id="main" tabindex="-1">
36
+ <div id="header">
37
+ <div id="menu">
38
+
39
+ <a href="_index.html">Index</a> &raquo;
40
+
41
+
42
+ <span class="title">Top Level Namespace</span>
43
+
44
+ </div>
45
+
46
+ <div id="search">
47
+
48
+ <a class="full_list_link" id="class_list_link"
49
+ href="class_list.html">
50
+
51
+ <svg width="24" height="24">
52
+ <rect x="0" y="4" width="24" height="4" rx="1" ry="1"></rect>
53
+ <rect x="0" y="12" width="24" height="4" rx="1" ry="1"></rect>
54
+ <rect x="0" y="20" width="24" height="4" rx="1" ry="1"></rect>
55
+ </svg>
56
+ </a>
57
+
58
+ </div>
59
+ <div class="clear"></div>
60
+ </div>
61
+
62
+ <div id="content"><h1>Top Level Namespace
63
+
64
+
65
+
66
+ </h1>
67
+ <div class="box_info">
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+ </div>
80
+
81
+ <h2>Defined Under Namespace</h2>
82
+ <p class="children">
83
+
84
+
85
+ <strong class="modules">Modules:</strong> <span class='object_link'><a href="EdFi.html" title="EdFi (module)">EdFi</a></span>
86
+
87
+
88
+
89
+
90
+ </p>
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+ </div>
101
+
102
+ <div id="footer">
103
+ Generated on Thu May 24 18:35:37 2018 by
104
+ <a href="http://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
105
+ 0.9.12 (ruby-2.5.1).
106
+ </div>
107
+
108
+ </div>
109
+ </body>
110
+ </html>
@@ -0,0 +1,32 @@
1
+
2
+ lib = File.expand_path('lib', __dir__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'ed_fi/client/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'ed_fi_client'
8
+ spec.version = EdFi::Client::VERSION
9
+ spec.authors = ['Nestor Custodio']
10
+ spec.email = ['sakimorix@gmail.com']
11
+
12
+ spec.summary = 'A simple API wrapper for Ed-Fi ODS access.'
13
+ spec.homepage = 'https://www.github.com/nestor-custodio/ed_fi_client'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.bindir = 'exe'
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ['lib']
22
+
23
+ spec.add_development_dependency 'bundler', '~> 1.16'
24
+ spec.add_development_dependency 'pry', '~> 0.11'
25
+ spec.add_development_dependency 'pry-byebug', '~> 3.6'
26
+ spec.add_development_dependency 'rake', '~> 10.0'
27
+ spec.add_development_dependency 'rspec', '~> 3.0'
28
+ spec.add_development_dependency 'rspec_junit_formatter', '~> 0.3'
29
+
30
+ spec.add_dependency 'activesupport', '~> 5.2.0'
31
+ spec.add_dependency 'crapi', '~> 0.1'
32
+ end
@@ -0,0 +1,244 @@
1
+ require 'active_support/all'
2
+ require 'crapi'
3
+
4
+ require 'ed_fi/client/auth'
5
+ require 'ed_fi/client/errors'
6
+ require 'ed_fi/client/proxy'
7
+ require 'ed_fi/client/response'
8
+ require 'ed_fi/client/version'
9
+
10
+ ## The main container defined by the **ed_fi_client** gem. Provides a connection mechanism, an
11
+ ## authentication mechanism, simple CRUD methods ({#delete} / {#get} / {#patch} / {#post} / {#put}),
12
+ ## and proxy generators.
13
+ ##
14
+ ## All other classes defined in this gem (including gem-specific `::Error` derivatives) are
15
+ ## subclasses of {EdFi::Client EdFi::Client}.
16
+ ##
17
+ class EdFi::Client < Crapi::Client
18
+ ## The "profile" header content-type template.
19
+ PROFILE_MIME_TYPE = 'application/vnd.ed-fi.%<resource>s.%<profile>s.%<access>s+json'.freeze
20
+
21
+ ## @param base_uri [URI, String]
22
+ ## The base URI the client should use for determining the host to connect to, whether SSH is
23
+ ## applicable, and the path to the target API.
24
+ ##
25
+ ## @param opts [Hash]
26
+ ## Method options. All options not explicitly listed below are passed on to Crapi::Client.
27
+ ##
28
+ ## @option opts [String] :profile
29
+ ## The profile for which {EdFi::Client#read} and {EdFi::Client#write} will generate headers, if
30
+ ## any.
31
+ ##
32
+ ## @option opts [String] :client_id
33
+ ## The client id to use for authentication. This is AKA the "api key" / "username".
34
+ ##
35
+ ## @option opts [String] :client_secret
36
+ ## The client secret to use for authentication. This is AKA the "api secret" / "password".
37
+ ##
38
+ ##
39
+ ## @raise [EdFi::Client::ArgumentError]
40
+ ##
41
+ def initialize(base_uri, opts = {})
42
+ required_opts = %i[client_id client_secret]
43
+ required_opts.each { |opt| raise ArgumentError, "missing keyword: #{opt}" unless opts.key? opt }
44
+
45
+ super(base_uri, opts)
46
+ self.profile = opts[:profile]
47
+
48
+ ## Giving the EdFi::Client::Auth instance its own Crapi client lets us do fancy things with the
49
+ ## API segmenting stuff ...
50
+ ##
51
+ auth_client = Crapi::Client.new(base_uri, opts)
52
+ @auth = EdFi::Client::Auth.new(client: auth_client,
53
+ client_id: opts[:client_id],
54
+ client_secret: opts[:client_secret])
55
+ end
56
+
57
+ ## Sets the profile to use for {EdFi::Client#read} / {EdFi::Client#write} calls.
58
+ ##
59
+ ##
60
+ ## @param profile [String, Symbol]
61
+ ## The profile for which {EdFi::Client#read} and {EdFi::Client#write} will generate headers.
62
+ ##
63
+ def profile=(profile)
64
+ @profile = profile&.to_s&.downcase
65
+ end
66
+
67
+ ## rubocop:disable Naming/UncommunicativeMethodParamName
68
+
69
+ ## Returns the header needed to {EdFi::Client#get} a resource with a profile.
70
+ ##
71
+ ##
72
+ ## @param resource [String, Symbol]
73
+ ## The resource to be read.
74
+ ##
75
+ ## @param as [String, Symbol]
76
+ ## The profile to use. If one has already been set via {EdFi::Client#initialize} or
77
+ ## {EdFi::Client#profile=}, this value is optional.
78
+ ##
79
+ ##
80
+ ## @return [HashWithIndifferentAccess]
81
+ ##
82
+ def read(resource, as: nil)
83
+ self.profile = as if as.present?
84
+ mime_type = format(PROFILE_MIME_TYPE, resource: resource, profile: @profile, access: :readable)
85
+
86
+ { 'Accept': mime_type }.with_indifferent_access
87
+ end
88
+ ## rubocop:enable Naming/UncommunicativeMethodParamName
89
+
90
+ ## rubocop:disable Naming/UncommunicativeMethodParamName
91
+
92
+ ## Returns the header needed to {EdFi::Client#delete} / {EdFi::Client#patch} / {EdFi::Client#post}
93
+ ## / {EdFi::Client#put} a resource with a profile.
94
+ ##
95
+ ##
96
+ ## @param resource [String, Symbol]
97
+ ## The resource to be written.
98
+ ##
99
+ ## @param as [String, Symbol]
100
+ ## The profile to use. If one has already been set via {EdFi::Client#initialize} or
101
+ ## {EdFi::Client#profile=}, this value is optional.
102
+ ##
103
+ ##
104
+ ## @return [HashWithIndifferentAccess]
105
+ ##
106
+ def write(resource, as: nil)
107
+ self.profile = as if as.present?
108
+ mime_type = format(PROFILE_MIME_TYPE, resource: resource, profile: @profile, access: :writable)
109
+
110
+ { 'Content-Type': mime_type }.with_indifferent_access
111
+ end
112
+ ## rubocop:enable Naming/UncommunicativeMethodParamName
113
+
114
+ ## CRUD methods ...
115
+
116
+ ## CRUD method: DELETE
117
+ ##
118
+ ## *headers* and *query* are preprocessed for auth and case conversion, but all parameters are
119
+ ## otherwise passed through to Crapi::Proxy#delete.
120
+ ##
121
+ def delete(path, headers: {}, query: {})
122
+ (headers, query) = preprocess(headers, query)
123
+ respond_with super(path, headers: headers, query: query)
124
+ end
125
+
126
+ ## CRUD method: GET
127
+ ##
128
+ ## *headers* and *query* are preprocessed for auth and case conversion, but all parameters are
129
+ ## otherwise passed through to Crapi::Proxy#get.
130
+ ##
131
+ def get(path, headers: {}, query: {})
132
+ (headers, query) = preprocess(headers, query)
133
+ respond_with super(path, headers: headers, query: query)
134
+ end
135
+
136
+ ## CRUD method: PATCH
137
+ ##
138
+ ## *headers*, *query*, and *payload* are preprocessed for auth and case conversion, but all
139
+ ## parameters are otherwise passed through to Crapi::Proxy#patch.
140
+ ##
141
+ def patch(path, headers: {}, query: {}, payload: {})
142
+ (headers, query, payload) = preprocess(headers, query, payload)
143
+ respond_with super(path, headers: headers, query: query, payload: payload)
144
+ end
145
+
146
+ ## CRUD method: POST
147
+ ##
148
+ ## *headers*, *query*, and *payload* are preprocessed for auth and case conversion, but all
149
+ ## parameters are otherwise passed through to Crapi::Proxy#post.
150
+ ##
151
+ def post(path, headers: {}, query: {}, payload: {})
152
+ (headers, query, payload) = preprocess(headers, query, payload)
153
+ respond_with super(path, headers: headers, query: query, payload: payload)
154
+ end
155
+
156
+ ## CRUD method: PUT
157
+ ##
158
+ ## *headers*, *query*, and *payload* are preprocessed for auth and case conversion, but all
159
+ ## parameters are otherwise passed through to Crapi::Proxy#put.
160
+ ##
161
+ def put(path, headers: {}, query: {}, payload: {})
162
+ (headers, query, payload) = preprocess(headers, query, payload)
163
+ respond_with super(path, headers: headers, query: query, payload: payload)
164
+ end
165
+
166
+ ## API segment proxies ...
167
+
168
+ ## Convenience proxy generator for v2.0 API access, also addomg the school year you'd like to
169
+ ## access, if given.
170
+ ##
171
+ ##
172
+ ## @param period [Integer]
173
+ ## The school year to be accessed. (To access the v2.0 API for school year 2017-2018, call
174
+ ## `v2(2017)`.)
175
+ ##
176
+ ##
177
+ ## @return [EdFi::Client::Proxy]
178
+ ##
179
+ def v2(period = nil)
180
+ period = period.to_i
181
+
182
+ @v2 = {} if @v2.nil?
183
+ @v2[period] ||= begin
184
+ path = '/api/v2.0'
185
+ path += "/#{period}" if period.nonzero?
186
+ EdFi::Client::Proxy.new(add: path, to: self)
187
+ end
188
+ end
189
+
190
+ ##
191
+
192
+ private
193
+
194
+ ##
195
+
196
+ ## Generates an auth header, with a valid Bearer token.
197
+ ##
198
+ ##
199
+ ## @return [HashWithIndifferentAccess]
200
+ ##
201
+ def auth_header
202
+ { 'Authorization': "Bearer #{@auth.token}" }.with_indifferent_access
203
+ end
204
+
205
+ ## Carries out preprocessing for headers, query data, and payload data, returning processed
206
+ ## *copies* of the given values.
207
+ ##
208
+ ##
209
+ ## @param headers [Hash]
210
+ ## The headers to preprocess. A copy of this value is returned with auth headers added, so long
211
+ ## as no key conflicts arise.
212
+ ##
213
+ ## @param query [Hash]
214
+ ## The querystring values to preprocess. Keys will be case-converted where necessary.
215
+ ##
216
+ ## @param payload [Hash]
217
+ ## The payload values to preprocess. Keys will be case-converted where necessary.
218
+ ##
219
+ ##
220
+ ## @return [(Hash, Hash, Hash)]
221
+ ##
222
+ def preprocess(headers, query = nil, payload = nil)
223
+ payload = payload.as_json if payload.is_a? EdFi::Client::Response
224
+
225
+ headers = auth_header.merge(headers)
226
+ query = query.deep_transform_keys { |key| key.to_s.camelize(:lower) } if query.is_a? Hash
227
+ payload = payload.deep_transform_keys { |key| key.to_s.camelize(:lower) } if payload.is_a? Hash
228
+
229
+ [headers, query, payload]
230
+ end
231
+
232
+ ## Returns an {EdFi::Client::Response EdFi::Client::Response} for the given API resonse value.
233
+ ##
234
+ ##
235
+ ## @param response [Hash, Array]
236
+ ## The API response Hash/Array to convert to an {EdFi::Client::Response EdFi::Client::Response}.
237
+ ##
238
+ ##
239
+ ## @return [EdFi::Client::Response]
240
+ ##
241
+ def respond_with(response)
242
+ EdFi::Client::Response.new(response, client: self)
243
+ end
244
+ end
@@ -0,0 +1,63 @@
1
+ require 'crapi'
2
+
3
+ class EdFi::Client < Crapi::Client
4
+ ## The {EdFi::Client::AccessToken EdFi::Client::AccessToken} represents an access token, as
5
+ ## returned by "/oauth/token" calls.
6
+ ##
7
+ class AccessToken
8
+ ## An {EdFi::Client::AccessToken EdFi::Client::AccessToken} can be initiialized with the
9
+ ## "/oauth/token" response Hash. If given, an additional "issued_at" Time value helps to more
10
+ ## accurately calculate the token's expiration Time.
11
+ ##
12
+ ##
13
+ ## @param access_token [String]
14
+ ## The actual token value to use as the Bearer token in subsequent requests.
15
+ ##
16
+ ## @param token_type [String]
17
+ ## The token type (e.g. "bearer").
18
+ ##
19
+ ## @param issued_at [Time]
20
+ ## An optional value denoting the Time at which the token was issued.
21
+ ## If unset, defaults to `Time.current`.
22
+ ##
23
+ ## @param expires_in [Numeric]
24
+ ## The token's lifetime, in seconds.
25
+ ##
26
+ def initialize(access_token:, token_type:, issued_at: Time.current, expires_in:)
27
+ @access_token = access_token.dup
28
+ @token_type = token_type.dup
29
+ @issued_at = issued_at.dup
30
+ @expires_in = expires_in.dup
31
+ end
32
+
33
+ ## Gives a copy of the token *value*.
34
+ ##
35
+ ##
36
+ ## @return [String]
37
+ ##
38
+ def token
39
+ @access_token.dup
40
+ end
41
+
42
+ ## Gives the token's *calculated* expiration Time.
43
+ ##
44
+ ##
45
+ ## @return [Time]
46
+ ##
47
+ def expires_at
48
+ return 1.second.ago if @access_token.blank?
49
+ (@issued_at + @expires_in.seconds)
50
+ end
51
+
52
+ ## Denotes whether the token is still "valid", per its (calculated) expiration timesstamp. Note
53
+ ## that a 5-second window is allotted for the request using the token to complete.
54
+ ##
55
+ ##
56
+ ## @return [true,false]
57
+ ##
58
+ def valid?
59
+ safety_window = 5.seconds
60
+ Time.current <= (expires_at - safety_window)
61
+ end
62
+ end
63
+ end