Skip to content

Commit 80fa522

Browse files
authored
Expand Profile API client (#379)
This PR makes some general improvements to the `ProfileApiClient` before expanding it to implement the following additional functionality in Profile API: - Listing students - Getting a student - Updating a student - Deleting a student Overview of other changes in this PR: - Use Ruby's `Data` class to return value objects from some of our `ProfileApiClient` methods. I prefer this to passing hashes around as I think it makes the code easier to reason about and because it means we fail fast if the response we receive from the Profile API is different to what we're expecting. - Configure Faraday to raise exceptions for 4xx and 5xx responses from Profile API. The only one of these responses we can currently handle is the 422 Unprocessable Entity response that can be returned when creating or updating a student. - Raise a ProfileApiClient::UnexpectedResponse exception if we receive any other notionally successful response (i.e. not 4xx or 5xx) than the response we're expecting. - Remove boiler plate code and unnecessary comments ## Examples of using this client to interact with Profile API ```ruby > token = '<your-token>' # Create a school > school = ProfileApiClient.create_school(token:, id: SecureRandom.uuid, code: SchoolCodeGenerator.generate) => #<data ProfileApiClient::School id="bd1ef16c-7f80-4c04-9d2f-8689a7bd4511", schoolCode="06-27-42", updatedAt="2024-07-10T14:24:15.130Z", createdAt="2024-07-10T14:24:15.130Z", discardedAt=nil> # List safeguarding flags - user only has teacher flag > ProfileApiClient.safeguarding_flags(token:) => [#<data ProfileApiClient::SafeguardingFlag id="5f741142-ad7b-41bb-9722-f6ea4b37b2ee", userId="583ba872-b16e-46e1-9f7d-df89d267550d", flag="school:teacher", email="[email protected]", createdAt="2024-07-10T14:22:20.392Z", updatedAt="2024-07-10T14:22:20.392Z", discardedAt=nil>] # Create owner safeguarding flag > ProfileApiClient.create_safeguarding_flag(token:, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:owner]) => nil # Delete teacher safeguarding flag > ProfileApiClient.delete_safeguarding_flag(token:, flag: ProfileApiClient::SAFEGUARDING_FLAGS[:teacher]) => nil # List safeguarding flags - user only has owner flag > ProfileApiClient.safeguarding_flags(token:) => [#<data ProfileApiClient::SafeguardingFlag id="6137f101-ac60-4365-8ec9-91d5ffbd198b", userId="583ba872-b16e-46e1-9f7d-df89d267550d", flag="school:owner", email="[email protected]", createdAt="2024-07-10T14:25:49.912Z", updatedAt="2024-07-10T14:25:49.912Z", discardedAt=nil>] # Create a student > response = ProfileApiClient.create_school_student(token:, school_id: school.id, username: 'username', password: 'password$123', name: 'name') => {:created=>["62045970-5957-4ac0-b8a1-96ae59dac58d"]} > student_id = response[:created].first => "62045970-5957-4ac0-b8a1-96ae59dac58d" # Create a student for non-existent school > ProfileApiClient.create_school_student(token:, school_id: SecureRandom.uuid, username: 'username', password: 'password$123', name: 'name') lib/profile_api_client.rb:105:in 'create_school_student': the server responded with status 404 (Faraday::ResourceNotFound) # Create a student with invalid password > ProfileApiClient.create_school_student(token:, school_id: school.id, username: 'username', password: 'password', name: 'name') lib/profile_api_client.rb:117:in 'rescue in create_school_student': Student not saved in Profile API (status code 422, username 'username', error 'password is invalid') (ProfileApiClient::Student422Error) # Create a student with duplicate username > ProfileApiClient.create_school_student(token:, school_id: school.id, username: 'username', password: 'password$123', name: 'name') lib/profile_api_client.rb:117:in 'rescue in create_school_student': Student not saved in Profile API (status code 422, username 'username', error 'username has already been taken') (ProfileApiClient::Student422Error) # Get student > ProfileApiClient.school_student(token:, school_id: school.id, student_id:) => #<data ProfileApiClient::Student id="62045970-5957-4ac0-b8a1-96ae59dac58d", schoolId="bd1ef16c-7f80-4c04-9d2f-8689a7bd4511", name="name", username="username", createdAt="2024-07-10T14:27:50.147Z", updatedAt="2024-07-10T14:27:50.147Z", discardedAt=nil> # Update student > ProfileApiClient.update_school_student(token:, school_id: school.id, student_id:, username: 'new-username', password: 'new-password', name: 'new-name') => #<data ProfileApiClient::Student id="62045970-5957-4ac0-b8a1-96ae59dac58d", schoolId="bd1ef16c-7f80-4c04-9d2f-8689a7bd4511", name="new-name", username="new-username", createdAt="2024-07-10T14:27:50.147Z", updatedAt="2024-07-10T14:33:19.707Z", discardedAt=nil> # List students > ProfileApiClient.list_school_students(token:, school_id: school.id, student_ids: [student_id]) => [#<data ProfileApiClient::Student id="62045970-5957-4ac0-b8a1-96ae59dac58d", schoolId="bd1ef16c-7f80-4c04-9d2f-8689a7bd4511", name="new-name", username="new-username", createdAt="2024-07-10T14:27:50.147Z", updatedAt="2024-07-10T14:33:19.707Z", discardedAt=nil>] # List students - error unless all student ids belong to school in Profile API > ProfileApiClient.list_school_students(token:, school_id: school.id, student_ids: [student_id, SecureRandom.uuid]) lib/profile_api_client.rb:93:in 'list_school_students': the server responded with status 404 (Faraday::ResourceNotFound) # Delete student > ProfileApiClient.delete_school_student(token:, school_id: school.id, student_id:) => nil ```
2 parents df4557a + 98885af commit 80fa522

File tree

13 files changed

+488
-219
lines changed

13 files changed

+488
-219
lines changed

app/controllers/api/school_students_controller.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ def create_batch
3939
end
4040

4141
def update
42-
result = SchoolStudent::Update.call(school: @school, school_student_params:, token: current_user.token)
42+
result = SchoolStudent::Update.call(
43+
school: @school, student_id: params[:id], school_student_params:, token: current_user.token
44+
)
4345

4446
if result.success?
4547
head :no_content

lib/concepts/school_student/create.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def call(school:, school_student_params:, token:)
77
response = OperationResponse.new
88
response[:student_id] = create_student(school, school_student_params, token)
99
response
10-
rescue ProfileApiClient::CreateStudent422Error => e
10+
rescue ProfileApiClient::Student422Error => e
1111
Sentry.capture_exception(e)
1212
response[:error] = "Error creating school student: #{e.error}"
1313
response

lib/concepts/school_student/delete.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def call(school:, student_id:, token:)
1616
private
1717

1818
def delete_student(school, student_id, token)
19-
ProfileApiClient.delete_school_student(token:, student_id:, organisation_id: school.id)
19+
ProfileApiClient.delete_school_student(token:, student_id:, school_id: school.id)
2020
end
2121
end
2222
end

lib/concepts/school_student/list.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ def call(school:, token:)
1616
private
1717

1818
def list_students(school, token)
19-
response = ProfileApiClient.list_school_students(token:, organisation_id: school.id)
20-
user_ids = response.fetch(:ids)
21-
22-
User.from_userinfo(ids: user_ids)
19+
student_ids = Role.student.where(school:).map(&:user_id)
20+
ProfileApiClient.list_school_students(token:, school_id: school.id, student_ids:).map do |student|
21+
User.new(student.to_h.slice(:id, :username, :name))
22+
end
2323
end
2424
end
2525
end

lib/concepts/school_student/update.rb

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
module SchoolStudent
44
class Update
55
class << self
6-
def call(school:, school_student_params:, token:)
6+
def call(school:, student_id:, school_student_params:, token:)
77
response = OperationResponse.new
8-
update_student(school, school_student_params, token)
8+
update_student(school, student_id, school_student_params, token)
99
response
1010
rescue StandardError => e
1111
Sentry.capture_exception(e)
@@ -15,17 +15,16 @@ def call(school:, school_student_params:, token:)
1515

1616
private
1717

18-
def update_student(school, school_student_params, token)
18+
def update_student(school, student_id, school_student_params, token)
1919
username = school_student_params.fetch(:username, nil)
2020
password = school_student_params.fetch(:password, nil)
2121
name = school_student_params.fetch(:name, nil)
2222

2323
validate(username:, password:, name:)
2424

25-
attributes_to_update = { username:, password:, name: }.compact
26-
return if attributes_to_update.empty?
27-
28-
ProfileApiClient.update_school_student(token:, attributes_to_update:, organisation_id: school.id)
25+
ProfileApiClient.update_school_student(
26+
token:, school_id: school.id, student_id:, username:, password:, name:
27+
)
2928
end
3029

3130
def validate(username:, password:, name:)

lib/profile_api_client.rb

Lines changed: 68 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ class ProfileApiClient
66
owner: 'school:owner'
77
}.freeze
88

