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
2 changes: 1 addition & 1 deletion Gemfile
2 changes: 1 addition & 1 deletion Gemfile.lock
1 change: 1 addition & 0 deletions infinum_json_api_setup.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Gem::Specification.new do |s|
s.required_ruby_version = '>= 3.2'
s.metadata = { 'rubygems_mfa_required' => 'true' }

s.add_dependency 'accept_language', '~> 2.0'
s.add_dependency 'jsonapi_parameters'
s.add_dependency 'jsonapi-query_builder'
s.add_dependency 'jsonapi-serializer'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ en:
bad_request:
title: Bad Request
detail: Bad request
invalid_locale: Invalid locale
not_found:
title: Not found
detail: Resource not found
Expand Down
2 changes: 2 additions & 0 deletions lib/infinum_json_api_setup.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'rails'

require 'accept_language'
require 'json'
require 'jsonapi_parameters'
require 'jsonapi/serializer'
Expand All @@ -14,6 +15,7 @@
require 'infinum_json_api_setup/json_api/error_serializer'

require 'infinum_json_api_setup/json_api/content_negotiation'
require 'infinum_json_api_setup/json_api/locale_negotiation'
require 'infinum_json_api_setup/json_api/request_parsing'
require 'infinum_json_api_setup/json_api/serializer_options'
require 'infinum_json_api_setup/json_api/responder'
Expand Down
45 changes: 45 additions & 0 deletions lib/infinum_json_api_setup/json_api/locale_negotiation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module InfinumJsonApiSetup
module JsonApi
module LocaleNegotiation
extend ActiveSupport::Concern

included do
around_action :setup_locale
class_attribute :fallback_to_default_locale_if_invalid, default: false
end

def rescue_with_handler(*)
I18n.with_locale(locale) { super }
end

private

def setup_locale(&)
return render_invalid_locale_error if locale.blank?

I18n.with_locale(locale, &)
end

def locale
return I18n.default_locale if locale_from_request.blank?

parsed_locale = AcceptLanguage.parse(locale_from_request).match(*I18n.available_locales)

if parsed_locale.present?
parsed_locale
elsif fallback_to_default_locale_if_invalid
I18n.default_locale
end
end

def locale_from_request
request.env['HTTP_ACCEPT_LANGUAGE']
end

