diff --git a/.gitignore b/.gitignore index 223f575d2..e6fe22f57 100755 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ config/settings/*.local.yml config/environments/*.local.yml /config/master.key +docs/development/ diff --git a/.rubocop.yml b/.rubocop.yml index b211470ed..117e48653 100755 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -322,3 +322,16 @@ Naming/MemoizedInstanceVariableName: Style/MultipleComparison: Enabled: false + + +# Rubocop doesn't seem to understand the rswag DSL. +# Exclude the rswag spec file spec/requests/api/v1/api_docs/committee_records_spec.rb +# from RSpec/EmptyExampleGroup. Without this, autocorrect will delete +# the code in that file. +RSpec/EmptyExampleGroup: + Exclude: + - 'spec/requests/api/v1/api_docs/committee_records_spec.rb' + +RSpec/VariableName: + Exclude: + - 'spec/requests/api/v1/api_docs/committee_records_spec.rb' \ No newline at end of file diff --git a/Gemfile b/Gemfile index afa1e9a1b..9dd2e109f 100644 --- a/Gemfile +++ b/Gemfile @@ -85,7 +85,8 @@ gem 'config', '~> 5.5.2' gem 'discard' # Temporary file downloads over HTTP gem 'down' - +# API documentation +gem 'rswag' group :development, :test do # Access an IRB console on exception pages or by using <%= console %> anywhere in the code. @@ -95,6 +96,8 @@ group :development, :test do # Coverage report gem 'simplecov', '~> 0.17.0' gem 'mutex_m' + # API documentation + gem 'rswag' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 11adfe0eb..b488e1234 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -214,6 +214,9 @@ GEM railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (2.16.0) + json-schema (6.1.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) jwt (2.10.1) base64 language_server-protocol (3.17.0.5) @@ -402,6 +405,21 @@ GEM rspec-retry (0.6.2) rspec-core (> 3.3) rspec-support (3.13.2) + rswag (2.17.0) + rswag-api (= 2.17.0) + rswag-specs (= 2.17.0) + rswag-ui (= 2.17.0) + rswag-api (2.17.0) + activesupport (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rswag-specs (2.17.0) + activesupport (>= 5.2, < 8.2) + json-schema (>= 2.2, < 7.0) + railties (>= 5.2, < 8.2) + rspec-core (>= 2.14) + rswag-ui (2.17.0) + actionpack (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -599,6 +617,7 @@ DEPENDENCIES rsolr (~> 2.5.0) rspec-rails (~> 7.0.0) rspec-retry (~> 0.6.0) + rswag sassc-rails (~> 2.1.2) seedbank (~> 0.5.0) selenium-webdriver (~> 4.26) diff --git a/app/controllers/api/v1/committee_records_controller.rb b/app/controllers/api/v1/committee_records_controller.rb new file mode 100644 index 000000000..11c8311b9 --- /dev/null +++ b/app/controllers/api/v1/committee_records_controller.rb @@ -0,0 +1,80 @@ +module Api + module V1 + class CommitteeRecordsController < ApplicationController + skip_before_action :verify_authenticity_token + + before_action :authenticate_api_key + + def faculty_committees + access_id = params[:access_id] + if access_id.blank? + render json: { error: 'access_id is required' }, status: :bad_request + return + end + + committee_memberships = CommitteeMember + .joins(:submission).where('submissions.status LIKE "released for publication%" OR submissions.status = "waiting for publication release"') + .includes(:committee_role, submission: [:author, :degree, :program]) + .where(access_id: access_id) + + render json: { + faculty_access_id: access_id, + committees: format_committees(committee_memberships) + }, status: :ok + rescue StandardError => e + render json: { error: e.message }, status: :internal_server_error + end + + private + + def authenticate_api_key + token = request.headers['HTTP_X_API_KEY'] + + @api_token = ApiToken.includes(:external_app).find_by(token: token) + return unauthorized! unless @api_token + + @external_app = @api_token.external_app + @api_token.update_column(:last_used_at, Time.current) + + true + end + + def unauthorized! + render json: { error: "Unauthorized" }, status: :unauthorized + end + + def format_committees(committee_memberships) + committee_memberships.map { |membership| committee_payload(membership) } + end + + def committee_payload(membership) + submission = membership.submission + author = submission&.author + + { + committee_member_id: membership.id, + + role: membership.committee_role&.name, + role_code: membership.committee_role&.code, + + student_fname: author&.first_name, + student_lname: author&.last_name, + student_access_id: author&.access_id, + + submission_id: submission.id, + title: submission.title, + degree_name: submission.degree&.name, + program_name: submission.program&.name, + semester: submission.semester, + year: submission.year, + + approval_started_at: membership.approval_started_at, + final_submission_approved_at: submission.final_submission_approved_at, + + submission_status: submission.status, + committee_member_status: membership.status + } + end + end + end +end diff --git a/app/models/external_app.rb b/app/models/external_app.rb index cf1649e88..87bae9243 100644 --- a/app/models/external_app.rb +++ b/app/models/external_app.rb @@ -29,10 +29,24 @@ def self.build end end + class FamsTools + NAME = "FAMS Tools" + + def self.build + ExternalApp.find_or_create_by(name: NAME) do |app| + app.api_tokens.build + end + end + end + def self.etda_explore EtdaExplore.build end + def self.fams_tools + FamsTools.build + end + def token api_tokens.first.token end diff --git a/config/initializers/rswag_api.rb b/config/initializers/rswag_api.rb new file mode 100644 index 000000000..428ac5aa8 --- /dev/null +++ b/config/initializers/rswag_api.rb @@ -0,0 +1,14 @@ +Rswag::Api.configure do |c| + + # Specify a root folder where Swagger JSON files are located + # This is used by the Swagger middleware to serve requests for API descriptions + # NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure + # that it's configured to generate files in the same folder + c.openapi_root = Rails.root.to_s + '/swagger' + + # Inject a lambda function to alter the returned Swagger prior to serialization + # The function will have access to the rack env for the current request + # For example, you could leverage this to dynamically assign the "host" property + # + c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] } +end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb new file mode 100644 index 000000000..37f7842bd --- /dev/null +++ b/config/initializers/rswag_ui.rb @@ -0,0 +1,16 @@ +Rswag::Ui.configure do |c| + + # List the Swagger endpoints that you want to be documented through the + # swagger-ui. The first parameter is the path (absolute or relative to the UI + # host) to the corresponding endpoint and the second is a title that will be + # displayed in the document selector. + # NOTE: If you're using rspec-api to expose Swagger files + # (under openapi_root) as JSON or YAML endpoints, then the list below should + # correspond to the relative paths for those endpoints. + + c.swagger_endpoint '/admin/api-docs/v1/swagger.yaml', 'API V1 Docs' + + # Add Basic Auth in case your API is private + # c.basic_auth_enabled = true + # c.basic_auth_credentials 'username', 'password' +end diff --git a/config/routes.rb b/config/routes.rb index 584a78506..d168b43db 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,7 +2,6 @@ require 'sidekiq/web' Rails.application.routes.draw do - devise_for :approvers, path: 'approver' devise_for :authors, path: 'author' devise_for :admins, path: 'admin' @@ -20,6 +19,12 @@ mount OkComputer::Engine, at: "/healthcheck" + namespace :api do + namespace :v1 do + post "committee_records/faculty_committees" + end + end + ## works: get '/committee_members/autocomplete', to: 'ldap_lookup#autocomplete', as: :committee_members_autocomplete get '/committee_members/autocomplete', to: 'application#autocomplete', as: :committee_members_autocomplete @@ -43,6 +48,8 @@ authenticate :admin do mount Sidekiq::Web => '/sidekiq' + mount Rswag::Api::Engine => '/api-docs' + mount Rswag::Ui::Engine => '/api-docs' end get '/custom_report', to: 'reports#custom_report_index', as: :custom_report_index diff --git a/db/schema.rb b/db/schema.rb index 9701a0852..e6da8a604 100755 --- a/db/schema.rb +++ b/db/schema.rb @@ -195,7 +195,7 @@ t.index ["name"], name: "index_degrees_on_name", unique: true end - create_table "external_apps", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + create_table "external_apps", charset: "utf8mb4", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -277,7 +277,7 @@ t.index ["name", "code"], name: "index_programs_on_name_and_code", unique: true end - create_table "remediated_final_submission_files", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + create_table "remediated_final_submission_files", charset: "utf8mb4", force: :cascade do |t| t.bigint "submission_id", null: false t.bigint "final_submission_file_id", null: false t.text "asset" @@ -342,9 +342,9 @@ t.string "lionpath_semester" t.string "academic_program" t.string "degree_checkout_status" - t.datetime "author_release_warning_sent_at", precision: nil - t.datetime "acknowledgment_page_submitted_at", precision: nil t.string "candidate_number" + t.datetime "acknowledgment_page_submitted_at", precision: nil + t.datetime "author_release_warning_sent_at", precision: nil t.string "extension_token" t.datetime "last_lionpath_export_at", precision: nil t.index ["author_id"], name: "submissions_author_id_fk" diff --git a/spec/controllers/committee_records_controller_spec.rb b/spec/controllers/committee_records_controller_spec.rb new file mode 100644 index 000000000..a6129f58c --- /dev/null +++ b/spec/controllers/committee_records_controller_spec.rb @@ -0,0 +1,239 @@ +require "rails_helper" + +RSpec.describe "CommitteeRecords API", type: :request do + let(:path) { "/api/v1/committee_records/faculty_committees" } + + let!(:external_app) { ExternalApp.create!(name: "Test App") } + let!(:api_token) { ApiToken.create!(token: "test_token", external_app: external_app) } + + let(:headers) do + { + "CONTENT_TYPE" => "application/json", + "ACCEPT" => "application/json", + "X-API-KEY" => api_token.token + } + end + + describe "authentication" do + it "returns 401 without token" do + post path, params: { access_id: "aab27" }.to_json + + expect(response).to have_http_status(:unauthorized) + expect(JSON.parse(response.body)["error"]).to eq("Unauthorized") + end + end + + describe "parameter validation" do + it "returns 400 when access_id is missing" do + post path, params: {}.to_json, headers: headers + + expect(response).to have_http_status(:bad_request) + expect(JSON.parse(response.body)["error"]).to eq("access_id is required") + end + end + + describe "basic success response" do + it "returns 200 and expected keys" do + allow(CommitteeMember).to receive_messages(includes: CommitteeMember, where: []) + + post path, params: { access_id: "aab27" }.to_json, headers: headers + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json).to have_key("faculty_access_id") + expect(json).to have_key("committees") + end + end + + describe "internal error handling" do + it "returns 500 when an exception occurs" do + allow(CommitteeMember).to receive(:joins).and_raise(StandardError.new("boom")) + + post path, params: { access_id: "aab27" }.to_json, headers: headers + + expect(response).to have_http_status(:internal_server_error) + expect(JSON.parse(response.body)["error"]).to eq("boom") + end + end + + describe "response body structure" do + let(:relation) { instance_double("ActiveRecord::Relation") } + + before do + allow(CommitteeMember).to receive(:joins).with(:submission).and_return(relation) + + allow(relation).to receive_messages( + where: relation, + includes: relation + ) + end + + describe "when submission is complete" do + it "returns key submission and student fields" do + committee_role = instance_double("CommitteeRole", name: "Advisor", code: "ADV") + author = instance_double("Author", first_name: "Ada", last_name: "Lovelace", access_id: "apl123") + + degree = instance_double("Degree", name: "MS") + program = instance_double("Program", name: "Computer Science") + + submission = instance_double( + "Submission", + id: 42, + title: "Thesis Title", + semester: "Spring", + year: 2026, + degree: degree, + program: program, + final_submission_approved_at: nil, + status: "released for publication", + author: author + ) + + membership = instance_double( + "CommitteeMember", + id: 7, + committee_role: committee_role, + submission: submission, + approval_started_at: nil, + status: "approved" + ) + + allow(relation).to receive(:where).with(access_id: "aab27").and_return([membership]) + + post path, params: { access_id: "aab27" }.to_json, headers: headers + + expect(response).to have_http_status(:ok) + + committee = JSON.parse(response.body)["committees"].first + + expect(committee).to include( + "committee_member_id" => 7, + "role" => "Advisor", + "student_access_id" => "apl123", + "submission_id" => 42, + "title" => "Thesis Title" + ) + + expect(committee["degree_name"]).to eq("MS") + expect(committee["program_name"]).to eq("Computer Science") + end + end + + describe "when submission data is blank" do + it "returns nils for safe fields" do + committee_role = instance_double("CommitteeRole", name: nil, code: nil) + + submission = instance_double( + "Submission", + id: 42, + title: nil, + semester: nil, + year: nil, + degree: nil, + program: nil, + final_submission_approved_at: nil, + status: "waiting for publication release", + author: nil + ) + + membership = instance_double( + "CommitteeMember", + id: 7, + committee_role: committee_role, + submission: submission, + approval_started_at: nil, + status: nil + ) + + allow(relation).to receive(:where).with(access_id: "aab27").and_return([membership]) + + post path, params: { access_id: "aab27" }.to_json, headers: headers + + expect(response).to have_http_status(:ok) + + committee = JSON.parse(response.body)["committees"].first + + expect(committee).to include( + "committee_member_id" => 7, + "submission_id" => 42 + ) + + expect(committee["role"]).to be_nil + expect(committee["student_access_id"]).to be_nil + expect(committee["title"]).to be_nil + expect(committee["degree_name"]).to be_nil + expect(committee["program_name"]).to be_nil + end + end + + describe "when faculty has multiple committees" do + it "returns all committee memberships for the faculty access_id" do + relation = instance_double("ActiveRecord::Relation") + allow(CommitteeMember).to receive(:joins).with(:submission).and_return(relation) + allow(relation).to receive_messages(where: relation, includes: relation) + + role1 = instance_double("CommitteeRole", name: "Advisor", code: "ADV") + role2 = instance_double("CommitteeRole", name: "Reader", code: "RDR") + author = instance_double("Author", first_name: "Ada", last_name: "Lovelace", access_id: "apl123") + + submission1 = instance_double( + "Submission", + id: 101, + title: "Thesis One", + semester: "Spring", + year: 2026, + degree: nil, + program: nil, + final_submission_approved_at: nil, + status: "released for publication", + author: author + ) + + submission2 = instance_double( + "Submission", + id: 202, + title: "Thesis Two", + semester: "Fall", + year: 2025, + degree: nil, + program: nil, + final_submission_approved_at: nil, + status: "waiting for publication release", + author: author + ) + + membership1 = instance_double( + "CommitteeMember", + id: 1, + committee_role: role1, + submission: submission1, + approval_started_at: nil, + status: "approved" + ) + + membership2 = instance_double( + "CommitteeMember", + id: 2, + committee_role: role2, + submission: submission2, + approval_started_at: nil, + status: "pending" + ) + + allow(relation).to receive(:where).with(access_id: "aab27").and_return([membership1, membership2]) + + post path, params: { access_id: "aab27" }.to_json, headers: headers + + expect(response).to have_http_status(:ok) + + body = JSON.parse(response.body) + expect(body["faculty_access_id"]).to eq("aab27") + expect(body["committees"].size).to eq(2) + + ids = body["committees"].map { |c| c["committee_member_id"] } + expect(ids).to contain_exactly(1, 2) + end + end + end +end diff --git a/spec/requests/api/v1/api_docs/committee_records_spec.rb b/spec/requests/api/v1/api_docs/committee_records_spec.rb new file mode 100644 index 000000000..d1bc8372a --- /dev/null +++ b/spec/requests/api/v1/api_docs/committee_records_spec.rb @@ -0,0 +1,92 @@ +require 'swagger_helper' + +RSpec.describe 'API::V1::CommitteeRecords', type: :request do + let!(:external_app) { ExternalApp.create!(name: "Test App") } + let!(:api_token) { ApiToken.create!(token: "test_token", external_app: external_app) } + + path '/api/v1/committee_records/faculty_committees' do + post 'Retrieves committee records' do + tags 'Committee Records' + produces 'application/json' + consumes 'application/json' + + description 'Retrieves committee records for a faculty member based on their access ID (PSU)' + security [ApiKeyAuth: []] + + parameter name: :payload, + in: :body, + required: true, + schema: { + type: :object, + properties: { + access_id: { type: :string, example: 'aab27' } + }, + required: ['access_id'] + } + let!(:external_app) { ExternalApp.create!(name: 'Test App') } + let!(:api_token) { ApiToken.create!(token: 'test_token', external_app: external_app) } + + response '200', 'committee records retrieved' do + let(:'X-API-KEY') { external_app.token } + let(:payload) { { access_id: 'aab27' } } + + schema type: :object, + properties: { + committees: { + type: :array, + items: { + type: :object, + properties: { + committee_member_id: { type: :integer, example: 789 }, + role: { type: :string, nullable: true, example: 'Director' }, + role_code: { type: :string, nullable: true, example: 'DI' }, + student_fname: { type: :string, nullable: true, example: 'Muhammad' }, + student_lname: { type: :string, nullable: true, example: 'Siddiqui' }, + student_access_id: { type: :string, nullable: true, example: 'ums467' }, + submission_id: { type: :integer, nullable: true, example: 43 }, + title: { type: :string, nullable: true, example: 'SPIDERMAN' }, + degree_name: { type: :string, nullable: true, example: 'Masters' }, + program_name: { type: :string, nullable: true, example: 'Computer science' }, + semester: { type: :string, nullable: true, example: 'Fall' }, + year: { type: :integer, nullable: true, example: 2028 }, + approval_started_at: { type: :string, format: 'date-time', nullable: true, example: Time.zone.now }, + final_submission_approved_at: { type: :string, format: 'date-time', nullable: true, example: Time.zone.now }, + submission_status: { type: :string, nullable: true, example: 'released for publication' }, + committee_member_status: { type: :string, nullable: true, example: 'approved' } + } + } + } + }, + required: ['faculty_access_id', 'committees'] + + run_test! + end + + response '400', 'access_id missing' do + let(:'X-API-KEY') { external_app.token } + let(:payload) { {} } + + schema type: :object, + properties: { error: { type: :string, example: 'access_id is required' } }, + required: ['error'] + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('access_id is required') + end + end + + response '401', 'unauthorized' do + let(:'X-API-KEY') { nil } + let(:payload) { { access_id: 'aab27' } } + + schema type: :object, + properties: { error: { type: :string, example: 'Unauthorized' } }, + required: ['error'] + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Unauthorized') + end + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb new file mode 100644 index 000000000..fd7699690 --- /dev/null +++ b/spec/swagger_helper.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.configure do |config| + # Specify a root folder where Swagger JSON files are generated + # NOTE: If you're using the rswag-api to serve API descriptions, you'll need + # to ensure that it's configured to serve Swagger from the same folder + config.openapi_root = Rails.root.join('swagger').to_s + + # Define one or more Swagger documents and provide global metadata for each one + # When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will + # be generated at the provided relative path under openapi_root + # By default, the operations defined in spec files are added to the first + # document below. You can override this behavior by adding a openapi_spec tag to the + # the root example_group in your specs, e.g. describe '...', openapi_spec: 'v2/swagger.json' + config.openapi_specs = { + 'v1/swagger.yaml' => { + openapi: '3.0.1', + info: { + title: 'API V1', + version: 'v1' + }, + + + components: { + securitySchemes: { + ApiKeyAuth: { + type: :apiKey, + name: 'X-API-KEY', + in: :header, + description: 'API key required in the X-API-KEY header' + } + } + } + } + } + + # Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'. + # The openapi_specs configuration option has the filename including format in + # the key, this may want to be changed to avoid putting yaml in json files. + # Defaults to json. Accepts ':json' and ':yaml'. + config.openapi_format = :yaml +end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml new file mode 100644 index 000000000..219197ebf --- /dev/null +++ b/swagger/v1/swagger.yaml @@ -0,0 +1,140 @@ +--- +openapi: 3.0.1 +info: + title: API V1 + version: v1 +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + name: X-API-KEY + in: header + description: API key required in the X-API-KEY header +paths: + "/api/v1/committee_records/faculty_committees": + post: + summary: Retrieves committee records + tags: + - Committee Records + description: Retrieves committee records for a faculty member based on their + access ID (PSU) + security: + - ApiKeyAuth: [] + parameters: [] + responses: + '200': + description: committee records retrieved + content: + application/json: + schema: + type: object + properties: + committees: + type: array + items: + type: object + properties: + committee_member_id: + type: integer + example: 789 + role: + type: string + nullable: true + example: Director + role_code: + type: string + nullable: true + example: DI + student_fname: + type: string + nullable: true + example: Muhammad + student_lname: + type: string + nullable: true + example: Siddiqui + student_access_id: + type: string + nullable: true + example: ums467 + submission_id: + type: integer + nullable: true + example: 43 + title: + type: string + nullable: true + example: SPIDERMAN + degree_name: + type: string + nullable: true + example: Masters + program_name: + type: string + nullable: true + example: Computer science + semester: + type: string + nullable: true + example: Fall + year: + type: integer + nullable: true + example: 2028 + approval_started_at: + type: string + format: date-time + nullable: true + example: '2026-02-27 08:37:15 -0500' + final_submission_approved_at: + type: string + format: date-time + nullable: true + example: '2026-02-27 08:37:15 -0500' + submission_status: + type: string + nullable: true + example: released for publication + committee_member_status: + type: string + nullable: true + example: approved + required: + - faculty_access_id + - committees + '400': + description: access_id missing + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: access_id is required + required: + - error + '401': + description: unauthorized + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: Unauthorized + required: + - error + requestBody: + content: + application/json: + schema: + type: object + properties: + access_id: + type: string + example: aab27 + required: + - access_id + required: true