9+
School = Data.define(:id, :schoolCode, :updatedAt, :createdAt, :discardedAt)
10+
SafeguardingFlag = Data.define(:id, :userId, :flag, :email, :createdAt, :updatedAt, :discardedAt)
11+
Student = Data.define(:id, :schoolId, :name, :username, :createdAt, :updatedAt, :discardedAt)
12+
913
class Error < StandardError; end
1014

11-
class CreateStudent422Error < Error
15+
class Student422Error < Error
1216
DEFAULT_ERROR = 'unknown error'
1317
ERRORS = {
1418
'ERR_USER_EXISTS' => 'username has already been taken',
@@ -23,13 +27,23 @@ def initialize(error)
2327
@username = error['username']
2428
@error = ERRORS.fetch(error['error'], DEFAULT_ERROR)
2529

26-
super "Student not created in Profile API (status code 422, username '#{@username}', error '#{@error}')"
30+
super "Student not saved in Profile API (status code 422, username '#{@username}', error '#{@error}')"
2731
end
2832
end
2933

30-
class << self
31-
# TODO: Replace with HTTP requests once the profile API has been built.
34+
class UnexpectedResponse < Error
35+
attr_reader :response_status, :response_headers, :response_body
36+
37+
def initialize(response)
38+
@response_status = response.status
39+
@response_headers = response.headers
40+
@response_body = response.body
41+
42+
super "Unexpected response from Profile API (status code #{response.status})"
43+
end
44+
end
3245

46+
class << self
3347
def create_school(token:, id:, code:)
3448
return { 'id' => id, 'schoolCode' => code } if ENV['BYPASS_OAUTH'].present?
3549

@@ -40,134 +54,51 @@ def create_school(token:, id:, code:)
4054
}
4155
end
4256