def render_invalid_locale_error
message = I18n.t('json_api.errors.bad_request.invalid_locale')
render_error(InfinumJsonApiSetup::Error::BadRequest.new(message:))
end
end
end
end
2 changes: 2 additions & 0 deletions rails.7.1.gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ PATH
remote: .
specs:
infinum_json_api_setup (0.0.8)
accept_language (~> 2.0)
json_schemer (~> 0.2)
jsonapi-query_builder
jsonapi-serializer
Expand All @@ -13,6 +14,7 @@ PATH
GEM
remote: https://rubygems.org/
specs:
accept_language (2.0.8)
actioncable (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
Expand Down
2 changes: 2 additions & 0 deletions rails.8.0.gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ PATH
remote: .
specs:
infinum_json_api_setup (0.0.8)
accept_language (~> 2.0)
json_schemer (~> 0.2)
jsonapi-query_builder
jsonapi-serializer
Expand All @@ -13,6 +14,7 @@ PATH
GEM
remote: https://rubygems.org/
specs:
accept_language (2.0.8)
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
Expand Down
1 change: 1 addition & 0 deletions spec/dummy/app/controllers/api/v1/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module V1
class BaseController < ApplicationController
include InfinumJsonApiSetup::JsonApi::ErrorHandling
include InfinumJsonApiSetup::JsonApi::ContentNegotiation
include InfinumJsonApiSetup::JsonApi::LocaleNegotiation

self.responder = InfinumJsonApiSetup::JsonApi::Responder
respond_to :json_api
Expand Down
11 changes: 11 additions & 0 deletions spec/dummy/app/controllers/api/v1/hello_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Api
module V1
class HelloController < BaseController
# GET /api/v1/hello
def index
message = I18n.t('hello')
render json: { data: { type: 'hello', attributes: { message: message } } }
end
end
end
end
6 changes: 0 additions & 6 deletions spec/dummy/app/controllers/api/v1/locations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ module V1
class LocationsController < BaseController
include Pundit::Authorization

around_action :with_locale

# GET /api/v1/locations
def index
q = Api::V1::Locations::Query.new(Location.all, params.to_unsafe_hash)
Expand Down Expand Up @@ -44,10 +42,6 @@ def destroy

private

def with_locale(&)
I18n.with_locale(params.fetch(:locale, :en), &)
end

def permitted_params
params.require(:location).permit(:latitude, :longitude)
end
Expand Down
3 changes: 3 additions & 0 deletions spec/dummy/config/initializers/i18n.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
I18n.available_locales = [:en, :de]

I18n.default_locale = :en
2 changes: 2 additions & 0 deletions spec/dummy/config/locales/de.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
de:
hello: "Hallo Welt"
22 changes: 22 additions & 0 deletions spec/dummy/config/locales/json_api.de.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
de:
json_api:
errors:
bad_request:
title: Ungültige Anfrage
detail: Ungültige Anfrage
invalid_locale: Ungültige Sprache
not_found:
title: Nicht gefunden
detail: Ressource nicht gefunden
forbidden:
title: Verboten
detail: Sie dürfen diese Aktion nicht ausführen
unauthorized:
title: Nicht autorisiert
detail: Sie müssen angemeldet sein, um diese Aktion auszuführen
unprocessable_entity:
title: Nicht verarbeitbar
detail: Anfrage kann nicht verarbeitet werden
internal_server_error:
title: Interner Fehler
detail: Ein interner Fehler ist aufgetreten
1 change: 1 addition & 0 deletions spec/dummy/config/locales/json_api.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ en:
bad_request:
title: Bad Request
detail: Bad request
invalid_locale: Invalid locale
not_found:
title: Not found
detail: Resource not found
Expand Down
1 change: 1 addition & 0 deletions spec/dummy/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
namespace :api do
namespace :v1 do
resources :locations, only: [:index, :create, :show, :update, :destroy]
resources :hello, only: [:index]
end
end
end
46 changes: 32 additions & 14 deletions spec/requests/api/v1/error_handling_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@

expect(response).to have_http_status(:bad_request)
error = json_response['errors'].first
expect(error['title']).to eq('Bad Request')
expect(error['detail']).to match(/param is missing or the value is empty/)
expect(error).to include('title' => 'Bad Request', 'detail' => match(/param is missing or the value is empty/))
end
end

Expand All @@ -29,20 +28,39 @@
get '/api/v1/locations/0', headers: default_headers

expect(response).to have_http_status(:not_found)
expect(json_response['errors'].first['title']).to eq('Not found')
expect(json_response['errors'].first['detail']).to eq('Resource not found')
expect(json_response['errors'].first).to include('title' => 'Not found', 'detail' => 'Resource not found')
end

context 'with another locale' do
it 'responds with localized error message' do
get '/api/v1/locations/0', headers: default_headers.merge('Accept-Language' => 'de')

expect(response).to have_http_status(:not_found)
expect(json_response['errors'].first).to include('title' => 'Nicht gefunden',
'detail' => 'Ressource nicht gefunden')
end
end
end

context 'when client is not authorized to perform requested action' do
let(:loc) { create(:location, :fourth_quadrant) }

it 'responds with 403 Forbidden' do
loc = create(:location, :fourth_quadrant)
get "/api/v1/locations/#{loc.id}", headers: default_headers

expect(response).to have_http_status(:forbidden)
expect(json_response['errors'].first['title']).to eq('Forbidden')
expect(json_response['errors'].first['detail'])
.to eq('You are not allowed to perform this action')
expect(json_response['errors'].first).to include('title' => 'Forbidden',
'detail' => 'You are not allowed to perform this action')
end

context 'with another locale' do
it 'responds with localized error message' do
get "/api/v1/locations/#{loc.id}", headers: default_headers.merge('Accept-Language' => 'de')

expect(response).to have_http_status(:forbidden)
expect(json_response['errors'].first).to include('title' => 'Verboten',
'detail' => 'Sie dürfen diese Aktion nicht ausführen')
end
end
end

Expand All @@ -64,8 +82,8 @@

expect(response).to have_http_status(:bad_request)
error = json_response['errors'].first
expect(error['title']).to eq('Bad Request')
expect(error['detail']).to eq('title is not a permitted sort attribute')
expect(error).to include('title' => 'Bad Request',
'detail' => 'title is not a permitted sort attribute')
end
end

Expand All @@ -89,14 +107,14 @@
get '/api/v1/locations/0', headers: default_headers

expect(response).to have_http_status(:internal_server_error)
expect(json_response['errors'].first['title']).to eq('Internal Server Error')
expect(json_response['errors'].first['detail']).to eq('Something went wrong')
expect(json_response['errors'].first).to include('title' => 'Internal Server Error',
'detail' => 'Something went wrong')
end
end

context 'when client requests invalid locale' do
it 'responds with 500 InternalServerError' do
get '/api/v1/locations?locale=--', headers: default_headers
it 'responds with 400 BadRequest' do
get '/api/v1/locations', headers: default_headers.merge('Accept-Language' => 'fr')

expect(response).to have_http_status(:bad_request)
expect(json_response['errors'].first['title']).to eq('Bad Request')
Expand Down
66 changes: 66 additions & 0 deletions spec/requests/api/v1/locale_negotiation_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
describe 'Locale negotiation' do
describe 'with localized content' do
it 'returns English hello message when Accept-Language is en' do
get '/api/v1/hello', headers: default_headers.merge('Accept-Language': 'en')

expect(response).to have_http_status(:ok)

expect(json_response['data']['attributes']['message']).to eq('Hello world')
end

it 'returns German hello message when Accept-Language is de' do
get '/api/v1/hello', headers: default_headers.merge('Accept-Language': 'de')

expect(response).to have_http_status(:ok)

expect(json_response['data']['attributes']['message']).to eq('Hallo Welt')
end

it 'returns default English message when no Accept-Language header provided' do
get '/api/v1/hello', headers: default_headers

expect(response).to have_http_status(:ok)

expect(json_response['data']['attributes']['message']).to eq('Hello world')
end

it 'prioritizes first valid locale from complex Accept-Language header' do
get '/api/v1/hello', headers: default_headers.merge('Accept-Language': 'de-DE,de;q=0.9,en;q=0.8')

expect(response).to have_http_status(:ok)

expect(json_response['data']['attributes']['message']).to eq('Hallo Welt')
end
end

describe 'error handling' do
it 'responds with 400 Bad Request when Accept-Language header is malformed' do
get '/api/v1/hello', headers: default_headers.merge('Accept-Language': '123-invalid')

expect(response).to have_http_status(:bad_request)
expect(response).to include_error_detail('Invalid locale')
end

it 'responds with 400 Bad Request and error message for invalid locale' do
get '/api/v1/hello', headers: default_headers.merge('Accept-Language': 'fr')

expect(response).to have_http_status(:bad_request)
expect(response).to include_error_detail('Invalid locale')
end

context 'when fallback to default is enabled' do
around do |example|
Api::V1::HelloController.fallback_to_default_locale_if_invalid = true
example.run
Api::V1::HelloController.fallback_to_default_locale_if_invalid = false
end

it 'responds with 200 OK and default locale body' do
get '/api/v1/hello', headers: default_headers.merge('Accept-Language': 'fr')

expect(response).to have_http_status(:ok)
expect(json_response['data']['attributes']['message']).to eq('Hello world')
end
end
end
end