Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .codex/skills/generate-rails-openapi/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
name: generate-rails-openapi
description: Generate or update the OpenAPI specification from the Rails application tests (specs). Use when you need to synchronize the public API contract with the actual implementation in the Rails service.
---

# Generate Rails OpenAPI

Use this skill to automatically generate an OpenAPI specification by running the RSpec integration tests. This ensures that the documentation accurately reflects the behavior of the API.

## Required Inputs

- Rails application directory (`rails-app`)
- Integration tests (request specs) in `spec/requests/`
- `rswag` gems (`rswag-api`, `rswag-ui`, `rswag-specs`) installed in the Rails app

## Workflow

1. **Ensure dependencies are installed**:

```bash
cd rails-app
bundle install
```

2. **Run tests with OpenAPI generation**:
Execute the RSpec tests using the Rswag task. This will run the specs and generate the Swagger file based on the `path`, `post`, `response`, etc. blocks.

```bash
cd rails-app
# Generate Swagger documentation
bundle exec rake rswag:specs:swaggerize
```

3. **Locate the generated spec**:
By default, the spec is generated at `swagger/v1/swagger.yaml` (as configured in `spec/swagger_helper.rb`).

4. **Review**:
Review the generated `swagger.yaml` to ensure it correctly captures the API behavior.

## Repository Configuration

- **Gem**: `rswag` is used to capture request/response data and generate the UI.
- **Helper**: `spec/swagger_helper.rb` configures the output path and global metadata (title, version, servers).
- **Test Inclusion**: Ensure request specs use `type: :request` and follow the Rswag DSL.

```ruby
# spec/requests/api/v0/my_spec.rb

require 'swagger_helper'

RSpec.describe 'api/v0/my_resource', type: :request do
path '/api/v0/my_resource' do
post 'Creates a resource' do
tags 'MyResource'
consumes 'application/json'
parameter name: :resource, in: :body, schema: { ... }

response '201', 'resource created' do
run_test!
end
end
end
end

```

## Troubleshooting

- **No examples found**: Ensure you are running `rake rswag:specs:swaggerize` and that your specs use the Rswag DSL (`path`, `response`, etc.).
- **Database Errors**: Ensure a test database is available or mocked. The app may require `AWS_REGION` and `DB_IAM_AUTH=false` for local tests.
- **Schema Drift**: We use a shared example `swagger_schema_drift_detection` to ensure models and their `to_swagger_schema` definitions stay in sync. Run these via:

```bash
bundle exec rspec spec/models
```
1 change: 1 addition & 0 deletions rails-app/.rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
--require spec_helper
6 changes: 5 additions & 1 deletion rails-app/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ gem "thruster", require: false
# gem "rack-cors"

group :development, :test do
gem "minitest", "~> 5.0"
gem "rspec-rails", "~> 7.0"
gem "rspec-openapi", group: :test
gem "climate_control"
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"

Expand All @@ -56,3 +58,5 @@ gem "shoryuken"
gem "datadog", "~> 2.0"

gem "pg-aws_rds_iam", "~> 0.8.0"

gem "rswag", "~> 2.17"
49 changes: 48 additions & 1 deletion rails-app/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1245.0)
Expand Down Expand Up @@ -106,6 +108,7 @@ GEM
bundler (>= 1.2.0)
thor (~> 1.0)
cgi (0.5.1)
climate_control (1.2.0)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
Expand All @@ -121,6 +124,7 @@ GEM
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
diff-lcs (1.6.2)
dotenv (3.2.0)
dotenv-rails (3.2.0)
dotenv (= 3.2.0)
Expand Down Expand Up @@ -149,6 +153,9 @@ GEM
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.19.5)
json-schema (6.2.0)
addressable (~> 2.8)
bigdecimal (>= 3.1, < 5)
jwt (3.1.2)
base64
kamal (2.11.0)
Expand Down Expand Up @@ -245,6 +252,7 @@ GEM
psych (5.3.1)
date
stringio
public_suffix (7.0.5)
puma (8.0.1)
nio4r (~> 2.0)
racc (1.8.1)
Expand Down Expand Up @@ -299,6 +307,42 @@ GEM
regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-openapi (0.26.0)
actionpack (>= 5.2.0)
rails-dom-testing
rspec-core
rspec-rails (7.1.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.7)
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.86.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
Expand Down Expand Up @@ -381,17 +425,20 @@ DEPENDENCIES
bootsnap
brakeman
bundler-audit
climate_control
datadog (~> 2.0)
debug
dotenv-rails
jwt
kamal
minitest (~> 5.0)
pg
pg-aws_rds_iam (~> 0.8.0)
puma (>= 5.0)
rails (~> 8.1.3)
redis
rspec-openapi
rspec-rails (~> 7.0)
rswag (~> 2.17)
rubocop-rails-omakase
shoryuken
stoplight (~> 5.8)
Expand Down
32 changes: 32 additions & 0 deletions rails-app/app/models/education/enrollment_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,38 @@ def initialize(params = {})
@address = params[:address]
end

def missing_required_field?
to_swagger_spec.any? { |field| field[:required] && field[:name].to_s.downcase.to_sym == :firstName && self.send(field[:name].to_s.downcase.to_sym).blank? }
end

