Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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'
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
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
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
4 changes: 4 additions & 0 deletions spec/dummy/config/locales/de.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
de:
hello: "Hallo Welt"


24 changes: 24 additions & 0 deletions spec/dummy/config/locales/json_api.de.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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
27 changes: 24 additions & 3 deletions spec/requests/api/v1/error_handling_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,39 @@
expect(json_response['errors'].first['title']).to eq('Not found')
expect(json_response['errors'].first['detail']).to eq('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['title']).to eq('Nicht gefunden')
expect(json_response['errors'].first['detail']).to eq('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')
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['title']).to eq('Verboten')
expect(json_response['errors'].first['detail']).to eq('Sie dürfen diese Aktion nicht ausführen')
end
end
end

context 'when request contains unpermitted sort params' do
Expand Down Expand Up @@ -95,8 +116,8 @@
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