Skip to content

Commit

Permalink
Merge pull request #793 from pulibrary/airtable
Browse files Browse the repository at this point in the history
Basic functionality for airtable staff list
  • Loading branch information
christinach authored Jun 21, 2024
2 parents ebed885 + 12dfd32 commit 1568f67
Show file tree
Hide file tree
Showing 12 changed files with 351 additions and 0 deletions.
19 changes: 19 additions & 0 deletions app/models/air_table_staff/csv_builder.rb
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions app/models/air_table_staff/json_value_extractor.rb
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions app/models/air_table_staff/record_list.rb
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions app/models/air_table_staff/staff_directory_mapping.rb
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions app/models/air_table_staff/staff_directory_person.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions spec/fixtures/files/air_table/records_no_offset.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]"
}
}
]
}
21 changes: 21 additions & 0 deletions spec/fixtures/files/air_table/records_with_offset.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]"
}
}
], "offset": "naeQu2ul/Ash6eiQu"
}
22 changes: 22 additions & 0 deletions spec/models/air_table_staff/csv_builder_spec.rb
Original file line number Diff line number Diff line change
@@ -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,[email protected],123 Stokes,Stokes,Stokes,,,,Virtual Reality
END_CSV
directory = described_class.new
expect(directory.to_csv).to eq(expected)
end
end
19 changes: 19 additions & 0 deletions spec/models/air_table_staff/json_value_extractor_spec.rb
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions spec/models/air_table_staff/record_list_spec.rb
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions spec/models/air_table_staff/staff_directory_person_spec.rb
Original file line number Diff line number Diff line change
@@ -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': '[email protected]',
'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
'[email protected]', # 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

0 comments on commit 1568f67

Please sign in to comment.