def self.to_swagger_schema
{
type: :object,
required: %i[firstName dateOfBirth lastName],
properties: {
firstName: { type: :string, description: "First name of the student.", example: "John" },
middleName: { type: :string, description: "Middle name of the student.", example: "Quincy" },
lastName: { type: :string, description: "Last name of the student.", example: "Doe" },
dateOfBirth: { type: :string, description: "Date of birth of the student (YYYY-MM-DD).", example: "1990-01-01" },
ssn: { type: :string, description: "Social Security Number of the student.", example: "000-00-0000" },
address: {
type: :object,
description: "Postal address when a lookup requires demographic matching instead of SSN matching.",
properties: {
street1: { type: :string, description: "Primary street address line.", example: "123 Main St" },
street2: { type: :string, description: "Secondary street address line when available.", example: "Apt 4B" },
street3: { type: :string, description: "Additional street address line when available." },
city: { type: :string, description: "City or locality.", example: "Arlington" },
state: { type: :string, description: "State, province, or region code.", example: "VA" },
postalCode: { type: :string, description: "Postal or ZIP code.", example: "22202" },
country: { type: :string, description: "Country name or code.", example: "USA" }
},
required: %i[street1 city state postalCode country]
}
}
}
end

def to_nsc_payload(account_id)
out = {
accountId: account_id,
Expand Down
38 changes: 38 additions & 0 deletions rails-app/app/models/education/enrollment_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,44 @@ def initialize(params = {})
@metadata = params[:metadata]
end

def self.to_swagger_schema
{
type: :object,
properties: {
enrollmentStatus: {
type: :string,
description: "Aggregated enrollment status across all schools.",
enum: EnrollmentStatus::RANKS.keys,
example: "FULL_TIME"
},
enrollmentDetails: {
type: :array,
items: {
type: :object,
properties: {
schoolName: { type: :string, description: "Official name of the educational institution.", example: "University of Excellence" },
termBeginDate: { type: :string, description: "Start date of the academic term (YYYY-MM-DD).", example: "2023-01-15" },
termEndDate: { type: :string, description: "End date of the academic term (YYYY-MM-DD).", example: "2023-05-20" },
enrollmentStatus: {
type: :string,
description: "Enrollment status for this specific term.",
enum: EnrollmentStatus::RANKS.keys,
example: "FULL_TIME"
}
}
}
},
dataSource: { type: :string, description: "The source of the enrollment data (e.g., NSC).", example: "NSC" },
metadata: {
type: :object,
properties: {
durationMs: { type: :integer, description: "Time taken to fetch data from the source in milliseconds.", example: 125 }
}
}
}
}
end

def as_json(options = {})
{
enrollmentStatus: enrollment_status,
Expand Down
3 changes: 2 additions & 1 deletion rails-app/config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ default: &default
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
sslmode: <%= ENV.fetch("DB_SSLMODE") { "prefer" } %>
iam_auth: <%= ENV.fetch("DB_IAM_AUTH") { "false" } %>
aws_rds_iam_auth_token_generator: default

development:
<<: *default

test:
<<: *default
database: <%= ENV.fetch("DB_NAME") { "postgres" } %>_test
iam_auth: false

production:
<<: *default
iam_auth: <%= ENV.fetch("DB_IAM_AUTH") { "false" } %>
aws_rds_iam_auth_token_generator: default
14 changes: 14 additions & 0 deletions rails-app/config/initializers/rswag_api.rb
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions rails-app/config/initializers/rswag_ui.rb
Original file line number Diff line number Diff line change
@@ -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.openapi_endpoint '/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
2 changes: 2 additions & 0 deletions rails-app/config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
Rails.application.routes.draw do
mount Rswag::Ui::Engine => '/api-docs'
mount Rswag::Api::Engine => '/api-docs'
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
Expand Down
35 changes: 35 additions & 0 deletions rails-app/spec/jobs/reporting/report_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
require 'rails_helper'

module Reporting
RSpec.describe ReportJob, type: :job do
describe '#perform' do
it 'performs and creates an ApiEvent' do
body = {
timestamp: Time.now.iso8601,
endpoint: '/api/v0/test',
data_source: 'test-source',
client_id: 'test-client',
status_code: 200,
success: true
}.to_json

expect {
ReportJob.new.perform(nil, body)
}.to change(ApiEvent, :count).by(1)

event = ApiEvent.last
expect(event.endpoint).to eq('/api/v0/test')
expect(event.data_source).to eq('test-source')
expect(event.client_id).to eq('test-client')
expect(event.status_code).to eq(200)
expect(event.success).to be true
end

it 'logs error and raises if JSON is invalid' do
expect {
ReportJob.new.perform(nil, 'invalid json')
}.to raise_error(JSON::ParserError)
end
end
end
end
7 changes: 7 additions & 0 deletions rails-app/spec/models/education/enrollment_request_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require 'rails_helper'

module Education
RSpec.describe EnrollmentRequest, type: :model do
it_behaves_like 'swagger_schema_drift_detection', EnrollmentRequest
end
end
7 changes: 7 additions & 0 deletions rails-app/spec/models/education/enrollment_response_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require 'rails_helper'

module Education
RSpec.describe EnrollmentResponse, type: :model do
it_behaves_like 'swagger_schema_drift_detection', EnrollmentResponse, ignored_attributes: [:raw_data]
end
end
Loading
Loading