diff --git a/sinatra-postgres-quickstart/.gitignore b/sinatra-postgres-quickstart/.gitignore new file mode 100644 index 0000000..eddc05e --- /dev/null +++ b/sinatra-postgres-quickstart/.gitignore @@ -0,0 +1,18 @@ +.env +keploy/ +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/test/tmp/ +/test/version_tmp/ +/tmp/ +.byebug_history +.dat* +.repl_history +.DS_Store +.env.example +VALIDATION.md diff --git a/sinatra-postgres-quickstart/Dockerfile b/sinatra-postgres-quickstart/Dockerfile new file mode 100644 index 0000000..fc4e3fb --- /dev/null +++ b/sinatra-postgres-quickstart/Dockerfile @@ -0,0 +1,23 @@ +FROM ruby:3.2-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev && \ + rm -rf /var/lib/apt/lists/* + +# Copy Gemfile and install dependencies +COPY Gemfile* ./ +RUN bundle install + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Start the application +CMD ["bundle", "exec", "puma", "config.ru", "-p", "8000", "-e", "production"] diff --git a/sinatra-postgres-quickstart/Gemfile b/sinatra-postgres-quickstart/Gemfile new file mode 100644 index 0000000..b3f06db --- /dev/null +++ b/sinatra-postgres-quickstart/Gemfile @@ -0,0 +1,9 @@ +source 'https://rubygems.org' + +ruby '~> 3.2' + +gem 'sinatra', '~> 3.0' +gem 'sinatra-contrib', '~> 3.0' +gem 'pg', '~> 1.5' +gem 'puma', '~> 6.4' +gem 'json', '~> 2.7' diff --git a/sinatra-postgres-quickstart/Gemfile.lock b/sinatra-postgres-quickstart/Gemfile.lock new file mode 100644 index 0000000..008b086 --- /dev/null +++ b/sinatra-postgres-quickstart/Gemfile.lock @@ -0,0 +1,43 @@ +GEM + remote: https://rubygems.org/ + specs: + base64 (0.3.0) + json (2.7.1) + multi_json (1.21.1) + mustermann (3.1.1) + nio4r (2.5.9) + pg (1.5.4) + puma (6.4.0) + nio4r (~> 2.0) + rack (2.2.23) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + sinatra (3.2.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.2.0) + tilt (~> 2.0) + sinatra-contrib (3.2.0) + multi_json (>= 0.0.2) + mustermann (~> 3.0) + rack-protection (= 3.2.0) + sinatra (= 3.2.0) + tilt (~> 2.0) + tilt (2.8.0) + +PLATFORMS + ruby + +DEPENDENCIES + json (~> 2.7) + pg (~> 1.5) + puma (~> 6.4) + sinatra (~> 3.0) + sinatra-contrib (~> 3.0) + +RUBY VERSION + ruby 3.2.0 + +BUNDLED WITH + 2.4.20 diff --git a/sinatra-postgres-quickstart/README.md b/sinatra-postgres-quickstart/README.md new file mode 100644 index 0000000..53c0709 --- /dev/null +++ b/sinatra-postgres-quickstart/README.md @@ -0,0 +1,205 @@ +# Ruby + PostgreSQL API Quickstart for Keploy + +This quickstart demonstrates a realistic API testing workflow with Ruby (Sinatra), PostgreSQL, and Keploy. + +It goes beyond basic CRUD by including: + +- Search, filtering, sorting, and pagination query patterns +- Related resources (`books` and dependent `reviews`) +- Analytics endpoint with aggregates +- Positive and negative test paths in one recording run +- Automated 20-request traffic generation script for repeatable recordings + +## Prerequisites + +- Docker 20.10+ +- Docker Compose v2+ +- Keploy CLI installed +- Linux/WSL2 (required by Keploy) + +Optional local run (without Docker): + +- Ruby 3.2+ +- Bundler +- PostgreSQL 15+ + +## Project Layout + +``` +. +├── app.rb +├── docker-compose.yml +├── init.sql +├── run-keploy.sh +├── keploy.yml +└── README.md +``` + +## Run Locally (Optional) + +```bash +bundle install +createdb booksdb +psql -d booksdb -f init.sql +bundle exec ruby app.rb +``` + +Health check: + +```bash +curl http://localhost:${APP_HOST_PORT:-18080}/health +``` + +## Run with Docker (Recommended) + +```bash +docker compose up --build +``` + +Then verify: + +```bash +curl http://localhost:${APP_HOST_PORT:-18080}/health +``` + +Stop services: + +```bash +docker compose down +``` + +If you changed schema and need a clean DB: + +```bash +docker compose down -v +``` + +## Keploy Recording (Automated 20-call Scenario) + +Run the script: + +```bash +./run-keploy.sh +``` + +What this script does: + +1. Starts Keploy record mode with `docker compose up --build` +2. Selects a free host port in the `18080-18120` range unless `APP_HOST_PORT` is set +3. Waits for `/health` +4. Sends 20 API calls across list/search/filter/create/update/delete/reviews/analytics +5. Includes expected validation and not-found errors (400/404) to capture negative scenarios +6. Stops recording cleanly + +Recorded tests are stored in the `keploy/tests` directory. + +## Keploy Replay + +```bash +keploy test -c "docker compose up" --container-name "ruby-books-app" --cmd-type docker-compose +``` + +## API Endpoints + +### Health + +- `GET /health` + +### Books + +- `GET /books` +- `GET /books/:id` +- `POST /books` +- `PUT /books/:id` +- `PATCH /books/:id` +- `DELETE /books/:id` + +Supported query params for `GET /books`: + +- `q` (search in title/author) +- `author` (author filter) +- `min_year`, `max_year` +- `sort_by`: `id`, `title`, `author`, `published_year`, `created_at` +- `order`: `asc`, `desc` +- `page` (default: 1) +- `per_page` (default: 10, max: 50) +- `include_stats` (default: true) + +For `GET /books/:id`: + +- `include_reviews=true` to include dependent reviews in response + +### Reviews (Dependent Resource) + +- `GET /books/:id/reviews` +- `POST /books/:id/reviews` +- `PATCH /books/:book_id/reviews/:review_id` +- `DELETE /books/:book_id/reviews/:review_id` + +### Analytics + +- `GET /analytics/books/top-rated` + +Supported query params: + +- `limit` (default: 5, max: 25) +- `min_reviews` (default: 1, max: 50) + +## Example Requests + +Search/filter/pagination: + +```bash +curl "http://localhost:${APP_HOST_PORT:-18080}/books?q=the&min_year=1900&page=1&per_page=5&sort_by=title&order=asc" +``` + +Create a book: + +```bash +curl -X POST http://localhost:${APP_HOST_PORT:-18080}/books \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Dune", + "author": "Frank Herbert", + "isbn": "9780441013593", + "published_year": 1965 + }' +``` + +Add a review: + +```bash +curl -X POST http://localhost:${APP_HOST_PORT:-18080}/books/1/reviews \ + -H "Content-Type: application/json" \ + -d '{ + "reviewer": "qa-team@example.com", + "rating": 5, + "comment": "Excellent world building." + }' +``` + +Analytics: + +```bash +curl "http://localhost:${APP_HOST_PORT:-18080}/analytics/books/top-rated?limit=3&min_reviews=1" +``` + +## Troubleshooting + +1. Keploy record fails to attach: + +- Make sure you are on Linux/WSL2 +- Ensure container name is `ruby-books-app` + +2. Database schema looks stale: + +- Run `docker compose down -v` and restart + +3. Port conflicts: + +- Ensure ports 5432 and 8000 are free + +## Notes + +- This quickstart is intentionally API-focused for Keploy record/replay workflows. +- For deterministic recordings, prefer running the scripted flow instead of manual calls. \ No newline at end of file diff --git a/sinatra-postgres-quickstart/app.rb b/sinatra-postgres-quickstart/app.rb new file mode 100644 index 0000000..cb388a4 --- /dev/null +++ b/sinatra-postgres-quickstart/app.rb @@ -0,0 +1,685 @@ +require 'sinatra' +require 'sinatra/json' +require 'pg' +require 'json' + +# Database configuration +DB_CONFIG = { + host: ENV['DB_HOST'] || 'localhost', + port: ENV['DB_PORT'] || 5432, + dbname: ENV['DB_NAME'] || 'booksdb', + user: ENV['DB_USER'] || 'postgres', + password: ENV['DB_PASSWORD'] || 'postgres' +}.freeze + +SCHEMA_SQL = <<~SQL + CREATE TABLE IF NOT EXISTS books ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + author VARCHAR(255) NOT NULL, + isbn VARCHAR(13) UNIQUE, + published_year INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT books_published_year_check CHECK ( + published_year IS NULL OR (published_year >= 1450 AND published_year <= EXTRACT(YEAR FROM CURRENT_DATE) + 1) + ) + ); + + ALTER TABLE books ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + ALTER TABLE books ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + + CREATE TABLE IF NOT EXISTS reviews ( + id SERIAL PRIMARY KEY, + book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, + reviewer VARCHAR(120) NOT NULL, + rating INTEGER NOT NULL, + comment TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT reviews_rating_check CHECK (rating >= 1 AND rating <= 5), + CONSTRAINT reviews_comment_len_check CHECK (comment IS NULL OR char_length(comment) <= 1000) + ); + + CREATE INDEX IF NOT EXISTS idx_books_title ON books (title); + CREATE INDEX IF NOT EXISTS idx_books_author ON books (author); + CREATE INDEX IF NOT EXISTS idx_books_published_year ON books (published_year); + CREATE INDEX IF NOT EXISTS idx_reviews_book_id ON reviews (book_id); +SQL + +# Configure Sinatra +set :port, ENV['PORT'] || 8000 +set :bind, '0.0.0.0' + +# Initialize database schema on startup +begin + startup_conn = PG.connect(DB_CONFIG) + startup_conn.exec(SCHEMA_SQL) + startup_conn.close +rescue PG::Error => e + puts "Failed to initialize database schema: #{e.message}" +end + +helpers do + def db_connection + PG.connect(DB_CONFIG) + rescue PG::Error => e + halt_json(500, { error: 'Database connection failed' }) + end + + def halt_json(status, payload) + content_type :json + halt status, payload.to_json + end + + def parse_json_body + body = request.body.read + halt_json(400, { error: 'Request body is required' }) if body.to_s.strip.empty? + + JSON.parse(body) + rescue JSON::ParserError + halt_json(400, { error: 'Invalid JSON format' }) + end + + def valid_positive_integer?(value) + value.to_s.match?(/\A[1-9]\d*\z/) + end + + def parse_integer_param(raw_value, name, min: nil, max: nil, default: nil) + return default if raw_value.nil? || raw_value.to_s.strip.empty? + + string_value = raw_value.to_s.strip + unless string_value.match?(/\A-?\d+\z/) + halt_json(400, { error: "#{name} must be an integer" }) + end + + value = string_value.to_i + if min && value < min + halt_json(400, { error: "#{name} must be >= #{min}" }) + end + + if max && value > max + halt_json(400, { error: "#{name} must be <= #{max}" }) + end + + value + end + + def parse_boolean_param(raw_value, name, default: false) + return default if raw_value.nil? + + normalized = raw_value.to_s.downcase.strip + return true if %w[1 true yes y].include?(normalized) + return false if %w[0 false no n].include?(normalized) + + halt_json(400, { error: "#{name} must be a boolean (true/false)" }) + end + + def parse_pagination_params + page = parse_integer_param(params['page'], 'page', min: 1, default: 1) + per_page = parse_integer_param(params['per_page'], 'per_page', min: 1, max: 50, default: 10) + + { + page: page, + per_page: per_page, + offset: (page - 1) * per_page + } + end + + def serialize_book(row) + { + id: row['id'].to_i, + title: row['title'], + author: row['author'], + isbn: row['isbn'], + published_year: row['published_year']&.to_i, + created_at: row['created_at'], + updated_at: row['updated_at'], + review_count: row['review_count'] ? row['review_count'].to_i : nil, + avg_rating: row['avg_rating'] ? row['avg_rating'].to_f : nil + } + end + + def serialize_review(row) + { + id: row['id'].to_i, + book_id: row['book_id'].to_i, + reviewer: row['reviewer'], + rating: row['rating'].to_i, + comment: row['comment'], + created_at: row['created_at'], + updated_at: row['updated_at'] + } + end + + def ensure_book_exists!(conn, book_id) + result = conn.exec_params('SELECT id FROM books WHERE id = $1', [book_id]) + halt_json(404, { error: 'Book not found' }) if result.ntuples.zero? + end + + def ensure_unique_isbn!(conn, isbn, exclude_book_id: nil) + return if isbn.nil? || isbn.to_s.strip.empty? + + query = 'SELECT id FROM books WHERE isbn = $1' + params = [isbn] + + if exclude_book_id + query += ' AND id <> $2' + params << exclude_book_id + end + + result = conn.exec_params(query, params) + halt_json(400, { error: 'ISBN already exists' }) unless result.ntuples.zero? + end + + def validate_book_payload!(payload, require_all_fields: true) + halt_json(400, { error: 'Payload must be a JSON object' }) unless payload.is_a?(Hash) + + allowed_fields = %w[title author isbn published_year] + unknown_fields = payload.keys - allowed_fields + unless unknown_fields.empty? + halt_json(400, { error: "Unknown field(s): #{unknown_fields.join(', ')}" }) + end + + if require_all_fields + %w[title author].each do |required_key| + if payload[required_key].to_s.strip.empty? + halt_json(400, { error: "#{required_key.capitalize} is required" }) + end + end + elsif (payload.keys & allowed_fields).empty? + halt_json(400, { error: 'At least one field must be provided for update' }) + end + + if payload.key?('title') && payload['title'].to_s.strip.empty? + halt_json(400, { error: 'Title cannot be empty' }) + end + + if payload.key?('author') && payload['author'].to_s.strip.empty? + halt_json(400, { error: 'Author cannot be empty' }) + end + + if payload.key?('isbn') && !payload['isbn'].nil? && payload['isbn'].to_s.length > 13 + halt_json(400, { error: 'ISBN must not exceed 13 characters' }) + end + + if payload.key?('published_year') && !payload['published_year'].nil? + unless payload['published_year'].is_a?(Integer) + halt_json(400, { error: 'Published year must be an integer' }) + end + + current_year_plus_one = Time.now.year + 1 + if payload['published_year'] < 1450 || payload['published_year'] > current_year_plus_one + halt_json(400, { error: "Published year must be between 1450 and #{current_year_plus_one}" }) + end + end + end + + def validate_review_payload!(payload, require_all_fields: true) + halt_json(400, { error: 'Payload must be a JSON object' }) unless payload.is_a?(Hash) + + allowed_fields = %w[reviewer rating comment] + unknown_fields = payload.keys - allowed_fields + unless unknown_fields.empty? + halt_json(400, { error: "Unknown field(s): #{unknown_fields.join(', ')}" }) + end + + if require_all_fields + %w[reviewer rating].each do |required_key| + if payload[required_key].nil? || payload[required_key].to_s.strip.empty? + halt_json(400, { error: "#{required_key.capitalize} is required" }) + end + end + elsif payload.empty? + halt_json(400, { error: 'At least one field must be provided for review update' }) + end + + if payload.key?('reviewer') && payload['reviewer'].to_s.strip.empty? + halt_json(400, { error: 'Reviewer cannot be empty' }) + end + + if payload.key?('rating') + unless payload['rating'].is_a?(Integer) + halt_json(400, { error: 'Rating must be an integer between 1 and 5' }) + end + + unless payload['rating'].between?(1, 5) + halt_json(400, { error: 'Rating must be between 1 and 5' }) + end + end + + if payload.key?('comment') && !payload['comment'].nil? && payload['comment'].to_s.length > 1000 + halt_json(400, { error: 'Comment must not exceed 1000 characters' }) + end + end +end + +# Health check endpoint +get '/health' do + conn = PG.connect(DB_CONFIG) + conn.exec('SELECT 1') + json({ status: 'healthy', service: 'Ruby Books API', database: 'connected' }) +rescue PG::Error => e + halt_json(503, { status: 'unhealthy', service: 'Ruby Books API', database: 'disconnected' }) +ensure + conn.close if conn +end + +# Get books with filtering, search, sorting, and pagination +get '/books' do + content_type :json + pagination = parse_pagination_params + + min_year = parse_integer_param(params['min_year'], 'min_year', min: 1450) + max_year = parse_integer_param(params['max_year'], 'max_year', min: 1450) + if min_year && max_year && min_year > max_year + halt_json(400, { error: 'min_year must be less than or equal to max_year' }) + end + + sort_map = { + 'id' => 'b.id', + 'title' => 'b.title', + 'author' => 'b.author', + 'published_year' => 'b.published_year', + 'created_at' => 'b.created_at' + } + sort_by = params['sort_by'] || 'id' + sort_column = sort_map[sort_by] + unless sort_column + halt_json(400, { error: "sort_by must be one of: #{sort_map.keys.join(', ')}" }) + end + + order = (params['order'] || 'asc').downcase + unless %w[asc desc].include?(order) + halt_json(400, { error: 'order must be either asc or desc' }) + end + + include_stats = parse_boolean_param(params['include_stats'], 'include_stats', default: true) + + query_params = [] + where_clauses = [] + + if params['q'] && !params['q'].strip.empty? + query_params << "%#{params['q'].strip.downcase}%" + where_clauses << "(LOWER(b.title) LIKE $#{query_params.length} OR LOWER(b.author) LIKE $#{query_params.length})" + end + + if params['author'] && !params['author'].strip.empty? + query_params << "%#{params['author'].strip.downcase}%" + where_clauses << "LOWER(b.author) LIKE $#{query_params.length}" + end + + if min_year + query_params << min_year + where_clauses << "b.published_year >= $#{query_params.length}" + end + + if max_year + query_params << max_year + where_clauses << "b.published_year <= $#{query_params.length}" + end + + where_sql = where_clauses.empty? ? '' : "WHERE #{where_clauses.join(' AND ')}" + + conn = db_connection + + count_result = conn.exec_params("SELECT COUNT(*) AS total FROM books b #{where_sql}", query_params) + total_records = count_result[0]['total'].to_i + + query_params << pagination[:per_page] + limit_placeholder = "$#{query_params.length}" + query_params << pagination[:offset] + offset_placeholder = "$#{query_params.length}" + + stats_sql = if include_stats + ', COALESCE(s.review_count, 0) AS review_count, COALESCE(s.avg_rating, 0) AS avg_rating' + else + '' + end + + join_sql = if include_stats + 'LEFT JOIN ( + SELECT book_id, COUNT(*) AS review_count, ROUND(AVG(rating)::numeric, 2) AS avg_rating + FROM reviews + GROUP BY book_id + ) s ON s.book_id = b.id' + else + '' + end + + books_result = conn.exec_params( + "SELECT b.*#{stats_sql} + FROM books b + #{join_sql} + #{where_sql} + ORDER BY #{sort_column} #{order.upcase} + LIMIT #{limit_placeholder} + OFFSET #{offset_placeholder}", + query_params + ) + + books = books_result.map { |row| serialize_book(row) } + total_pages = (total_records.to_f / pagination[:per_page]).ceil + + json( + books: books, + pagination: { + page: pagination[:page], + per_page: pagination[:per_page], + total_records: total_records, + total_pages: total_pages + } + ) +ensure + conn.close if conn +end + +# Get a specific book by ID with optional related reviews +get '/books/:id' do + content_type :json + book_id = params['id'] + halt_json(400, { error: 'Invalid ID format. ID must be a positive integer' }) unless valid_positive_integer?(book_id) + + include_reviews = parse_boolean_param(params['include_reviews'], 'include_reviews', default: false) + + conn = db_connection + book_result = conn.exec_params( + 'SELECT b.*, COALESCE(s.review_count, 0) AS review_count, COALESCE(s.avg_rating, 0) AS avg_rating + FROM books b + LEFT JOIN ( + SELECT book_id, COUNT(*) AS review_count, ROUND(AVG(rating)::numeric, 2) AS avg_rating + FROM reviews + GROUP BY book_id + ) s ON s.book_id = b.id + WHERE b.id = $1', + [book_id] + ) + halt_json(404, { error: 'Book not found' }) if book_result.ntuples.zero? + + response = serialize_book(book_result[0]) + if include_reviews + reviews_result = conn.exec_params('SELECT * FROM reviews WHERE book_id = $1 ORDER BY id', [book_id]) + response[:reviews] = reviews_result.map { |review| serialize_review(review) } + end + + json(response) +ensure + conn.close if conn +end + +# Create a new book +post '/books' do + content_type :json + request_body = parse_json_body + validate_book_payload!(request_body, require_all_fields: true) + + conn = db_connection + ensure_unique_isbn!(conn, request_body['isbn']) + result = conn.exec_params( + 'INSERT INTO books (title, author, isbn, published_year) VALUES ($1, $2, $3, $4) RETURNING *', + [ + request_body['title'].strip, + request_body['author'].strip, + request_body['isbn'], + request_body['published_year'] + ] + ) + + status 201 + json({ message: 'Book created successfully', book: serialize_book(result[0]) }) +rescue PG::UniqueViolation + halt_json(400, { error: 'ISBN already exists' }) +ensure + conn.close if conn +end + +# Update a book fully +put '/books/:id' do + content_type :json + book_id = params['id'] + halt_json(400, { error: 'Invalid ID format. ID must be a positive integer' }) unless valid_positive_integer?(book_id) + + request_body = parse_json_body + validate_book_payload!(request_body, require_all_fields: true) + + conn = db_connection + ensure_book_exists!(conn, book_id) + ensure_unique_isbn!(conn, request_body['isbn'], exclude_book_id: book_id) + + result = conn.exec_params( + 'UPDATE books + SET title = $1, author = $2, isbn = $3, published_year = $4, updated_at = CURRENT_TIMESTAMP + WHERE id = $5 + RETURNING *', + [ + request_body['title'].strip, + request_body['author'].strip, + request_body['isbn'], + request_body['published_year'], + book_id + ] + ) + + json({ message: 'Book updated successfully', book: serialize_book(result[0]) }) +rescue PG::UniqueViolation + halt_json(400, { error: 'ISBN already exists' }) +ensure + conn.close if conn +end + +# Partial update (PATCH) a book +patch '/books/:id' do + content_type :json + book_id = params['id'] + halt_json(400, { error: 'Invalid ID format. ID must be a positive integer' }) unless valid_positive_integer?(book_id) + + request_body = parse_json_body + validate_book_payload!(request_body, require_all_fields: false) + + conn = db_connection + existing_result = conn.exec_params('SELECT * FROM books WHERE id = $1', [book_id]) + halt_json(404, { error: 'Book not found' }) if existing_result.ntuples.zero? + + existing = existing_result[0] + title = request_body.key?('title') ? request_body['title']&.strip : existing['title'] + author = request_body.key?('author') ? request_body['author']&.strip : existing['author'] + isbn = request_body.key?('isbn') ? request_body['isbn'] : existing['isbn'] + published_year = if request_body.key?('published_year') + request_body['published_year'] + else + existing['published_year']&.to_i + end + + ensure_unique_isbn!(conn, isbn, exclude_book_id: book_id) + + result = conn.exec_params( + 'UPDATE books + SET title = $1, author = $2, isbn = $3, published_year = $4, updated_at = CURRENT_TIMESTAMP + WHERE id = $5 + RETURNING *', + [title, author, isbn, published_year, book_id] + ) + + json({ message: 'Book updated successfully', book: serialize_book(result[0]) }) +rescue PG::UniqueViolation + halt_json(400, { error: 'ISBN already exists' }) +ensure + conn.close if conn +end + +# Delete a book +delete '/books/:id' do + content_type :json + book_id = params['id'] + halt_json(400, { error: 'Invalid ID format. ID must be a positive integer' }) unless valid_positive_integer?(book_id) + + conn = db_connection + ensure_book_exists!(conn, book_id) + conn.exec_params('DELETE FROM books WHERE id = $1', [book_id]) + + json({ message: 'Book deleted successfully' }) +ensure + conn.close if conn +end + +# Get reviews for a book +get '/books/:id/reviews' do + content_type :json + book_id = params['id'] + halt_json(400, { error: 'Invalid ID format. ID must be a positive integer' }) unless valid_positive_integer?(book_id) + + pagination = parse_pagination_params + + conn = db_connection + ensure_book_exists!(conn, book_id) + + count_result = conn.exec_params('SELECT COUNT(*) AS total FROM reviews WHERE book_id = $1', [book_id]) + total_records = count_result[0]['total'].to_i + + result = conn.exec_params( + 'SELECT * FROM reviews WHERE book_id = $1 ORDER BY id LIMIT $2 OFFSET $3', + [book_id, pagination[:per_page], pagination[:offset]] + ) + + reviews = result.map { |row| serialize_review(row) } + total_pages = (total_records.to_f / pagination[:per_page]).ceil + + json( + reviews: reviews, + pagination: { + page: pagination[:page], + per_page: pagination[:per_page], + total_records: total_records, + total_pages: total_pages + } + ) +ensure + conn.close if conn +end + +# Create a review for a book +post '/books/:id/reviews' do + content_type :json + book_id = params['id'] + halt_json(400, { error: 'Invalid ID format. ID must be a positive integer' }) unless valid_positive_integer?(book_id) + + request_body = parse_json_body + validate_review_payload!(request_body, require_all_fields: true) + + conn = db_connection + ensure_book_exists!(conn, book_id) + + result = conn.exec_params( + 'INSERT INTO reviews (book_id, reviewer, rating, comment) + VALUES ($1, $2, $3, $4) + RETURNING *', + [book_id, request_body['reviewer'].strip, request_body['rating'], request_body['comment']] + ) + + status 201 + json({ message: 'Review created successfully', review: serialize_review(result[0]) }) +ensure + conn.close if conn +end + +# Partially update a review +patch '/books/:book_id/reviews/:review_id' do + content_type :json + book_id = params['book_id'] + review_id = params['review_id'] + halt_json(400, { error: 'Invalid book ID format. ID must be a positive integer' }) unless valid_positive_integer?(book_id) + halt_json(400, { error: 'Invalid review ID format. ID must be a positive integer' }) unless valid_positive_integer?(review_id) + + request_body = parse_json_body + validate_review_payload!(request_body, require_all_fields: false) + + conn = db_connection + ensure_book_exists!(conn, book_id) + + existing_result = conn.exec_params('SELECT * FROM reviews WHERE id = $1 AND book_id = $2', [review_id, book_id]) + halt_json(404, { error: 'Review not found' }) if existing_result.ntuples.zero? + + existing = existing_result[0] + reviewer = request_body.key?('reviewer') ? request_body['reviewer']&.strip : existing['reviewer'] + rating = request_body.key?('rating') ? request_body['rating'] : existing['rating'].to_i + comment = request_body.key?('comment') ? request_body['comment'] : existing['comment'] + + result = conn.exec_params( + 'UPDATE reviews + SET reviewer = $1, rating = $2, comment = $3, updated_at = CURRENT_TIMESTAMP + WHERE id = $4 AND book_id = $5 + RETURNING *', + [reviewer, rating, comment, review_id, book_id] + ) + + json({ message: 'Review updated successfully', review: serialize_review(result[0]) }) +ensure + conn.close if conn +end + +# Delete a review +delete '/books/:book_id/reviews/:review_id' do + content_type :json + book_id = params['book_id'] + review_id = params['review_id'] + halt_json(400, { error: 'Invalid book ID format. ID must be a positive integer' }) unless valid_positive_integer?(book_id) + halt_json(400, { error: 'Invalid review ID format. ID must be a positive integer' }) unless valid_positive_integer?(review_id) + + conn = db_connection + ensure_book_exists!(conn, book_id) + + result = conn.exec_params('DELETE FROM reviews WHERE id = $1 AND book_id = $2 RETURNING id', [review_id, book_id]) + halt_json(404, { error: 'Review not found' }) if result.ntuples.zero? + + json({ message: 'Review deleted successfully' }) +ensure + conn.close if conn +end + +# Aggregate endpoint to demo dependent calls and analytics use-cases +get '/analytics/books/top-rated' do + content_type :json + limit = parse_integer_param(params['limit'], 'limit', min: 1, max: 25, default: 5) + min_reviews = parse_integer_param(params['min_reviews'], 'min_reviews', min: 1, max: 50, default: 1) + + conn = db_connection + result = conn.exec_params( + 'SELECT b.id, b.title, b.author, + COUNT(r.id) AS review_count, + ROUND(AVG(r.rating)::numeric, 2) AS avg_rating + FROM books b + JOIN reviews r ON r.book_id = b.id + GROUP BY b.id + HAVING COUNT(r.id) >= $1 + ORDER BY avg_rating DESC, review_count DESC, b.id ASC + LIMIT $2', + [min_reviews, limit] + ) + + top_books = result.map do |row| + { + id: row['id'].to_i, + title: row['title'], + author: row['author'], + review_count: row['review_count'].to_i, + avg_rating: row['avg_rating'].to_f + } + end + + json({ books: top_books, count: top_books.length }) +ensure + conn.close if conn +end + +# Error handlers +error 400 do + json({ error: 'Bad Request' }) +end + +error 404 do + json({ error: 'Not Found' }) +end + +error 500 do + json({ error: 'Internal Server Error' }) +end \ No newline at end of file diff --git a/sinatra-postgres-quickstart/config.ru b/sinatra-postgres-quickstart/config.ru new file mode 100644 index 0000000..76a6edf --- /dev/null +++ b/sinatra-postgres-quickstart/config.ru @@ -0,0 +1,2 @@ +require './app' +run Sinatra::Application diff --git a/sinatra-postgres-quickstart/docker-compose.yml b/sinatra-postgres-quickstart/docker-compose.yml new file mode 100644 index 0000000..3d711e5 --- /dev/null +++ b/sinatra-postgres-quickstart/docker-compose.yml @@ -0,0 +1,43 @@ +services: + postgres: + image: postgres:15-alpine + container_name: ruby-books-postgres + environment: + POSTGRES_DB: booksdb + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - books-network + + app: + build: . + container_name: ruby-books-app + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: booksdb + DB_USER: postgres + DB_PASSWORD: postgres + PORT: 8000 + ports: + - "${APP_HOST_PORT:-18080}:8000" + depends_on: + postgres: + condition: service_healthy + networks: + - books-network + +volumes: + postgres_data: + +networks: + books-network: + driver: bridge diff --git a/sinatra-postgres-quickstart/docker-compose.yml.test b/sinatra-postgres-quickstart/docker-compose.yml.test new file mode 100644 index 0000000..8c77a38 --- /dev/null +++ b/sinatra-postgres-quickstart/docker-compose.yml.test @@ -0,0 +1,46 @@ +services: + postgres: + image: postgres:15-alpine + container_name: ruby-books-postgres-test + environment: + POSTGRES_DB: booksdb + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5433:5432" + volumes: + - postgres_data_test:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - books-network-test + + app: + build: . + container_name: ruby-books-app-test + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: booksdb + DB_USER: postgres + DB_PASSWORD: postgres + PORT: 8001 + ports: + - "8001:8001" + command: bundle exec puma config.ru -b tcp://0.0.0.0:8001 -e production + depends_on: + postgres: + condition: service_healthy + networks: + - books-network-test + +volumes: + postgres_data_test: + +networks: + books-network-test: + driver: bridge diff --git a/sinatra-postgres-quickstart/init.sql b/sinatra-postgres-quickstart/init.sql new file mode 100644 index 0000000..0be87a9 --- /dev/null +++ b/sinatra-postgres-quickstart/init.sql @@ -0,0 +1,47 @@ +-- Create the books table +CREATE TABLE IF NOT EXISTS books ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + author VARCHAR(255) NOT NULL, + isbn VARCHAR(13) UNIQUE, + published_year INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT books_published_year_check CHECK ( + published_year IS NULL OR (published_year >= 1450 AND published_year <= EXTRACT(YEAR FROM CURRENT_DATE) + 1) + ) +); + +-- Related resource table for dependent API calls +CREATE TABLE IF NOT EXISTS reviews ( + id SERIAL PRIMARY KEY, + book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, + reviewer VARCHAR(120) NOT NULL, + rating INTEGER NOT NULL, + comment TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT reviews_rating_check CHECK (rating >= 1 AND rating <= 5), + CONSTRAINT reviews_comment_len_check CHECK (comment IS NULL OR char_length(comment) <= 1000) +); + +CREATE INDEX IF NOT EXISTS idx_books_title ON books (title); +CREATE INDEX IF NOT EXISTS idx_books_author ON books (author); +CREATE INDEX IF NOT EXISTS idx_books_published_year ON books (published_year); +CREATE INDEX IF NOT EXISTS idx_reviews_book_id ON reviews (book_id); + +-- Insert sample data +INSERT INTO books (title, author, isbn, published_year) VALUES + ('The Great Gatsby', 'F. Scott Fitzgerald', '9780743273565', 1925), + ('To Kill a Mockingbird', 'Harper Lee', '9780061120084', 1960), + ('1984', 'George Orwell', '9780451524935', 1949), + ('Pride and Prejudice', 'Jane Austen', '9780141439518', 1813), + ('The Catcher in the Rye', 'J.D. Salinger', '9780316769174', 1951) +ON CONFLICT (isbn) DO NOTHING; + +INSERT INTO reviews (book_id, reviewer, rating, comment) VALUES + (1, 'alice@example.com', 5, 'A timeless classic with vivid writing.'), + (1, 'bob@example.com', 4, 'Great characters and atmosphere.'), + (2, 'charlie@example.com', 5, 'Powerful themes and storytelling.'), + (3, 'dana@example.com', 4, 'Still highly relevant today.') +ON CONFLICT DO NOTHING; diff --git a/sinatra-postgres-quickstart/keploy.yml b/sinatra-postgres-quickstart/keploy.yml new file mode 100755 index 0000000..7f32e48 --- /dev/null +++ b/sinatra-postgres-quickstart/keploy.yml @@ -0,0 +1,85 @@ +# Generated by Keploy (3.3.10) +path: "" +appName: keploy-ruby-postgresql-quickstart +appId: 0 +command: docker compose up --build +templatize: + testSets: [] +port: 0 +e2e: false +dnsPort: 26789 +proxyPort: 16789 +incomingProxyPort: 36789 +debug: false +disableTele: false +disableANSI: false +containerName: ruby-books-app +networkName: "" +buildDelay: 30 +test: + selectedTests: {} + globalNoise: + global: {} + test-sets: {} + delay: 5 + host: "" + port: 0 + grpcPort: 0 + apiTimeout: 5 + skipCoverage: false + coverageReportPath: "" + ignoreOrdering: true + mongoPassword: "" + language: "" + removeUnusedMocks: false + fallBackOnMiss: false + jacocoAgentPath: "" + basePath: "" + mocking: true + ignoredTests: {} + disableLineCoverage: false + disableMockUpload: true + useLocalMock: false + updateTemplate: false + mustPass: false + maxFailAttempts: 5 + maxFlakyChecks: 1 + protoFile: "" + protoDir: "" + protoInclude: [] +record: + filters: [] + basePath: "" + recordTimer: 0s + metadata: "" + sync: false + globalPassthrough: false +report: + selectedTestSets: {} + showFullBody: false + reportPath: "" + summary: false + testCaseIDs: [] +disableMapping: false +configPath: "" +bypassRules: [] +generateGithubActions: false +keployContainer: keploy-v3 +keployNetwork: keploy-network +cmdType: docker-compose +contract: + services: [] + tests: [] + path: "" + download: false + generate: false + driven: consumer + mappings: + servicesMapping: {} + self: s1 +inCi: false +serverPort: 0 +mockDownload: + registryIds: [] + +# Visit [https://keploy.io/docs/running-keploy/configuration-file/] to learn about using keploy through configuration file. diff --git a/sinatra-postgres-quickstart/run-keploy.sh b/sinatra-postgres-quickstart/run-keploy.sh new file mode 100755 index 0000000..078689c --- /dev/null +++ b/sinatra-postgres-quickstart/run-keploy.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_NAME="$(basename "$ROOT_DIR")" +POSTGRES_VOLUME="${PROJECT_NAME}_postgres_data" +APP_HOST_PORT="${APP_HOST_PORT:-}" +BASE_URL="${BASE_URL:-}" +CONTAINER_NAME="ruby-books-app" +SUDO_PREFIX=() + +cd "$ROOT_DIR" + +if ! command -v keploy >/dev/null 2>&1; then + echo "Error: keploy binary not found in PATH" + exit 1 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "Error: curl is required" + exit 1 +fi + +if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + if sudo -n true >/dev/null 2>&1; then + SUDO_PREFIX=(sudo -n) + else + echo "Refreshing sudo credentials for Keploy..." + sudo -v + SUDO_PREFIX=(sudo) + fi +fi + +request_counter=0 + +choose_free_port() { + local candidate + local occupied_ports + if ! command -v ss >/dev/null 2>&1; then + echo "Error: 'ss' command not found. Install iproute2 or set APP_HOST_PORT explicitly." >&2 + return 1 + fi + occupied_ports="$(ss -ltnH | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\1/' | sort -u)" + + for candidate in $(seq 18080 18120); do + if ! printf '%s\n' "$occupied_ports" | grep -qx "$candidate"; then + printf '%s' "$candidate" + return 0 + fi + done + + return 1 +} + +if [[ -z "$APP_HOST_PORT" ]]; then + APP_HOST_PORT="$(choose_free_port)" || { + echo "Error: no free host port found in range 18080-18120" + exit 1 + } +fi + +if [[ -z "$BASE_URL" ]]; then + BASE_URL="http://localhost:${APP_HOST_PORT}" +fi + +export APP_HOST_PORT BASE_URL + +cleanup() { + echo "Cleaning up containers..." + docker compose down -v --remove-orphans >/dev/null 2>&1 || true + docker volume rm -f "$POSTGRES_VOLUME" >/dev/null 2>&1 || true +} + +trap cleanup EXIT + +echo "Resetting docker services for a clean recording run..." +docker compose down -v --remove-orphans >/dev/null 2>&1 || true +docker volume rm -f "$POSTGRES_VOLUME" >/dev/null 2>&1 || true + +echo "Starting Keploy record mode..." +"${SUDO_PREFIX[@]}" keploy record -c "APP_HOST_PORT=$APP_HOST_PORT BASE_URL=$BASE_URL docker compose up --build" --container-name "$CONTAINER_NAME" --cmd-type docker-compose --sync & +KEPLOY_PID=$! + +wait_for_health() { + local retries=60 + local wait_seconds=2 + + echo "Waiting for API readiness..." + for ((i=1; i<=retries; i++)); do + local status + local body + status="$(curl -sS -o /tmp/keploy_health_body.$$ -w "%{http_code}" "$BASE_URL/health" 2>/dev/null || true)" + body="$(cat /tmp/keploy_health_body.$$ 2>/dev/null || true)" + rm -f /tmp/keploy_health_body.$$ >/dev/null 2>&1 || true + + if [[ "$status" == "200" ]] && [[ "$body" == *'"status":"healthy"'* ]] && [[ "$body" == *'"service":"Ruby Books API"'* ]]; then + echo "API is ready." + return 0 + fi + + if ! kill -0 "$KEPLOY_PID" >/dev/null 2>&1; then + echo "Error: Keploy record process exited before API became ready" + return 1 + fi + + sleep "$wait_seconds" + done + + echo "Error: API did not become healthy in time" + return 1 +} + +api_call() { + local label="$1" + local method="$2" + local endpoint="$3" + local expected_status="$4" + local payload="${5:-}" + + request_counter=$((request_counter + 1)) + local tmp_body + tmp_body="$(mktemp)" + + local status + if [[ -n "$payload" ]]; then + status="$(curl -sS -o "$tmp_body" -w "%{http_code}" -X "$method" "$BASE_URL$endpoint" -H "Content-Type: application/json" -d "$payload")" + else + status="$(curl -sS -o "$tmp_body" -w "%{http_code}" -X "$method" "$BASE_URL$endpoint")" + fi + + local body + body="$(cat "$tmp_body")" + rm -f "$tmp_body" + + echo "[$request_counter] $label" >&2 + echo " $method $endpoint => $status" >&2 + echo " $body" >&2 + + if [[ "$status" != "$expected_status" ]]; then + echo "Error: expected HTTP $expected_status but got $status" + kill -INT "$KEPLOY_PID" >/dev/null 2>&1 || true + wait "$KEPLOY_PID" >/dev/null 2>&1 || true + exit 1 + fi + + printf '%s' "$body" +} + +extract_first_numeric_id() { + local json_input="$1" + + printf '%s' "$json_input" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -n 1 | grep -oE '[0-9]+' +} + +generate_isbn() { + local seed + seed="$(( ($(date +%s) + $$ + request_counter) % 10000000000 ))" + printf '978%010d' "$seed" +} + +wait_for_health + +# 1 +api_call "Health check" "GET" "/health" "200" >/dev/null + +# 2 +api_call "List books" "GET" "/books?page=1&per_page=3&sort_by=title&order=asc" "200" >/dev/null + +# 3 +api_call "Search and filter books" "GET" "/books?q=the&min_year=1900&include_stats=true" "200" >/dev/null + +# 4 - expected validation error +api_call "Invalid pagination" "GET" "/books?page=0" "400" >/dev/null + +# 5 +api_call "Get book with reviews" "GET" "/books/1?include_reviews=true" "200" >/dev/null + +# 6 - expected 404 +api_call "Get missing book" "GET" "/books/9999" "404" >/dev/null + +# 7 +book_isbn="$(generate_isbn)" +new_book_json="{\"title\":\"Dune\",\"author\":\"Frank Herbert\",\"isbn\":\"$book_isbn\",\"published_year\":1965}" +create_book_response="$(api_call "Create book" "POST" "/books" "201" "$new_book_json")" +BOOK_ID="$(extract_first_numeric_id "$create_book_response")" + +if [[ -z "$BOOK_ID" ]]; then + echo "Error: Failed to capture BOOK_ID from create response" + exit 1 +fi + +# 8 - expected duplicate ISBN error +api_call "Create duplicate ISBN" "POST" "/books" "400" "$new_book_json" >/dev/null + +# 9 +api_call "Patch book" "PATCH" "/books/$BOOK_ID" "200" '{"title":"Dune (Extended Edition)","published_year":1966}' >/dev/null + +# 10 +updated_book_isbn="$(generate_isbn)" +api_call "Put full book update" "PUT" "/books/$BOOK_ID" "200" "{\"title\":\"Dune: Revised\",\"author\":\"Frank Herbert\",\"isbn\":\"$updated_book_isbn\",\"published_year\":1967}" >/dev/null + +# 11 +review_1_response="$(api_call "Create first review" "POST" "/books/$BOOK_ID/reviews" "201" '{"reviewer":"qa-team@example.com","rating":5,"comment":"Excellent world building."}')" +REVIEW_ID="$(extract_first_numeric_id "$review_1_response")" + +if [[ -z "$REVIEW_ID" ]]; then + echo "Error: Failed to capture REVIEW_ID from create review response" + exit 1 +fi + +# 12 +review_2_response="$(api_call "Create second review" "POST" "/books/$BOOK_ID/reviews" "201" '{"reviewer":"integration@example.com","rating":4,"comment":"Great pacing and detail."}')" +REVIEW_2_ID="$(extract_first_numeric_id "$review_2_response")" + +if [[ -z "$REVIEW_2_ID" ]]; then + echo "Error: Failed to capture REVIEW_2_ID from second review" + exit 1 +fi + +# 13 +api_call "List reviews with pagination" "GET" "/books/$BOOK_ID/reviews?page=1&per_page=1" "200" >/dev/null + +# 14 +api_call "Update first review" "PATCH" "/books/$BOOK_ID/reviews/$REVIEW_ID" "200" '{"rating":5,"comment":"Still excellent after re-read."}' >/dev/null + +# 15 +api_call "Analytics top rated" "GET" "/analytics/books/top-rated?limit=3&min_reviews=1" "200" >/dev/null + +# 16 - expected validation error +api_call "Invalid review rating" "POST" "/books/$BOOK_ID/reviews" "400" '{"reviewer":"bad-rating@example.com","rating":8,"comment":"Should fail"}' >/dev/null + +# 17 +api_call "Delete second review" "DELETE" "/books/$BOOK_ID/reviews/$REVIEW_2_ID" "200" >/dev/null + +# 18 +api_call "Delete created book" "DELETE" "/books/$BOOK_ID" "200" >/dev/null + +# 19 - expected 404 after deletion +api_call "Get deleted book" "GET" "/books/$BOOK_ID" "404" >/dev/null + +# 20 +api_call "Final books listing" "GET" "/books?page=1&per_page=5" "200" >/dev/null + +echo "Stopping Keploy recording..." +kill -INT "$KEPLOY_PID" >/dev/null 2>&1 || true +wait "$KEPLOY_PID" >/dev/null 2>&1 || true + +echo "Recorded $request_counter API interactions." +echo "Run replay with: keploy test -c \"docker compose up\" --container-name \"$CONTAINER_NAME\" --delay 20" \ No newline at end of file