Commit d829c629 authored by Zac Wood's avatar Zac Wood
Browse files

Merge branch '13-api-docs' into 'master'

Resolve "Add API Documentation"

Closes #13

See merge request srct/schedules!15
parents eb721652 cb6416d6
stages: stages:
- test - test
- build - build
test_api: test_api:
image: ruby:2.5 image: ruby:2.5
stage: test stage: test
...@@ -10,6 +10,7 @@ test_api: ...@@ -10,6 +10,7 @@ test_api:
- bundle install - bundle install
- rails db:migrate - rails db:migrate
- rails test - rails test
- rubocop
test_web: test_web:
image: node:9 image: node:9
......
--require spec_helper
...@@ -12,7 +12,7 @@ gem 'sqlite3' ...@@ -12,7 +12,7 @@ gem 'sqlite3'
# Use Puma as the app server # Use Puma as the app server
gem 'puma', '~> 3.7' gem 'puma', '~> 3.7'
# Use SCSS for stylesheets # Use SCSS for stylesheets
gem 'sass-rails', '~> 5.0'
# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks # Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
gem 'turbolinks', '~> 5' gem 'turbolinks', '~> 5'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
...@@ -47,8 +47,26 @@ end ...@@ -47,8 +47,26 @@ end
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
# HTTP requests
gem 'httparty' gem 'httparty'
# Working with iCalendar
gem 'icalendar' gem 'icalendar'
# Parsing HTML
gem 'nokogiri' gem 'nokogiri'
# Easily deal with CORS
gem 'rack-cors', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'
# Parsing Excel files
gem 'rubyXL' gem 'rubyXL'
# Linting
gem "rubocop", "~> 0.58.2"
# API documentation
gem 'apipie-rails'
# Markdown for API docs
gem 'maruku'
...@@ -40,7 +40,10 @@ GEM ...@@ -40,7 +40,10 @@ GEM
tzinfo (~> 1.1) tzinfo (~> 1.1)
addressable (2.5.2) addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0) public_suffix (>= 2.0.2, < 4.0)
apipie-rails (0.5.10)
rails (>= 4.1)
arel (8.0.0) arel (8.0.0)
ast (2.4.0)
bindex (0.5.0) bindex (0.5.0)
builder (3.2.3) builder (3.2.3)
byebug (10.0.2) byebug (10.0.2)
...@@ -65,6 +68,7 @@ GEM ...@@ -65,6 +68,7 @@ GEM
i18n (1.0.1) i18n (1.0.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
icalendar (2.4.1) icalendar (2.4.1)
jaro_winkler (1.5.1)
jbuilder (2.7.0) jbuilder (2.7.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
multi_json (>= 1.2) multi_json (>= 1.2)
...@@ -77,6 +81,7 @@ GEM ...@@ -77,6 +81,7 @@ GEM
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.0) mail (2.7.0)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
maruku (0.7.3)
method_source (0.9.0) method_source (0.9.0)
mini_mime (1.0.0) mini_mime (1.0.0)
mini_portile2 (2.3.0) mini_portile2 (2.3.0)
...@@ -86,6 +91,10 @@ GEM ...@@ -86,6 +91,10 @@ GEM
nio4r (2.3.1) nio4r (2.3.1)
nokogiri (1.8.3) nokogiri (1.8.3)
mini_portile2 (~> 2.3.0) mini_portile2 (~> 2.3.0)
parallel (1.12.1)
parser (2.5.1.2)
ast (~> 2.4.0)
powerpack (0.1.2)
pry (0.11.3) pry (0.11.3)
coderay (~> 1.1.0) coderay (~> 1.1.0)
method_source (~> 0.9.0) method_source (~> 0.9.0)
...@@ -121,15 +130,36 @@ GEM ...@@ -121,15 +130,36 @@ GEM
method_source method_source
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0) thor (>= 0.18.1, < 2.0)
rainbow (3.0.0)
rake (12.3.1) rake (12.3.1)
rb-fsevent (0.10.3) rb-fsevent (0.10.3)
rb-inotify (0.9.10) rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2) ffi (>= 0.5.0, < 2)
rubocop (0.58.2)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1)
powerpack (~> 0.1)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-progressbar (1.10.0)
rubyXL (3.3.29) rubyXL (3.3.29)
nokogiri (>= 1.4.4) nokogiri (>= 1.4.4)
rubyzip (>= 1.1.6) rubyzip (>= 1.1.6)
ruby_dep (1.5.0) ruby_dep (1.5.0)
rubyzip (1.2.1) rubyzip (1.2.1)
sass (3.5.7)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
sass-rails (5.0.7)
railties (>= 4.0.0, < 6)
sass (~> 3.1)
sprockets (>= 2.8, < 4.0)
sprockets-rails (>= 2.0, < 4.0)
tilt (>= 1.1, < 3)
selenium-webdriver (3.13.0) selenium-webdriver (3.13.0)
childprocess (~> 0.5) childprocess (~> 0.5)
rubyzip (~> 1.2) rubyzip (~> 1.2)
...@@ -148,11 +178,13 @@ GEM ...@@ -148,11 +178,13 @@ GEM
sqlite3 (1.3.13) sqlite3 (1.3.13)
thor (0.20.0) thor (0.20.0)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.0.8)
turbolinks (5.1.1) turbolinks (5.1.1)
turbolinks-source (~> 5.1) turbolinks-source (~> 5.1)
turbolinks-source (5.1.0) turbolinks-source (5.1.0)
tzinfo (1.2.5) tzinfo (1.2.5)
thread_safe (~> 0.1) thread_safe (~> 0.1)
unicode-display_width (1.4.0)
web-console (3.6.2) web-console (3.6.2)
actionview (>= 5.0) actionview (>= 5.0)
activemodel (>= 5.0) activemodel (>= 5.0)
...@@ -169,19 +201,23 @@ PLATFORMS ...@@ -169,19 +201,23 @@ PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
apipie-rails
byebug byebug
capybara (~> 2.13) capybara (~> 2.13)
httparty httparty
icalendar icalendar
jbuilder (~> 2.5) jbuilder (~> 2.5)
listen (>= 3.0.5, < 3.2) listen (>= 3.0.5, < 3.2)
maruku
nokogiri nokogiri
pry pry
pry-doc pry-doc
puma (~> 3.7) puma (~> 3.7)
rack-cors rack-cors
rails (~> 5.1.6) rails (~> 5.1.6)
rubocop (~> 0.58.2)
rubyXL rubyXL
sass-rails (~> 5.0)
selenium-webdriver selenium-webdriver
spring spring
spring-watcher-listen (~> 2.0.0) spring-watcher-listen (~> 2.0.0)
...@@ -191,4 +227,4 @@ DEPENDENCIES ...@@ -191,4 +227,4 @@ DEPENDENCIES
web-console (>= 3.3.0) web-console (>= 3.3.0)
BUNDLED WITH BUNDLED WITH
1.16.2 1.16.3
# Contains all actions having to do with CourseSections. # Contains all actions having to do with CourseSections.
# This is a nested controller -- see +config/routes.rb+ for details # This is a nested controller -- see +config/routes.rb+ for details
class CourseSectionsController < ApplicationController class CourseSectionsController < ApplicationController
# Render JSON of all Sections belonging to a given Course. resource_description do
short 'Working with course sections, e.g. CS 112 001'
end
api :GET, '/courses_sections', 'Get a list of course sections'
param :course_id, Integer, desc: "Only get the course sections belonging to the course with this ID"
param :crn, String, desc: "Get the course section with this CRN"
def index def index
@sections = CourseSection.all @sections = CourseSection.all
@sections = @sections.where(course_id: params[:course_id]) if params.key?(:course_id) @sections = @sections.where(course_id: params[:course_id]) if params.key?(:course_id)
@sections = @sections.where(crn: params[:crn]) if params.key?(:crn) @sections = @sections.where(crn: params[:crn]) if params.key?(:crn)
render json: @sections render json: @sections
end end
end end
# Contains all actions having to do with Courses. # Contains all actions having to do with Courses.
class CoursesController < ApplicationController class CoursesController < ApplicationController
# Renders JSON of courses matching params. resource_description do
short 'Working with courses, e.g. CS 112'
end
api :GET, '/courses', "Get a list of courses."
param :subject, String, desc: 'Course subject, e.g. "CS" or "ACCT"'
param :course_number, Integer, desc: 'Course number, e.g. "112"'
def index def index
@courses = Course.all @courses = Course.all
...@@ -11,7 +17,8 @@ class CoursesController < ApplicationController ...@@ -11,7 +17,8 @@ class CoursesController < ApplicationController
render json: @courses render json: @courses
end end
# Renders JSON of details of a singluar course, such as its sections api :GET, '/courses/:id', "Get a list of all course sections for the course with the given id."
param :id, :number, desc: 'Course ID', required: true
def show def show
@sections = CourseSection.where(course_id: params[:id]) @sections = CourseSection.where(course_id: params[:id])
......
...@@ -3,8 +3,13 @@ require 'time' ...@@ -3,8 +3,13 @@ require 'time'
# Contains functionality for generating schedules. # Contains functionality for generating schedules.
class SchedulesController < ApplicationController class SchedulesController < ApplicationController
# Render an iCal file containing the schedules of all the resource_description do
short 'Endpoints for generating iCal files'
end
# Render an iCal file containing the schedules of all the
# course sections with the given CRNs. # course sections with the given CRNs.
api :GET, '/schedules', 'Generate an iCal file with events for the given CRNs'
param :crns, String, desc: 'Comma separated list of CRNs to include as events in the calendar', required: true
def index def index
crns = params["crns"].split ',' crns = params["crns"].split ','
@schedule = Schedule.new crns @schedule = Schedule.new crns
......
...@@ -5,7 +5,7 @@ class Schedule ...@@ -5,7 +5,7 @@ class Schedule
def initialize(crns) def initialize(crns)
@cal = Icalendar::Calendar.new @cal = Icalendar::Calendar.new
@cal.x_wr_calname = 'GMU Fall 2018' @cal.x_wr_calname = 'GMU Fall 2018'
@course_sections = crns.map do |crn| @course_sections = crns.map do |crn|
CourseSection.find_by crn: crn CourseSection.find_by crn: crn
end end
...@@ -23,7 +23,7 @@ class Schedule ...@@ -23,7 +23,7 @@ class Schedule
def load_events def load_events
@course_sections.each do |section| @course_sections.each do |section|
unless section.start_time == "TBA" || section.end_time == "TBA" unless section.start_time == "TBA" || section.end_time == "TBA"
event = generate_event_from_section(section) event = generate_event_from_section(section)
@cal.add_event(event) @cal.add_event(event)
end end
...@@ -89,7 +89,7 @@ class Schedule ...@@ -89,7 +89,7 @@ class Schedule
# @return [Array] # @return [Array]
def exdates_for_section(section) def exdates_for_section(section)
# Generate exdates for all closures in a semester # Generate exdates for all closures in a semester
exdates = Closure.where(semester: section.course.semester).map { |closure| exdates = Closure.where(semester: section.course.semester).map { |closure|
generate_exdate(closure.date.to_formatted_s(:number), section.start_time) generate_exdate(closure.date.to_formatted_s(:number), section.start_time)
} }
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
# A +Semester+ is a simple model that consists of a +year+ and a +season+, e.g. "Fall 2018". # A +Semester+ is a simple model that consists of a +year+ and a +season+, e.g. "Fall 2018".
class Semester < ApplicationRecord class Semester < ApplicationRecord
has_many :courses has_many :courses
# Ensure necessary fields are present. # Ensure necessary fields are present.
validates :year, presence: true validates :year, presence: true
validates :season, presence: true validates :season, presence: true
......
...@@ -19,7 +19,7 @@ module Schedules ...@@ -19,7 +19,7 @@ module Schedules
config.middleware.insert_before 0, Rack::Cors do config.middleware.insert_before 0, Rack::Cors do
allow do allow do
origins '*' origins '*'
resource '*', :headers => :any, :methods => [:get, :post, :options] resource '*', headers: :any, methods: [:get, :post, :options]
end end
end end
end end
......
Apipie.configure do |config|
config.app_name = "SRCT Schedules API"
config.api_base_url = "/api"
config.doc_base_url = "/api"
# where is your API defined?
config.api_controllers_matcher = "#{Rails.root}/app/controllers/**/*.rb"
config.api_routes = Rails.application.routes
# use Markdown for writing docs
config.markup = Apipie::Markup::Markdown.new
# Fixes annoying "can't find resource" bug, see https://github.com/Apipie/apipie-rails/issues/549
config.translate = false
config.default_locale = nil
config.app_info["1.0"] = "The SRCT Schedules API provides data about courses, sections, and professors offered at GMU."
end
class IntegerValidator < Apipie::Validator::BaseValidator
def initialize(param_description, argument)
super(param_description)
@type = argument
end
def validate(value)
return false if value.nil?
!!(value.to_s =~ /^[-+]?[0-9]+$/)
end
def self.build(param_description, argument, _options, _block)
new(param_description, argument) if argument == Integer
end
def description
"Must be #{@type}."
end
end
...@@ -6,5 +6,6 @@ Rails.application.routes.draw do ...@@ -6,5 +6,6 @@ Rails.application.routes.draw do
resources :schedules, only: [:index] resources :schedules, only: [:index]
end end
root 'courses#index' # Set the root to be the courses API endpoint apipie # sets up API docs
get '/', to: redirect('/api') # redirect the root url to API docs
end end
...@@ -27,7 +27,7 @@ class ExcelLoader ...@@ -27,7 +27,7 @@ class ExcelLoader
end end
private private
# create closures for the days there will be no classes # create closures for the days there will be no classes
# see: https://registrar.gmu.edu/calendars/fall-2018/ # see: https://registrar.gmu.edu/calendars/fall-2018/
def load_closures def load_closures
...@@ -36,7 +36,7 @@ class ExcelLoader ...@@ -36,7 +36,7 @@ class ExcelLoader
(21..25).each { |n| Closure.create! date: Date.new(2018, 11, n), semester: @semester } (21..25).each { |n| Closure.create! date: Date.new(2018, 11, n), semester: @semester }
(10..19).each { |n| Closure.create! date: Date.new(2018, 12, n), semester: @semester } (10..19).each { |n| Closure.create! date: Date.new(2018, 12, n), semester: @semester }
end end
# Prints the failure, deletes all data added during loading, and raises the failure error. # Prints the failure, deletes all data added during loading, and raises the failure error.
def fail(error) def fail(error)
logger.fatal error.message logger.fatal error.message
...@@ -67,42 +67,42 @@ class ExcelLoader ...@@ -67,42 +67,42 @@ class ExcelLoader
# Tries to create a section from a given row. # Tries to create a section from a given row.
def configure_section?(row) def configure_section?(row)
section_name = row.cells[2]&.value section_name = row.cells[2]&.value
# If there is no valid section name, just continue to the next row # If there is no valid section name, just continue to the next row
unless section_name.blank? || section_name == 'Total' return nil if section_name.blank? || section_name == 'Total'
# The time field in the spreadsheet uses the format "start_time - end_time" i.e. "12:00 PM - 1:15 PM".
# So, split the times string by the - character # The time field in the spreadsheet uses the format "start_time - end_time" i.e. "12:00 PM - 1:15 PM".
times = row.cells[23]&.value # So, split the times string by the - character
time_strs = times.split('-') times = row.cells[23]&.value
time_strs = times.split('-')
instructor_val = row.cells[16]
instructor = if instructor_val.nil? || instructor_val.value == "'-" instructor_val = row.cells[16]
"TBA" instructor = if instructor_val.nil? || instructor_val.value == "'-"
else
instructor_val.value
end
location_cell = row.cells[25]
location = if location_cell.nil? || location_cell.value.include?("'-")
"TBA" "TBA"
else else
location_cell.value instructor_val.value
end end
section = CourseSection.create name: section_name, location_cell = row.cells[25]
course: @current_course, location = if location_cell.nil? || location_cell.value.include?("'-")
crn: row.cells[6]&.value, "TBA"
section_type: row.cells[8]&.value, else
title: row.cells[11]&.value, location_cell.value
instructor: instructor, end
start_date: row.cells[18]&.value,
end_date: row.cells[21]&.value, section = CourseSection.create name: section_name,
days: row.cells[22]&.value, course: @current_course,
start_time: time_strs[0].strip, crn: row.cells[6]&.value,
end_time: time_strs[1].strip, section_type: row.cells[8]&.value,
location: location title: row.cells[11]&.value,
instructor: instructor,
section start_date: row.cells[18]&.value,
end end_date: row.cells[21]&.value,
days: row.cells[22]&.value,
start_time: time_strs[0].strip,
end_time: time_strs[1].strip,
location: location
section
end end
end end
# frozen_string_literal: true
require 'thwait'
require 'httparty'
require 'nokogiri'
require 'json'
#
# USAGE:
#
# Just run it and it dynamically dumps the latest semester. There's a bit to do it for all of the ones in history commented out below but it'll thrash your RAM and probably piss off PatriotWeb. Also note this script could be trivially modified to correlate human readable names to semester IDs since they're just the .text attribute of the option node.
#
# There's a few minor issues like multiple spaces in teacher names and we could be scraping out email addresses but no major ones.
#
# DISCLAIMER/WARNING:
#
# This opens a number of connections pretty transparently from a script to PatriotWeb. I am not liable if you run this a million times and somehow kill over PatriotWeb. It's a scraper, not a DoS utility.
#
# Credit stackoverflow
class String
def alpha?
!!match(/^[[:alpha:]]+$/)
end
end
def get_details(data, titledetails, titledata)
crn = titledetails[1].strip
data[crn] = {} unless data[titledetails[1]]
crsinfo = { 'name': titledetails[0].strip }
uniquedata = { 'sect': titledetails[3].strip, 'crn': titledetails[1].strip }
general = { 'subj': titledata[0].strip, 'code': titledata[1].strip }
data[crn] = general.merge(uniquedata.merge(crsinfo))
data[crn][:code] = titledetails[2].split(' ')[1]
[data, data[crn]]
end
def sort_item(item, currentobj, data)
if item.name == 'th'
if item.to_html.include? '-'
titletxt = item.text
if item.text.include? ' - Honors'
titletxt = titletxt.gsub(' - Honors', ' (Honors)')
end
titledetails = titletxt.split(' - ')
if titledetails.count > 4
titledetails = ["#{titledetails[0]} #{titledetails[1]}", titledetails[2], titledetails[3], titledetails[4]]
end
titledata = titledetails[2].split(' ')
begin
data = get_details(data, titledetails, titledata)[0]
currentobj = get_details(data, titledetails, titledata)[1]
rescue StandardError => e
puts item
puts e
exit(1)
end
currentobj[:fields] = []
end
elsif item.is_a? Nokogiri::XML::Element