From cc532ecac361c12cb5ce451fb84dca0adb4dd0a6 Mon Sep 17 00:00:00 2001 From: Samnang Chhun Date: Thu, 9 Jan 2025 17:53:09 +0700 Subject: [PATCH] Beneficiary Stats API --- .../api/v1/beneficiaries/stats_controller.rb | 33 ++++++++++++ app/models/aggregate_data_query.rb | 15 ++++++ app/models/stat_result.rb | 11 ++++ .../v1/beneficiary_stats_request_schema.rb | 49 ++++++++++++++++++ app/serailizers/stat_serializer.rb | 17 +++++++ config/routes.rb | 2 + lib/invalid_request_schema_responder.rb | 3 +- .../open_ews_api/v1/beneficiaries_spec.rb | 50 +++++++++++++++++++ .../api_response_schemas/stat_schema.rb | 10 ++++ 9 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 app/controllers/api/v1/beneficiaries/stats_controller.rb create mode 100644 app/models/aggregate_data_query.rb create mode 100644 app/models/stat_result.rb create mode 100644 app/request_schemas/v1/beneficiary_stats_request_schema.rb create mode 100644 app/serailizers/stat_serializer.rb create mode 100644 spec/support/api_response_schemas/stat_schema.rb diff --git a/app/controllers/api/v1/beneficiaries/stats_controller.rb b/app/controllers/api/v1/beneficiaries/stats_controller.rb new file mode 100644 index 000000000..81d93ef84 --- /dev/null +++ b/app/controllers/api/v1/beneficiaries/stats_controller.rb @@ -0,0 +1,33 @@ +module API + module V1 + module Beneficiaries + class StatsController < BaseController + def index + validate_request_schema( + with: ::V1::BeneficiaryStatsRequestSchema, + serializer_class: StatSerializer, + **serializer_options + ) do |permitted_params| + AggregateDataQuery.new(permitted_params).apply(beneficiaries_scope) + end + end + + private + + def beneficiaries_scope + current_account.beneficiaries + end + + def serializer_options + { + input_params: request.query_parameters, + decorator_class: nil, + pagination_options: { + sort_direction: :asc + } + } + end + end + end + end +end diff --git a/app/models/aggregate_data_query.rb b/app/models/aggregate_data_query.rb new file mode 100644 index 000000000..1dd489ea4 --- /dev/null +++ b/app/models/aggregate_data_query.rb @@ -0,0 +1,15 @@ +class AggregateDataQuery + attr_reader :filters, :groups + + def initialize(options) + @filters = options.fetch(:filters, {}) + @groups = Array(options.fetch(:groups)) + end + + def apply(scope) + result = scope.where(filters).group(groups).count + result.map.with_index do |(key, value), index| + StatResult.new(groups:, key:, value:, sequence_number: index + 1) + end + end +end diff --git a/app/models/stat_result.rb b/app/models/stat_result.rb new file mode 100644 index 000000000..2123f797b --- /dev/null +++ b/app/models/stat_result.rb @@ -0,0 +1,11 @@ +StatResult = Data.define(:key, :groups, :value, :sequence_number) do + def id + StatResult::IDGenerator.generate_id(key) + end +end + +class StatResult::IDGenerator + def self.generate_id(key) + Digest::SHA256.hexdigest(Array(key).reject(&:blank?).map(&:downcase).join(":")) + end +end diff --git a/app/request_schemas/v1/beneficiary_stats_request_schema.rb b/app/request_schemas/v1/beneficiary_stats_request_schema.rb new file mode 100644 index 000000000..35f9fd572 --- /dev/null +++ b/app/request_schemas/v1/beneficiary_stats_request_schema.rb @@ -0,0 +1,49 @@ +module V1 + class BeneficiaryStatsRequestSchema < ApplicationRequestSchema + # Group = Data.define(:name, :column) + + # COUNTRY_GROUP = Group.new(name: "country", column: :iso_country_code) + # REGION_GROUP = Group.new(name: "region", column: :iso_region_code) + # LOCALITY_GROUP = Group.new(name: "locality", column: :locality) + # + # GROUPS = [ COUNTRY_GROUP, REGION_GROUP, LOCALITY_GROUP ].freeze + # + # VALID_GROUP_BY_OPTIONS = [ + # [ COUNTRY_GROUP, REGION_GROUP, LOCALITY_GROUP ] + # ] + + params do + optional(:filter).value(:hash).hash do + optional(:gender).filled(Types::UpcaseString, included_in?: Contact.gender.values) + end + required(:group_by).value(array[:string]) + end + + # rule(:group_by) do |context:| + # context[:groups] = find_groups(value) + # key.failure("is invalid") if context[:groups].blank? + # end + + def output + result = super + + # filter = params.fetch(:filter) + # conditions = filter.slice(:type, :locality) + # conditions[:iso_country_code] = filter.fetch(:country) if filter.key?(:country) + # conditions[:iso_region_code] = filter.fetch(:region) if filter.key?(:region) + # + # result = {} + # + # result[:named_scopes] = :available + # result[:conditions] = conditions + result[:groups] = result[:group_by] + result + end + + private + + def find_groups(group_names) + VALID_GROUP_BY_OPTIONS.find { |group_list| group_list.map(&:name).sort == group_names.sort } + end + end +end diff --git a/app/serailizers/stat_serializer.rb b/app/serailizers/stat_serializer.rb new file mode 100644 index 000000000..138942d37 --- /dev/null +++ b/app/serailizers/stat_serializer.rb @@ -0,0 +1,17 @@ +class StatSerializer < JSONAPISerializer + attribute :result do |object| + result = {} + + object.groups.each_with_index do |group, index| + group_value = Array(object.key)[index] + + # NOTE: Handle field value object. Eg. enumerize field. + group_value = group_value.value if group_value.respond_to?(:value) + + result[group] = group_value + end + + result[:value] = object.value + result + end +end diff --git a/config/routes.rb b/config/routes.rb index 888e65cb9..93d1c1810 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -64,6 +64,8 @@ namespace :v1, module: "api/v1", as: "api_v1", defaults: { format: "json" } do resources :beneficiaries, only: [ :index, :create, :show, :update ] do + get "stats" => "beneficiaries/stats#index", on: :collection + resources :addresses, only: [ :index, :create, :show, :destroy ] end end diff --git a/lib/invalid_request_schema_responder.rb b/lib/invalid_request_schema_responder.rb index 51a1e9028..70da28900 100644 --- a/lib/invalid_request_schema_responder.rb +++ b/lib/invalid_request_schema_responder.rb @@ -8,7 +8,7 @@ def display(_resource, given_options = {}) end def json_resource_errors - serializer_class = options.fetch(:error_serializer_class) { resource.class.error_serializer_class } + serializer_class = options.fetch(:error_serializer_class) { JSONAPIRequestSchemaErrorsSerializer } errors = serializer_class.new(resource).as_json Rails.logger.info(errors) @@ -16,4 +16,3 @@ def json_resource_errors errors end end - diff --git a/spec/requests/open_ews_api/v1/beneficiaries_spec.rb b/spec/requests/open_ews_api/v1/beneficiaries_spec.rb index 3c860997b..e4e8cfce4 100644 --- a/spec/requests/open_ews_api/v1/beneficiaries_spec.rb +++ b/spec/requests/open_ews_api/v1/beneficiaries_spec.rb @@ -276,4 +276,54 @@ ) end end + + get "/v1/beneficiaries/stats" do + with_options scope: :filter do + parameter( + :gender, "Must be either `M` or `F`", + required: false + ) + end + + parameter( + :group_by, + "An array of fields to group by.`", + required: true + ) + + example "Fetch beneficiaries stats" do + account = create(:account) + create_list(:beneficiary, 2, account:, gender: "M") + create(:beneficiary, account:, gender: "F") + + set_authorization_header_for(account) + do_request(group_by: [ "gender" ]) + + expect(response_status).to eq(200) + expect(response_body).to match_jsonapi_resource_collection_schema("stat") + results = json_response.fetch("data").map { |data| data.dig("attributes", "result") } + + expect(results).to eq( + [ + { + "gender" => "M", + "value" => 2 + }, + { + "gender" => "F", + "value" => 1 + } + ] + ) + end + + example "Handles invalid requests", document: false do + account = create(:account) + + set_authorization_header_for(account) + do_request + + expect(response_status).to eq(400) + end + end end diff --git a/spec/support/api_response_schemas/stat_schema.rb b/spec/support/api_response_schemas/stat_schema.rb new file mode 100644 index 000000000..409692713 --- /dev/null +++ b/spec/support/api_response_schemas/stat_schema.rb @@ -0,0 +1,10 @@ +module APIResponseSchema + StatSchema = Dry::Schema.JSON do + required(:id).filled(:str?) + required(:type).filled(eql?: "stat") + + required(:attributes).schema do + required(:result).filled(:hash?) + end + end +end