43-
raise "School not created in Profile API (status code #{response.status})" unless response.status == 201
57+
raise UnexpectedResponse, response unless response.status == 201
4458

45-
response.body
59+
School.new(**response.body)
4660
end
4761

48-
# The API should enforce these constraints:
49-
# - The token has the school-owner or school-teacher role for the given organisation ID
50-
# - The token user or given user should not be under 13
51-
# - The email must be verified
52-
#
53-
# The API should respond:
54-
# - 422 Unprocessable if the constraints are not met
55-
def list_school_owners(token:, organisation_id:)
56-
return [] if token.blank?
57-
58-
_ = organisation_id
59-
60-
# TODO: We should make Faraday raise a Ruby error for a non-2xx status
61-
# code so that SchoolOwner::Invite propagates the error in the response.
62-
response = { 'ids' => ['99999999-9999-9999-9999-999999999999'] }
63-
response.deep_symbolize_keys
62+
def list_school_owners(*)
63+
{}
6464
end
6565

66-
# The API should enforce these constraints:
67-
# - The token has the school-owner role for the given organisation ID
68-
# - The token user or given user should not be under 13
69-
# - The email must be verified
70-
#
71-
# The API should respond:
72-
# - 404 Not Found if the user doesn't exist
73-
# - 422 Unprocessable if the constraints are not met
74-
def invite_school_owner(token:, email_address:, organisation_id:)
75-
return nil if token.blank?
76-
77-
_ = email_address
78-
_ = organisation_id
79-
80-
# TODO: We should make Faraday raise a Ruby error for a non-2xx status
81-
# code so that SchoolOwner::Invite propagates the error in the response.
82-
response = {}
83-
response.deep_symbolize_keys
66+
def invite_school_owner(*)
67+
{}
8468
end
8569

86-
# The API should enforce these constraints:
87-
# - The token has the school-owner role for the given organisation ID
88-
# - The token user should not be under 13
89-
# - The email must be verified
90-
#
91-
# The API should respond:
92-
# - 404 Not Found if the user doesn't exist
93-
# - 422 Unprocessable if the constraints are not met
94-
def remove_school_owner(token:, owner_id:, organisation_id:)
95-
return nil if token.blank?
96-
97-
_ = owner_id
98-
_ = organisation_id
99-
100-
# TODO: We should make Faraday raise a Ruby error for a non-2xx status
101-
# code so that SchoolOwner::Remove propagates the error in the response.
102-
response = {}
103-
response.deep_symbolize_keys
70+
def remove_school_owner(*)
71+
{}
10472
end
10573

