Skip to content

Commit 525ba91

Browse files
committed
feat(api): implement response envelope, error handling, and sparse fieldsets
New concerns and modules: - ApiPublic::ResponseEnvelope - Standardized response format with render_envelope, pagination meta, and HATEOAS links helpers - ApiPublic::ErrorHandler - rescue_from handlers with standardized error format including error codes and request IDs - ApiPublic::SparseFieldsets - fields parameter for requesting only specific fields to reduce payload size - ApiPublic::Errors - Custom error classes (ValidationError, PropertyNotFoundError, RateLimitedError, etc.) Updated BaseController to include all new concerns. Added error_handling_spec.rb for testing error responses. All existing API tests pass (27+ examples).
1 parent 901c701 commit 525ba91

File tree

6 files changed

+516
-0
lines changed

6 files changed

+516
-0
lines changed

app/controllers/api_public/v1/base_controller.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
require_dependency "api_public/errors"
4+
15
module ApiPublic
26
module V1
37
class BaseController < ActionController::Base
48
include SubdomainTenant
9+
include ApiPublic::ResponseEnvelope
10+
include ApiPublic::ErrorHandler
11+
include ApiPublic::SparseFieldsets
512

613
skip_before_action :verify_authenticity_token
714

@@ -26,3 +33,4 @@ def set_api_public_cache_headers
2633
end
2734
end
2835
end
36+
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
# Concern for standardized error handling across API controllers
5+
# Provides rescue handlers and error rendering
6+
module ErrorHandler
7+
extend ActiveSupport::Concern
8+
9+
included do
10+
# Rescue from custom API errors
11+
rescue_from ApiPublic::Errors::ApiError, with: :render_api_error
12+
13+
# Rescue from ActiveRecord errors
14+
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
15+
rescue_from ActiveRecord::RecordInvalid, with: :render_validation_error
16+
17+
# Rescue from parameter errors
18+
rescue_from ActionController::ParameterMissing, with: :render_parameter_missing
19+
end
20+
21+
private
22+
23+
# Render a standardized API error response
24+
def render_api_error(error)
25+
# Add request ID for debugging
26+
error_response = {
27+
error: error.to_h.merge(request_id: request.uuid)
28+
}
29+
30+
# Add retry-after header for rate limiting
31+
if error.is_a?(ApiPublic::Errors::RateLimitedError)
32+
response.headers["Retry-After"] = error.retry_after.to_s
33+
end
34+
35+
render json: error_response, status: error.status
36+
end
37+
38+
# Handle ActiveRecord::RecordNotFound
39+
def render_not_found(exception)
40+
model_name = exception.model || "Resource"
41+
message = "#{model_name} not found"
42+
43+
render json: {
44+
error: {
45+
code: "NOT_FOUND",
46+
message: message,
47+
status: 404,
48+
request_id: request.uuid
49+
}
50+
}, status: :not_found
51+
end
52+
53+
# Handle ActiveRecord::RecordInvalid
54+
def render_validation_error(exception)
55+
errors = exception.record&.errors&.to_hash || {}
56+
57+
render json: {
58+
error: {
59+
code: "VALIDATION_FAILED",
60+
message: "Validation failed",
61+
status: 422,
62+
details: { validation_errors: errors },
63+
request_id: request.uuid
64+
}
65+
}, status: :unprocessable_entity
66+
end
67+
68+
# Handle ActionController::ParameterMissing
69+
def render_parameter_missing(exception)
70+
render json: {
71+
error: {
72+
code: "VALIDATION_FAILED",
73+
message: "Missing required parameter: #{exception.param}",
74+
status: 400,
75+
details: { missing_param: exception.param.to_s },
76+
request_id: request.uuid
77+
}
78+
}, status: :bad_request
79+
end
80+
81+
# Generic 404 helper
82+
def render_not_found_error(message = "Resource not found", code: "NOT_FOUND")
83+
render json: {
84+
error: {
85+
code: code,
86+
message: message,
87+
status: 404,
88+
request_id: request.uuid
89+
}
90+
}, status: :not_found
91+
end
92+
93+
# Generic 400 helper
94+
def render_bad_request(message, code: "BAD_REQUEST", details: {})
95+
render json: {
96+
error: {
97+
code: code,
98+
message: message,
99+
status: 400,
100+
details: details.presence,
101+
request_id: request.uuid
102+
}.compact
103+
}, status: :bad_request
104+
end
105+
end
106+
end
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
# Concern for standardized API response envelopes
5+
# Provides consistent response format across all endpoints
6+
#
7+
# Standard envelope format:
8+
# {
9+
# "data": { ... } | [...],
10+
# "meta": { "total": 100, "page": 1, ... },
11+
# "_links": { "self": "/...", "next": "/..." },
12+
# "_errors": []
13+
# }
14+
module ResponseEnvelope
15+
extend ActiveSupport::Concern
16+
17+
private
18+
19+
# Render a standardized response envelope
20+
#
21+
# @param data [Hash, Array] The primary response data
22+
# @param meta [Hash] Pagination and metadata (optional)
23+
# @param links [Hash] HATEOAS-style links (optional)
24+
# @param errors [Array] Partial failure errors (optional)
25+
# @param status [Symbol, Integer] HTTP status (default: :ok)
26+
def render_envelope(data:, meta: nil, links: nil, errors: nil, status: :ok)
27+
response = { data: data }
28+
response[:meta] = meta if meta.present?
29+
response[:_links] = links if links.present?
30+
response[:_errors] = errors if errors.present? && errors.any?
31+
32+
render json: response, status: status
33+
end
34+
35+
# Build pagination meta from a collection
36+
#
37+
# @param total [Integer] Total count of items
38+
# @param page [Integer] Current page number
39+
# @param per_page [Integer] Items per page
40+
# @return [Hash] Pagination metadata
41+
def build_pagination_meta(total:, page:, per_page:)
42+
total_pages = per_page.positive? ? (total.to_f / per_page).ceil : 0
43+
44+
{
45+
total: total,
46+
page: page,
47+
per_page: per_page,
48+
total_pages: total_pages
49+
}
50+
end
51+
52+
# Build HATEOAS-style pagination links
53+
#
54+
# @param base_path [String] Base URL path for the resource
55+
# @param page [Integer] Current page number
56+
# @param total_pages [Integer] Total number of pages
57+
# @param query_params [Hash] Additional query parameters
58+
# @return [Hash] Navigation links
59+
def build_pagination_links(base_path:, page:, total_pages:, query_params: {})
60+
links = {
61+
self: build_page_url(base_path, page, query_params)
62+
}
63+
64+
links[:first] = build_page_url(base_path, 1, query_params) if page > 1
65+
links[:prev] = build_page_url(base_path, page - 1, query_params) if page > 1
66+
links[:next] = build_page_url(base_path, page + 1, query_params) if page < total_pages
67+
links[:last] = build_page_url(base_path, total_pages, query_params) if page < total_pages
68+
69+
links
70+
end
71+
72+
# Build a URL with page parameter
73+
def build_page_url(base_path, page, query_params)
74+
params = query_params.merge(page: page)
75+
query_string = params.to_query
76+
query_string.present? ? "#{base_path}?#{query_string}" : base_path
77+
end
78+
79+
# Render a success response with optional message
80+
def render_success(message: nil, data: nil, status: :ok)
81+
response = { success: true }
82+
response[:message] = message if message
83+
response[:data] = data if data
84+
85+
render json: response, status: status
86+
end
87+
88+
# Render a created response (201)
89+
def render_created(data:, location: nil)
90+
response = { data: data }
91+
headers["Location"] = location if location
92+
93+
render json: response, status: :created
94+
end
95+
end
96+
end
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
# Concern for sparse fieldsets - allows clients to request only specific fields
5+
# Reduces payload size for bandwidth-constrained clients
6+
#
7+
# Usage:
8+
# GET /api_public/v1/en/properties?fields=id,slug,title,primary_image_url
9+
#
10+
module SparseFieldsets
11+
extend ActiveSupport::Concern
12+
13+
private
14+
15+
# Filter a hash to only include specified fields
16+
#
17+
# @param data [Hash] The full data hash
18+
# @param allowed_fields [Array<Symbol>] Fields allowed to be requested
19+
# @return [Hash] Filtered hash with only requested fields
20+
def apply_sparse_fieldsets(data, allowed_fields:)
21+
requested_fields = parse_fields_param
22+
return data if requested_fields.blank?
23+
24+
# Only allow whitelisted fields
25+
valid_fields = requested_fields & allowed_fields.map(&:to_s)
26+
return data if valid_fields.blank?
27+
28+
# Filter the data
29+
data.slice(*valid_fields.map(&:to_sym))
30+
end
31+
32+
# Apply sparse fieldsets to an array of records
33+
#
34+
# @param records [Array<Hash>] Array of record hashes
35+
# @param allowed_fields [Array<Symbol>] Fields allowed to be requested
36+
# @return [Array<Hash>] Array with filtered records
37+
def apply_sparse_fieldsets_to_collection(records, allowed_fields:)
38+
requested_fields = parse_fields_param
39+
return records if requested_fields.blank?
40+
41+
# Only allow whitelisted fields
42+
valid_fields = requested_fields & allowed_fields.map(&:to_s)
43+
return records if valid_fields.blank?
44+
45+
# Filter each record
46+
records.map { |record| record.slice(*valid_fields.map(&:to_sym)) }
47+
end
48+
49+
# Parse the fields parameter from request
50+
#
51+
# @return [Array<String>] List of requested field names
52+
def parse_fields_param
53+
return [] unless params[:fields].present?
54+
55+
params[:fields].to_s.split(",").map(&:strip).reject(&:blank?)
56+
end
57+
58+
# Check if sparse fieldsets are requested
59+
def sparse_fieldsets_requested?
60+
params[:fields].present?
61+
end
62+
63+
# Property fields that can be requested via sparse fieldsets
64+
PROPERTY_ALLOWED_FIELDS = %i[
65+
id
66+
slug
67+
reference
68+
title
69+
description
70+
price_sale_current_cents
71+
price_rental_monthly_current_cents
72+
formatted_price
73+
currency
74+
count_bedrooms
75+
count_bathrooms
76+
count_garages
77+
constructed_area
78+
area_unit
79+
for_sale
80+
for_rent
81+
highlighted
82+
latitude
83+
longitude
84+
primary_image_url
85+
prop_photos
86+
created_at
87+
updated_at
88+
].freeze
89+
90+
# Link fields that can be requested via sparse fieldsets
91+
LINK_ALLOWED_FIELDS = %i[
92+
id
93+
slug
94+
title
95+
url
96+
position
97+
order
98+
visible
99+
external
100+
].freeze
101+
102+
# Testimonial fields that can be requested via sparse fieldsets
103+
TESTIMONIAL_ALLOWED_FIELDS = %i[
104+
id
105+
name
106+
role
107+
company
108+
content
109+
rating
110+
avatar_url
111+
featured
112+
visible
113+
].freeze
114+
end
115+
end

0 commit comments

Comments
 (0)