-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathprofile_api_client.rb
More file actions
277 lines (215 loc) · 9.1 KB
/
profile_api_client.rb
File metadata and controls
277 lines (215 loc) · 9.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# frozen_string_literal: true
class ProfileApiClient
SAFEGUARDING_FLAGS = {
teacher: 'school:teacher',
owner: 'school:owner'
}.freeze
# rubocop:disable Naming/MethodName
School = Data.define(:id, :schoolCode, :studentEmailDomains, :updatedAt, :createdAt, :discardedAt)
SafeguardingFlag = Data.define(:id, :userId, :schoolId, :flag, :email, :createdAt, :updatedAt, :discardedAt)
Student = Data.define(:id, :schoolId, :name, :username, :createdAt, :updatedAt, :discardedAt, :email, :ssoProviders)
# rubocop:enable Naming/MethodName
class Error < StandardError; end
class Student422Error < StandardError
attr_reader :errors
def initialize(errors)
@errors = errors
if errors.is_a?(Hash)
super(errors['errorCode'] || errors['message'])
else
super()
end
end
end
class UnauthorizedError < Error; end
class UnexpectedResponse < Error
attr_reader :response_status, :response_headers, :response_body
def initialize(response)
@response_status = response.status
@response_headers = response.headers
@response_body = response.body
super("Unexpected response from Profile API (status code #{response.status})")
end
end
class << self
def check_auth(token:)
return true if ENV['BYPASS_OAUTH'].present?
response = connection(token).get('/api/v1/access')
response.status == 200
rescue Faraday::BadRequestError, Faraday::UnauthorizedError
false
end
def create_school(token:, id:, code:, school_email_domains: [])
return { 'id' => id, 'schoolCode' => code, 'studentEmailDomains' => school_email_domains } if ENV['BYPASS_OAUTH'].present?
response = connection(token).post('/api/v1/schools') do |request|
request.body = {
id:,
schoolCode: code,
studentEmailDomains: school_email_domains
}
end
unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 201
School.new(**response.body)
end
def school_student(token:, school_id:, student_id:)
response = connection(token).get("/api/v1/schools/#{school_id}/students/#{student_id}")
unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 200
build_student(response.body)
end
def list_school_students(token:, school_id:, student_ids:)
return [] if token.blank?
response = connection(token).post("/api/v1/schools/#{school_id}/students/list") do |request|
request.body = student_ids
end
unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 200
response.body.map { |attrs| build_student(attrs) }
end
def create_school_student(token:, username:, password:, name:, school_id:)
return nil if token.blank?
response = connection(token).post("/api/v1/schools/#{school_id}/students") do |request|
request.body = [{
name: name.strip,
username: username.strip,
password: password.strip
}]
end
unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 201
response.body.deep_symbolize_keys
rescue Faraday::BadRequestError => e
raise Student422Error, JSON.parse(e.response_body)['errors'].first
end
def validate_school_students(token:, students:, school_id:)
return nil if token.blank?
students = Array(students)
endpoint = "/api/v1/schools/#{school_id}/students/preflight-student-upload"
response = connection(token).post(endpoint) do |request|
request.body = students.to_json
request.headers['Content-Type'] = 'application/json'
end
unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 200
rescue Faraday::UnprocessableEntityError => e
raise Student422Error, JSON.parse(e.response_body)['errors']
end
def create_school_students(token:, students:, school_id:, preflight: false)
return nil if token.blank?
students = Array(students)
endpoint = "/api/v1/schools/#{school_id}/students"
endpoint += '/preflight' if preflight
response = connection(token).post(endpoint) do |request|
request.body = students.to_json
request.headers['Content-Type'] = 'application/json'
end
unauthorized!(response)
raise UnexpectedResponse, response unless [200, 201].include?(response.status)
response.body.deep_symbolize_keys
rescue Faraday::BadRequestError => e
handle_student_creation_error(e)
end
def create_school_students_sso(token:, students:, school_id:)
return nil if token.blank?
students = Array(students)
endpoint = "/api/v1/schools/#{school_id}/students/sso"
response = connection(token).post(endpoint) do |request|
request.body = students.to_json
request.headers['Content-Type'] = 'application/json'
end
unauthorized!(response)
raise UnexpectedResponse, response unless [200, 201].include?(response.status)
response.body.map(&:deep_symbolize_keys)
rescue Faraday::BadRequestError => e
handle_student_creation_error(e)
end
def update_school_student(token:, school_id:, student_id:, name: nil, username: nil, password: nil) # rubocop:disable Metrics/ParameterLists
return nil if token.blank?
response = connection(token).patch("/api/v1/schools/#{school_id}/students/#{student_id}") do |request|
request.body = {
name: name&.strip,
username: username&.strip,
password: password&.strip
}.compact
end
unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 200
build_student(response.body)
rescue Faraday::BadRequestError => e
raise Student422Error, JSON.parse(e.response_body)['errors'].first
end
def delete_school_student(token:, school_id:, student_id:)
return nil if token.blank?
response = connection(token).delete("/api/v1/schools/#{school_id}/students/#{student_id}")
unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 204
end
def safeguarding_flags(token:)
response = connection(token).get('/api/v1/safeguarding-flags')
unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 200
response.body.map { |flag| SafeguardingFlag.new(**flag.symbolize_keys) }
end
def create_safeguarding_flag(token:, flag:, email:, school_id:)
response = connection(token).post('/api/v1/safeguarding-flags') do |request|
request.body = { flag:, email:, schoolId: school_id }
end
unauthorized!(response)
raise UnexpectedResponse, response unless [201, 303].include?(response.status)
end
def delete_safeguarding_flag(token:, flag:)
response = connection(token).delete("/api/v1/safeguarding-flags/#{flag}")
unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 204
end
def update_school_email_domains(token:, school_id:, school_email_domains: [])
return { 'id' => school_id, 'studentEmailDomains' => school_email_domains } if ENV['BYPASS_OAUTH'].present?
response = connection(token).patch("/api/v1/schools/#{school_id}") do |request|
request.body = {
studentEmailDomains: school_email_domains
}
end
unauthorized!(response)
raise UnexpectedResponse, response unless response.status == 200
School.new(**response.body)
end
private
def connection(token)
Faraday.new(ENV.fetch('IDENTITY_URL')) do |faraday|
faraday.request :json
faraday.response :json
faraday.response :raise_error, allowed_statuses: [401]
faraday.headers = {
'Accept' => 'application/json',
'Authorization' => "Bearer #{token}",
'X-API-KEY' => ENV.fetch('PROFILE_API_KEY')
}
end
end
def handle_student_creation_error(faraday_error)
raw_error = JSON.parse(faraday_error.response_body)
# Profile returns an array for standard errors, and json for bulk validations
if raw_error.is_a?(Array)
raise Error, raw_error.first['message']
elsif raw_error['errors']
raise Student422Error, raw_error['errors']
else
raise Student422Error, 'An unknown error occurred'
end
end
def build_student(attrs)
symbolized_attrs = attrs.symbolize_keys
# As of 30/09/25 we need these defaults for backwards compatibility until profile SSO changes are released.
# (I was tempted to refactor this handling to be more flexible, however with major profile changes around
# the corner, it makes more sense to stick with this approach for now)
symbolized_attrs[:email] ||= nil
symbolized_attrs[:ssoProviders] ||= []
Student.new(**symbolized_attrs)
end
def unauthorized!(response)
# The API is only available to verified non-student users that are over 13. Others get a 401.
raise UnauthorizedError, 'Profile API unauthorized' if response.status == 401
end
end
end