106-
# The API should enforce these constraints:
107-
# - The token has the school-owner or school-teacher role for the given organisation ID
108-
# - The token user or given user should not be under 13
109-
# - The email must be verified
110-
#
111-
# The API should respond:
112-
# - 422 Unprocessable if the constraints are not met
113-
def list_school_teachers(token:, organisation_id:)
114-
return [] if token.blank?
115-
116-
_ = organisation_id
74+
def list_school_teachers(*)
75+
{}
76+
end
11777

118-
# TODO: We should make Faraday raise a Ruby error for a non-2xx status
119-
# code so that SchoolOwner::Invite propagates the error in the response.
120-
response = { 'ids' => ['99999999-9999-9999-9999-999999999999'] }
121-
response.deep_symbolize_keys
78+
def remove_school_teacher(*)
79+
{}
12280
end
12381

124-
# The API should enforce these constraints:
125-
# - The token has the school-owner role for the given organisation ID
126-
# - The token user should not be under 13
127-
# - The email must be verified
128-
#
129-
# The API should respond:
130-
# - 404 Not Found if the user doesn't exist
131-
# - 422 Unprocessable if the constraints are not met
132-
def remove_school_teacher(token:, teacher_id:, organisation_id:)
133-
return nil if token.blank?
82+
def school_student(token:, school_id:, student_id:)
83+
response = connection(token).get("/api/v1/schools/#{school_id}/students/#{student_id}")
13484

135-
_ = teacher_id
136-
_ = organisation_id
85+
raise UnexpectedResponse, response unless response.status == 200
13786

138-
# TODO: We should make Faraday raise a Ruby error for a non-2xx status
139-
# code so that SchoolOwner::Remove propagates the error in the response.
140-
response = {}
141-
response.deep_symbolize_keys
87+
Student.new(**response.body)
14288
end
14389

144-
# The API should enforce these constraints:
145-
# - The token has the school-owner or school-teacher role for the given organisation ID
146-
# - The token user or given user should not be under 13
147-
# - The email must be verified
148-
#
149-
# The API should respond:
150-
# - 422 Unprocessable if the constraints are not met
151-
def list_school_students(token:, organisation_id:)
90+
def list_school_students(token:, school_id:, student_ids:)
15291
return [] if token.blank?
15392

154-
_ = organisation_id
93+
response = connection(token).post("/api/v1/schools/#{school_id}/students/list") do |request|
94+
request.body = student_ids
95+
end
96+
97+
raise UnexpectedResponse, response unless response.status == 200
15598

156-
# TODO: We should make Faraday raise a Ruby error for a non-2xx status
157-
# code so that SchoolOwner::Invite propagates the error in the response.
158-
response = { 'ids' => ['99999999-9999-9999-9999-999999999999'] }
159-
response.deep_symbolize_keys
99+
response.body.map { |attrs| Student.new(**attrs.symbolize_keys) }
160100
end
161101

162-
# The API should enforce these constraints:
163-
# - The token has the school-owner or school-teacher role for the given organisation ID
164-
# - The token user should not be under 13
165-
# - The email must be verified
166-
#
167-
# The API should respond:
168-
# - 404 Not Found if the user doesn't exist
169-
# - 422 Unprocessable if the constraints are not met
170-
# rubocop:disable Metrics/AbcSize
171102
def create_school_student(token:, username:, password:, name:, school_id:)
172103
return nil if token.blank?
173104

@@ -179,81 +110,61 @@ def create_school_student(token:, username:, password:, name:, school_id:)
179110
}]
180111
end
181112

182-
raise CreateStudent422Error, response.body['errors'].first if response.status == 422
183-
raise "Student not created in Profile API (status code #{response.status})" unless response.status == 201
113+
raise UnexpectedResponse, response unless response.status == 201
184114

185115
response.body.deep_symbolize_keys
116+
rescue Faraday::UnprocessableEntityError => e
117+
raise Student422Error, JSON.parse(e.response_body)['errors'].first
186118
end
187-
# rubocop:enable Metrics/AbcSize
188119

