diff --git a/app/models/air_table_staff/csv_builder.rb b/app/models/air_table_staff/csv_builder.rb new file mode 100644 index 00000000..d1c43b9a --- /dev/null +++ b/app/models/air_table_staff/csv_builder.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module AirTableStaff + # This class is responsible for creating a CSV out of the + # data from Airtable + class CSVBuilder + def to_csv + @csv ||= CSV.generate do |csv| + # Add the headers... + csv << StaffDirectoryMapping.new.to_a + + # Then add the data + AirTableStaff::RecordList.new.to_a.each do |record| + csv << record.to_a + end + end + end + end +end diff --git a/app/models/air_table_staff/json_value_extractor.rb b/app/models/air_table_staff/json_value_extractor.rb new file mode 100644 index 00000000..da515c25 --- /dev/null +++ b/app/models/air_table_staff/json_value_extractor.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module AirTableStaff + # This class is responsible for extracting a single + # value from a json hash, based on the criteria in + # the field hash + class JsonValueExtractor + def initialize(json:, field:) + @json = json + @field = field + end + + def extract + raw_value = json[field[:airtable_field]] + transformer = field[:transformer] + transformer ? transformer.call(raw_value) : raw_value + end + + private + + attr_reader :json, :field + end +end diff --git a/app/models/air_table_staff/record_list.rb b/app/models/air_table_staff/record_list.rb new file mode 100644 index 00000000..9e67dde8 --- /dev/null +++ b/app/models/air_table_staff/record_list.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true +module AirTableStaff + # This class is responsible for maintaining a list + # of staff records taken from the Airtable API + class RecordList + def initialize + @base_url = "https://api.airtable.com/v0/appv7XA5FWS7DG9oe/Synchronized%20Staff%20Directory%20View?view=Grid%20view" + @token = LibJobs.config[:airtable_token] + end + + # The library staff list is split into several pages. + # For each page (except the last), Airtable gives us an + # offset, which is how we request the next page. + def to_a(offset: nil) + @as_array ||= begin + json = get_json(offset:) + records = json[:records].map do |row| + AirTableStaff::StaffDirectoryPerson.new(row[:fields]) + end + offset = json[:offset] + + # If we have an offset, call this method recursively + # (to fetch additional pages of data), until airtable + # no longer gives us an offset + records += to_a(offset:) if offset + records + end + end + + private + + attr_reader :base_url, :token + + def get_json(offset: nil) + JSON.parse(response(offset:).body, symbolize_names: true) + end + + def response(offset: nil) + url = url_with_optional_offset(offset:) + request = Net::HTTP::Get.new(url) + request["Authorization"] = "Bearer #{token}" + Net::HTTP.start(url.hostname, url.port, { use_ssl: true }) do |http| + http.request(request) + end + end + + def url_with_optional_offset(offset: nil) + URI.parse(offset ? "#{base_url}&offset=#{offset}" : base_url) + end + end +end diff --git a/app/models/air_table_staff/staff_directory_mapping.rb b/app/models/air_table_staff/staff_directory_mapping.rb new file mode 100644 index 00000000..fabc0285 --- /dev/null +++ b/app/models/air_table_staff/staff_directory_mapping.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +module AirTableStaff + # This class is responsible for describing the fields in the + # airtable and the resultant CSV, and how they are related + class StaffDirectoryMapping + # A mapping of: + # - an airtable field (airtable_field) + # - a column name we want for our csv (our_field) + # - an optional "transformer" lambda for converting the airtable values + # to the desired format for the csv + # rubocop:disable Metrics/MethodLength + def fields + [ + { airtable_field: :'University ID', our_field: :puid }, + { airtable_field: :netid, our_field: :netid }, + { airtable_field: :'University Phone', our_field: :phone }, + { airtable_field: :'pul:Preferred Name', our_field: :name }, + { airtable_field: :'Last Name', our_field: :lastName }, + { airtable_field: :'First Name', our_field: :firstName }, + { airtable_field: :Email, our_field: :email }, + { airtable_field: :Address, our_field: :address }, + { airtable_field: :'pul:Building', our_field: :building }, + { airtable_field: :Division, our_field: :department }, + { airtable_field: :'pul:Department', our_field: :division }, + { airtable_field: :'pul:Unit', our_field: :unit }, + { airtable_field: :'pul:Team', our_field: :team }, + { airtable_field: :'Area of Study', our_field: :areasOfStudy, transformer: ->(areas) { areas&.join('//') } } + ] + end + # rubocop:enable Metrics/MethodLength + + def to_a + @as_array ||= fields.pluck(:our_field) + end + end +end diff --git a/app/models/air_table_staff/staff_directory_person.rb b/app/models/air_table_staff/staff_directory_person.rb new file mode 100644 index 00000000..c14550a9 --- /dev/null +++ b/app/models/air_table_staff/staff_directory_person.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module AirTableStaff + # This class is responsible for extracting information about + # a person from the airtable staff directory JSON, according + # to the mapping from the StaffDirectoryMapping class. + class StaffDirectoryPerson + def initialize(json) + @json = json + @mapping = StaffDirectoryMapping.new + end + + def to_a + @array_version ||= mapping.fields.map do |field| + JsonValueExtractor.new(field:, json:).extract + end + end + + private + + attr_reader :json, :mapping + end +end diff --git a/config/config.yml b/config/config.yml index bcfcbe42..fa9633cc 100644 --- a/config/config.yml +++ b/config/config.yml @@ -28,6 +28,7 @@ defaults: &defaults alma_region: <%= ENV["ALMA_REGION"] %> alma_api_key: <%= ENV["ALMA_CONFIG_API_KEY"] || "1234" %> alma_bib_api_key: <%= ENV["ALMA_BIB_API_KEY"] || "1234" %> + airtable_token: <%= ENV["AIRTABLE_TOKEN"] || 'FAKE_AIRTABLE_TOKEN' %> development: <<: *defaults diff --git a/spec/fixtures/files/air_table/records_no_offset.json b/spec/fixtures/files/air_table/records_no_offset.json new file mode 100644 index 00000000..3d3efdf8 --- /dev/null +++ b/spec/fixtures/files/air_table/records_no_offset.json @@ -0,0 +1,21 @@ +{ + "records": [ + { + "id": "recrhIUhcJw3lUlRE", + "createdTime": "2024-01-30T22:16:16.000Z", + "fields": { + "Address": "123 Stokes", + "Area of Study": ["Virtual Reality"], + "Division": "Stokes", + "pul:Building": "Stokes", + "Last Name": "Librarian", + "First Name": "Phillip", + "University ID": "123", + "netid": "ab123", + "University Phone": "(123) 123-1234", + "pul:Preferred Name": "Phillip Librarian", + "Email": "ab123@princeton.edu" + } + } + ] +} diff --git a/spec/fixtures/files/air_table/records_with_offset.json b/spec/fixtures/files/air_table/records_with_offset.json new file mode 100644 index 00000000..7f7b026b --- /dev/null +++ b/spec/fixtures/files/air_table/records_with_offset.json @@ -0,0 +1,21 @@ +{ + "records": [ + { + "id": "recrhIUhcJw3lUlRE", + "createdTime": "2025-01-30T22:16:16.000Z", + "fields": { + "Address": "Firestone A floor", + "Area of Study": ["Cinema history", "Robots"], + "Division": "Special Collections", + "pul:Building": "Firestone", + "Last Name": "Carmant", + "First Name": "Drema", + "University ID": "456", + "netid": "zz99", + "University Phone": "(123) 555-5555", + "pul:Preferred Name": "Drema Carmant", + "Email": "zz99@princeton.edu" + } + } + ], "offset": "naeQu2ul/Ash6eiQu" +} diff --git a/spec/models/air_table_staff/csv_builder_spec.rb b/spec/models/air_table_staff/csv_builder_spec.rb new file mode 100644 index 00000000..bad732b0 --- /dev/null +++ b/spec/models/air_table_staff/csv_builder_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe AirTableStaff::CSVBuilder do + before do + stub_request(:get, "https://api.airtable.com/v0/appv7XA5FWS7DG9oe/Synchronized%20Staff%20Directory%20View?view=Grid%20view") + .with( + headers: { + 'Authorization' => 'Bearer FAKE_AIRTABLE_TOKEN' + } + ) + .to_return(status: 200, body: File.read(file_fixture('air_table/records_no_offset.json')), headers: {}) + end + it 'creates a CSV object with data from the HTTP API' do + expected = <<~END_CSV + puid,netid,phone,name,lastName,firstName,email,address,building,department,division,unit,team,areasOfStudy + 123,ab123,(123) 123-1234,Phillip Librarian,Librarian,Phillip,ab123@princeton.edu,123 Stokes,Stokes,Stokes,,,,Virtual Reality + END_CSV + directory = described_class.new + expect(directory.to_csv).to eq(expected) + end +end diff --git a/spec/models/air_table_staff/json_value_extractor_spec.rb b/spec/models/air_table_staff/json_value_extractor_spec.rb new file mode 100644 index 00000000..f863beba --- /dev/null +++ b/spec/models/air_table_staff/json_value_extractor_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe AirTableStaff::JsonValueExtractor do + context 'when there is no transformer lambda provided in field config' do + it 'extracts the text verbatim' do + json = { cat: 'tabby cat' } + field = { airtable_field: :cat } + expect(described_class.new(json:, field:).extract).to eq('tabby cat') + end + end + context 'when there is a transformer lambda provided in field config' do + it 'extracts the text verbatim' do + json = { cat: 'tabby cat' } + field = { airtable_field: :cat, transformer: ->(value) { "My favorite cat is #{value}" } } + expect(described_class.new(json:, field:).extract).to eq('My favorite cat is tabby cat') + end + end +end diff --git a/spec/models/air_table_staff/record_list_spec.rb b/spec/models/air_table_staff/record_list_spec.rb new file mode 100644 index 00000000..e5b0e032 --- /dev/null +++ b/spec/models/air_table_staff/record_list_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +require 'rails_helper' + +BASE_AIRTABLE_URL = 'https://api.airtable.com/v0/appv7XA5FWS7DG9oe/Synchronized%20Staff%20Directory%20View?view=Grid%20view' + +RSpec.describe AirTableStaff::RecordList do + context 'when the airtable response is not paginated' do + before do + stub_request(:get, BASE_AIRTABLE_URL) + .with( + headers: { + 'Authorization' => 'Bearer FAKE_AIRTABLE_TOKEN' + } + ) + .to_return(status: 200, body: File.read(file_fixture('air_table/records_no_offset.json')), headers: {}) + end + it 'creates an array with data from a single call to the HTTP API' do + list = described_class.new.to_a + + expect(list.length).to eq(1) + + first_person = list[0].to_a + expect(first_person[0]).to eq('123') # puid + expect(first_person[3]).to eq('Phillip Librarian') # name + + expect(WebMock).to have_requested(:get, BASE_AIRTABLE_URL).once + end + end + context 'when the airtable response is paginated' do + before do + stub_request(:get, BASE_AIRTABLE_URL) + .with( + headers: { + 'Authorization' => 'Bearer FAKE_AIRTABLE_TOKEN' + } + ) + .to_return(status: 200, body: File.read(file_fixture('air_table/records_with_offset.json')), headers: {}) + stub_request(:get, "#{BASE_AIRTABLE_URL}&offset=naeQu2ul/Ash6eiQu") + .with( + headers: { + 'Authorization' => 'Bearer FAKE_AIRTABLE_TOKEN' + } + ) + .to_return(status: 200, body: File.read(file_fixture('air_table/records_no_offset.json')), headers: {}) + end + it 'creates an array with data from multiple calls to the HTTP API' do + list = described_class.new.to_a + + expect(list.length).to eq(2) + + first_person = list[0].to_a + expect(first_person[0]).to eq('456') # puid + expect(first_person[3]).to eq('Drema Carmant') # name + + second_person = list[1].to_a + expect(second_person[0]).to eq('123') # puid + expect(second_person[3]).to eq('Phillip Librarian') # name + + expect(WebMock).to have_requested(:get, BASE_AIRTABLE_URL).once + expect(WebMock).to have_requested(:get, "#{BASE_AIRTABLE_URL}&offset=naeQu2ul/Ash6eiQu").once + end + context 'when we run it multiple times' do + it 'gives us the same data without additional network calls' do + list = described_class.new + first_time = list.to_a + second_time = list.to_a + + expect(first_time).to eq(second_time) + expect(WebMock).to have_requested(:get, BASE_AIRTABLE_URL).once + expect(WebMock).to have_requested(:get, "#{BASE_AIRTABLE_URL}&offset=naeQu2ul/Ash6eiQu").once + end + end + end +end diff --git a/spec/models/air_table_staff/staff_directory_person_spec.rb b/spec/models/air_table_staff/staff_directory_person_spec.rb new file mode 100644 index 00000000..c37eb24e --- /dev/null +++ b/spec/models/air_table_staff/staff_directory_person_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe AirTableStaff::StaffDirectoryPerson do + describe '#to_a' do + it 'uses the order from the mapping' do + json = { + 'pul:Preferred Name': 'Sage Archivist', + 'University ID': '987654321', + 'netid': 'ab1234', + 'University Phone': '(609) 555-1234', + 'Last Name': 'Archivist', + 'First Name': 'Phoenix', + 'Email': 'test@princeton.edu', + 'Address': '123 Lewis Library', + 'pul:Building': 'Stokes Library', + 'Division': 'ReCAP', + 'pul:Department': 'Cataloging and Metadata Services', + 'pul:Unit': 'Rare Books Cataloging Team', + 'pul:Team': 'IT, Discovery and Access Services', + 'Area of Study': ['Chemistry', 'African American Studies'] + } + expected = [ + '987654321', # puid + 'ab1234', # netid + '(609) 555-1234', # phone + 'Sage Archivist', # name + 'Archivist', # lastName + 'Phoenix', # firstName + 'test@princeton.edu', # email + '123 Lewis Library', # address + 'Stokes Library', # building + 'ReCAP', # department + 'Cataloging and Metadata Services', # division + 'Rare Books Cataloging Team', # unit + 'IT, Discovery and Access Services', # team + 'Chemistry//African American Studies' # areasOfStudy + ] + + expect(described_class.new(json).to_a).to eq(expected) + end + end +end