diff --git a/Gemfile b/Gemfile index 89b02d5..789d0ff 120000 --- a/Gemfile +++ b/Gemfile @@ -1 +1 @@ -rails.7.0.gemfile \ No newline at end of file +rails.7.1.gemfile \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 6c352b9..f56b39c 120000 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1 +1 @@ -rails.7.0.gemfile.lock \ No newline at end of file +rails.7.1.gemfile.lock \ No newline at end of file diff --git a/infinum_json_api_setup.gemspec b/infinum_json_api_setup.gemspec index cb1501a..7c807a7 100644 --- a/infinum_json_api_setup.gemspec +++ b/infinum_json_api_setup.gemspec @@ -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' diff --git a/lib/generators/infinum_json_api_setup/templates/config/locales/json_api.en.yml b/lib/generators/infinum_json_api_setup/templates/config/locales/json_api.en.yml index 8018d21..75ee14b 100644 --- a/lib/generators/infinum_json_api_setup/templates/config/locales/json_api.en.yml +++ b/lib/generators/infinum_json_api_setup/templates/config/locales/json_api.en.yml @@ -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 diff --git a/lib/infinum_json_api_setup.rb b/lib/infinum_json_api_setup.rb index 1956725..0862cc9 100644 --- a/lib/infinum_json_api_setup.rb +++ b/lib/infinum_json_api_setup.rb @@ -1,5 +1,6 @@ require 'rails' +require 'accept_language' require 'json' require 'jsonapi_parameters' require 'jsonapi/serializer' @@ -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' diff --git a/lib/infinum_json_api_setup/json_api/locale_negotiation.rb b/lib/infinum_json_api_setup/json_api/locale_negotiation.rb new file mode 100644 index 0000000..296aa55 --- /dev/null +++ b/lib/infinum_json_api_setup/json_api/locale_negotiation.rb @@ -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 diff --git a/rails.7.1.gemfile.lock b/rails.7.1.gemfile.lock index eb8a8a2..a95fe33 100644 --- a/rails.7.1.gemfile.lock +++ b/rails.7.1.gemfile.lock @@ -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 @@ -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) diff --git a/rails.8.0.gemfile.lock b/rails.8.0.gemfile.lock index 09ba47a..f6ad5c5 100644 --- a/rails.8.0.gemfile.lock +++ b/rails.8.0.gemfile.lock @@ -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 @@ -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) diff --git a/spec/dummy/app/controllers/api/v1/base_controller.rb b/spec/dummy/app/controllers/api/v1/base_controller.rb index 4631485..d6311c2 100644 --- a/spec/dummy/app/controllers/api/v1/base_controller.rb +++ b/spec/dummy/app/controllers/api/v1/base_controller.rb @@ -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 diff --git a/spec/dummy/app/controllers/api/v1/hello_controller.rb b/spec/dummy/app/controllers/api/v1/hello_controller.rb new file mode 100644 index 0000000..8c94c53 --- /dev/null +++ b/spec/dummy/app/controllers/api/v1/hello_controller.rb @@ -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 diff --git a/spec/dummy/app/controllers/api/v1/locations_controller.rb b/spec/dummy/app/controllers/api/v1/locations_controller.rb index e025bb2..b2f855a 100644 --- a/spec/dummy/app/controllers/api/v1/locations_controller.rb +++ b/spec/dummy/app/controllers/api/v1/locations_controller.rb @@ -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) @@ -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 diff --git a/spec/dummy/config/initializers/i18n.rb b/spec/dummy/config/initializers/i18n.rb new file mode 100644 index 0000000..313178d --- /dev/null +++ b/spec/dummy/config/initializers/i18n.rb @@ -0,0 +1,3 @@ +I18n.available_locales = [:en, :de] + +I18n.default_locale = :en diff --git a/spec/dummy/config/locales/de.yml b/spec/dummy/config/locales/de.yml new file mode 100644 index 0000000..4da422a --- /dev/null +++ b/spec/dummy/config/locales/de.yml @@ -0,0 +1,2 @@ +de: + hello: "Hallo Welt" diff --git a/spec/dummy/config/locales/json_api.de.yml b/spec/dummy/config/locales/json_api.de.yml new file mode 100644 index 0000000..61f89f5 --- /dev/null +++ b/spec/dummy/config/locales/json_api.de.yml @@ -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 diff --git a/spec/dummy/config/locales/json_api.en.yml b/spec/dummy/config/locales/json_api.en.yml index 8018d21..75ee14b 100644 --- a/spec/dummy/config/locales/json_api.en.yml +++ b/spec/dummy/config/locales/json_api.en.yml @@ -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 diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 73d8723..b7e29ba 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -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 diff --git a/spec/requests/api/v1/error_handling_spec.rb b/spec/requests/api/v1/error_handling_spec.rb index 36dcd70..32b6ce5 100644 --- a/spec/requests/api/v1/error_handling_spec.rb +++ b/spec/requests/api/v1/error_handling_spec.rb @@ -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 @@ -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 @@ -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 @@ -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') diff --git a/spec/requests/api/v1/locale_negotiation_spec.rb b/spec/requests/api/v1/locale_negotiation_spec.rb new file mode 100644 index 0000000..ca442f0 --- /dev/null +++ b/spec/requests/api/v1/locale_negotiation_spec.rb @@ -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