189-
# The API should enforce these constraints:
190-
# - The token has the school-owner or school-teacher role for the given organisation ID
191-
# - The token user should not be under 13
192-
# - The email must be verified
193-
# - The student_id must be a school-student for the given organisation ID
194-
#
195-
# The API should respond:
196-
# - 404 Not Found if the user doesn't exist
197-
# - 422 Unprocessable if the constraints are not met
198-
def update_school_student(token:, attributes_to_update:, organisation_id:)
120+
# rubocop:disable Metrics/AbcSize
121+
def update_school_student(token:, school_id:, student_id:, name: nil, username: nil, password: nil) # rubocop:disable Metrics/ParameterLists
199122
return nil if token.blank?
200123

201-
_ = attributes_to_update
202-
_ = organisation_id
124+
response = connection(token).patch("/api/v1/schools/#{school_id}/students/#{student_id}") do |request|
125+
request.body = {
126+
name: name&.strip,
127+
username: username&.strip,
128+
password: password&.strip
129+
}.compact
130+
end
131+
132+
raise UnexpectedResponse, response unless response.status == 200
203133

204-
# TODO: We should make Faraday raise a Ruby error for a non-2xx status
205-
# code so that SchoolOwner::Remove propagates the error in the response.
206-
response = {}
207-
response.deep_symbolize_keys
134+
Student.new(**response.body)
135+
rescue Faraday::UnprocessableEntityError => e
136+
raise Student422Error, JSON.parse(e.response_body)['errors'].first
208137
end
138+
# rubocop:enable Metrics/AbcSize
209139

210-
# The API should enforce these constraints:
211-
# - The token has the school-owner role for the given organisation ID
212-
# - The token user should not be under 13
213-
# - The email must be verified
214-
# - The student_id must be a school-student for the given organisation ID
215-
#
216-
# The API should respond:
217-
# - 404 Not Found if the user doesn't exist
218-
# - 422 Unprocessable if the constraints are not met
219-
def delete_school_student(token:, student_id:, organisation_id:)
140+
def delete_school_student(token:, school_id:, student_id:)
220141
return nil if token.blank?
221142

222-
_ = student_id
223-
_ = organisation_id
143+
response = connection(token).delete("/api/v1/schools/#{school_id}/students/#{student_id}")
224144

225-
# TODO: We should make Faraday raise a Ruby error for a non-2xx status
226-
# code so that SchoolOwner::Remove propagates the error in the response.
227-
response = {}
228-
response.deep_symbolize_keys
145+
raise UnexpectedResponse, response unless response.status == 204
229146
end
230147

231148
def safeguarding_flags(token:)
232149
response = connection(token).get('/api/v1/safeguarding-flags')
233150

234-
unless response.status == 200
235-
raise "Safeguarding flags cannot be retrieved from Profile API (status code #{response.status})"
236-
end
151+
raise UnexpectedResponse, response unless response.status == 200
237152

238-
response.body.map(&:deep_symbolize_keys)
153+
response.body.map { |flag| SafeguardingFlag.new(**flag.symbolize_keys) }
239154
end
240155

241156
def create_safeguarding_flag(token:, flag:)
242157
response = connection(token).post('/api/v1/safeguarding-flags') do |request|
243158
request.body = { flag: }
244159
end
245160

246-
return if response.status == 201 || response.status == 303
247-
248-
raise "Safeguarding flag not created in Profile API (status code #{response.status})"
161+
raise UnexpectedResponse, response unless [201, 303].include?(response.status)
249162
end
250163

251164
def delete_safeguarding_flag(token:, flag:)
252165
response = connection(token).delete("/api/v1/safeguarding-flags/#{flag}")
253166

254-
return if response.status == 204
255-
256-
raise "Safeguarding flag not deleted from Profile API (status code #{response.status})"
167+
raise UnexpectedResponse, response unless response.status == 204
257168
end
258169

259170
private
@@ -262,6 +173,7 @@ def connection(token)
262173
Faraday.new(ENV.fetch('IDENTITY_URL')) do |faraday|
263174
faraday.request :json
264175
faraday.response :json
176+
faraday.response :raise_error
265177
faraday.headers = {
266178
'Accept' => 'application/json',
267179
'Authorization' => "Bearer #{token}",

0 commit comments

Comments
 (0)