From a48f26479945534ae31152e6d651fd0855a69616 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 4 May 2026 17:08:43 -0600 Subject: [PATCH] feat(api): expose securities and price history (#1642) * feat(api): expose securities and prices * fix(api): stabilize security price filters * fix(api): cap security pagination limits * fix(api): preserve security price decimal scale * fix(api): validate securities boolean filters * fix(api): reject blank securities boolean filters * fix(api): trim security exchange filter * fix(api): tighten security price filters * fix(api): tighten security resource filters * fix(api): tighten securities docs fixtures --- .../api/v1/securities_controller.rb | 60 +++ .../api/v1/security_prices_controller.rb | 63 +++ .../api/v1/security_resource_filtering.rb | 53 +++ .../api/v1/securities/_security.json.jbuilder | 18 + .../api/v1/securities/index.json.jbuilder | 12 + .../api/v1/securities/show.json.jbuilder | 3 + .../_security_price.json.jbuilder | 18 + .../v1/security_prices/index.json.jbuilder | 12 + .../api/v1/security_prices/show.json.jbuilder | 3 + config/routes.rb | 2 + docs/api/openapi.yaml | 366 ++++++++++++++++++ spec/requests/api/v1/securities_spec.rb | 177 +++++++++ spec/requests/api/v1/security_prices_spec.rb | 189 +++++++++ spec/swagger_helper.rb | 68 ++++ .../api/v1/securities_controller_test.rb | 182 +++++++++ .../api/v1/security_prices_controller_test.rb | 196 ++++++++++ 16 files changed, 1422 insertions(+) create mode 100644 app/controllers/api/v1/securities_controller.rb create mode 100644 app/controllers/api/v1/security_prices_controller.rb create mode 100644 app/controllers/concerns/api/v1/security_resource_filtering.rb create mode 100644 app/views/api/v1/securities/_security.json.jbuilder create mode 100644 app/views/api/v1/securities/index.json.jbuilder create mode 100644 app/views/api/v1/securities/show.json.jbuilder create mode 100644 app/views/api/v1/security_prices/_security_price.json.jbuilder create mode 100644 app/views/api/v1/security_prices/index.json.jbuilder create mode 100644 app/views/api/v1/security_prices/show.json.jbuilder create mode 100644 spec/requests/api/v1/securities_spec.rb create mode 100644 spec/requests/api/v1/security_prices_spec.rb create mode 100644 test/controllers/api/v1/securities_controller_test.rb create mode 100644 test/controllers/api/v1/security_prices_controller_test.rb diff --git a/app/controllers/api/v1/securities_controller.rb b/app/controllers/api/v1/securities_controller.rb new file mode 100644 index 000000000..bc0715d10 --- /dev/null +++ b/app/controllers/api/v1/securities_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Api::V1::SecuritiesController < Api::V1::BaseController + include Pagy::Backend + include Api::V1::SecurityResourceFiltering + + before_action :ensure_read_scope + before_action :set_security, only: :show + + def index + securities_query = apply_filters(securities_scope).order(:ticker, :exchange_operating_mic, :name) + @per_page = safe_per_page_param + + @pagy, @securities = pagy( + securities_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue Api::V1::SecurityResourceFiltering::InvalidFilterError => e + render_validation_error(e.message) + end + + def show + render :show + end + + private + + def set_security + raise ActiveRecord::RecordNotFound, "Security not found" unless valid_uuid?(params[:id]) + + @security = securities_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def securities_scope + Security + .where(id: scoped_security_ids) + end + + def apply_filters(query) + query = query.where("LOWER(securities.ticker) = ?", params[:ticker].to_s.strip.downcase) if params[:ticker].present? + query = query.where(exchange_operating_mic: params[:exchange_operating_mic].to_s.strip.upcase) if params[:exchange_operating_mic].present? + if params[:kind].present? + invalid_filter!("kind must be one of: #{Security::KINDS.join(', ')}") unless Security::KINDS.include?(params[:kind]) + + query = query.where(kind: params[:kind]) + end + if params.key?(:offline) + offline = parse_boolean_filter_param(:offline) + query = query.where(offline: offline) + end + query + end +end diff --git a/app/controllers/api/v1/security_prices_controller.rb b/app/controllers/api/v1/security_prices_controller.rb new file mode 100644 index 000000000..5463f0f97 --- /dev/null +++ b/app/controllers/api/v1/security_prices_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class Api::V1::SecurityPricesController < Api::V1::BaseController + include Pagy::Backend + include Api::V1::SecurityResourceFiltering + + before_action :ensure_read_scope + before_action :set_security_price, only: :show + + def index + security_prices_query = apply_filters(security_prices_scope).order(date: :desc, created_at: :desc) + @per_page = safe_per_page_param + + @pagy, @security_prices = pagy( + security_prices_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue Api::V1::SecurityResourceFiltering::InvalidFilterError => e + render_validation_error(e.message) + end + + def show + render :show + end + + private + + def set_security_price + raise ActiveRecord::RecordNotFound, "Security price not found" unless valid_uuid?(params[:id]) + + @security_price = security_prices_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def security_prices_scope + Security::Price + .where(security_id: scoped_security_ids) + .includes(:security) + end + + def apply_filters(query) + if params[:security_id].present? + invalid_filter!("security_id must be a valid UUID") unless valid_uuid?(params[:security_id]) + + query = query.where(security_id: params[:security_id]) + end + + query = query.where(currency: params[:currency].to_s.strip.upcase) if params[:currency].present? + query = query.where("security_prices.date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("security_prices.date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + if params.key?(:provisional) + provisional = parse_boolean_filter_param(:provisional) + query = query.where(provisional: provisional) + end + query + end +end diff --git a/app/controllers/concerns/api/v1/security_resource_filtering.rb b/app/controllers/concerns/api/v1/security_resource_filtering.rb new file mode 100644 index 000000000..8f58391f1 --- /dev/null +++ b/app/controllers/concerns/api/v1/security_resource_filtering.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Api::V1::SecurityResourceFiltering + class InvalidFilterError < StandardError; end + + BOOLEAN_FILTERS = { + "true" => true, + "1" => true, + "false" => false, + "0" => false + }.freeze + + private + + def scoped_security_ids + Security + .where(id: holding_security_ids) + .or(Security.where(id: trade_security_ids)) + .distinct + .select(:id) + end + + def holding_security_ids + Holding.where(account_id: accessible_account_ids).select(:security_id) + end + + def trade_security_ids + Trade.joins(:entry).where(entries: { account_id: accessible_account_ids }).select(:security_id) + end + + def accessible_account_ids + @accessible_account_ids ||= current_resource_owner.family.accounts.visible.accessible_by(current_resource_owner).select(:id) + end + + def parse_boolean_filter_param(key) + normalized_value = params[key].to_s.strip.downcase + + invalid_filter!("#{key} must be true or false") if normalized_value.blank? + return BOOLEAN_FILTERS.fetch(normalized_value) if BOOLEAN_FILTERS.key?(normalized_value) + + invalid_filter!("#{key} must be true or false") + end + + def parse_date_param(key) + Date.iso8601(params[key].to_s) + rescue ArgumentError + invalid_filter!("#{key} must be an ISO 8601 date") + end + + def invalid_filter!(message) + raise InvalidFilterError, message + end +end diff --git a/app/views/api/v1/securities/_security.json.jbuilder b/app/views/api/v1/securities/_security.json.jbuilder new file mode 100644 index 000000000..3fcc31b31 --- /dev/null +++ b/app/views/api/v1/securities/_security.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +json.id security.id +json.ticker security.ticker +json.name security.name +json.kind security.kind +json.country_code security.country_code +json.exchange_mic security.exchange_mic +json.exchange_acronym security.exchange_acronym +json.exchange_operating_mic security.exchange_operating_mic +json.exchange_name security.exchange_name +json.offline security.offline +json.offline_reason security.offline_reason +json.website_url security.website_url +json.logo_url security.display_logo_url +json.first_provider_price_on security.first_provider_price_on +json.created_at security.created_at.iso8601 +json.updated_at security.updated_at.iso8601 diff --git a/app/views/api/v1/securities/index.json.jbuilder b/app/views/api/v1/securities/index.json.jbuilder new file mode 100644 index 000000000..f7ffdd22d --- /dev/null +++ b/app/views/api/v1/securities/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.securities @securities do |security| + json.partial! "security", security: security +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/securities/show.json.jbuilder b/app/views/api/v1/securities/show.json.jbuilder new file mode 100644 index 000000000..7ed519050 --- /dev/null +++ b/app/views/api/v1/securities/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "security", security: @security diff --git a/app/views/api/v1/security_prices/_security_price.json.jbuilder b/app/views/api/v1/security_prices/_security_price.json.jbuilder new file mode 100644 index 000000000..9804493bb --- /dev/null +++ b/app/views/api/v1/security_prices/_security_price.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +json.id security_price.id +json.date security_price.date +json.price Money.new(security_price.price, security_price.currency).format +json.price_amount format("%.4f", security_price.price.to_d) +json.currency security_price.currency +json.provisional security_price.provisional + +json.security do + json.id security_price.security.id + json.ticker security_price.security.ticker + json.name security_price.security.name + json.exchange_operating_mic security_price.security.exchange_operating_mic +end + +json.created_at security_price.created_at.iso8601 +json.updated_at security_price.updated_at.iso8601 diff --git a/app/views/api/v1/security_prices/index.json.jbuilder b/app/views/api/v1/security_prices/index.json.jbuilder new file mode 100644 index 000000000..8c534edfc --- /dev/null +++ b/app/views/api/v1/security_prices/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.security_prices @security_prices do |security_price| + json.partial! "security_price", security_price: security_price +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/security_prices/show.json.jbuilder b/app/views/api/v1/security_prices/show.json.jbuilder new file mode 100644 index 000000000..e3a997f70 --- /dev/null +++ b/app/views/api/v1/security_prices/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "security_price", security_price: @security_price diff --git a/config/routes.rb b/config/routes.rb index ce780cc49..047af6677 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -429,6 +429,8 @@ Rails.application.routes.draw do resources :merchants, only: %i[index show] resources :rules, only: [ :index, :show ] resources :rule_runs, only: [ :index, :show ] + resources :securities, only: [ :index, :show ] + resources :security_prices, only: [ :index, :show ] resources :tags, only: %i[index show create update destroy] resources :transactions, only: [ :index, :show, :create, :update, :destroy ] diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7f42f01c7..5f5740b78 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1466,6 +1466,141 @@ components: "$ref": "#/components/schemas/Holding" pagination: "$ref": "#/components/schemas/Pagination" + Security: + type: object + required: + - id + - ticker + - kind + - offline + - created_at + - updated_at + properties: + id: + type: string + format: uuid + ticker: + type: string + name: + type: string + nullable: true + kind: + type: string + enum: + - standard + - cash + country_code: + type: string + nullable: true + exchange_mic: + type: string + nullable: true + exchange_acronym: + type: string + nullable: true + exchange_operating_mic: + type: string + nullable: true + exchange_name: + type: string + nullable: true + offline: + type: boolean + offline_reason: + type: string + nullable: true + website_url: + type: string + nullable: true + logo_url: + type: string + nullable: true + first_provider_price_on: + type: string + format: date + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + SecurityCollection: + type: object + required: + - securities + - pagination + properties: + securities: + type: array + items: + "$ref": "#/components/schemas/Security" + pagination: + "$ref": "#/components/schemas/Pagination" + SecurityPrice: + type: object + required: + - id + - date + - price + - price_amount + - currency + - provisional + - security + - created_at + - updated_at + properties: + id: + type: string + format: uuid + date: + type: string + format: date + price: + type: string + description: Formatted security price + price_amount: + type: string + description: Exact decimal security price + currency: + type: string + provisional: + type: boolean + security: + type: object + required: + - id + - ticker + properties: + id: + type: string + format: uuid + ticker: + type: string + name: + type: string + nullable: true + exchange_operating_mic: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + SecurityPriceCollection: + type: object + required: + - security_prices + - pagination + properties: + security_prices: + type: array + items: + "$ref": "#/components/schemas/SecurityPrice" + pagination: + "$ref": "#/components/schemas/Pagination" Money: type: object required: @@ -3683,6 +3818,237 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/securities": + get: + summary: List securities referenced by family investment data + tags: + - Securities + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: ticker + in: query + required: false + description: Filter by ticker symbol + schema: + type: string + - name: exchange_operating_mic + in: query + required: false + description: Filter by exchange operating MIC + schema: + type: string + - name: kind + in: query + required: false + description: Filter by security kind + schema: + type: string + enum: + - standard + - cash + - name: offline + in: query + required: false + description: Filter by offline status. When supplied, must be true or false. + schema: + type: boolean + responses: + '200': + description: securities listed + content: + application/json: + schema: + "$ref": "#/components/schemas/SecurityCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/securities/{id}": + parameters: + - name: id + in: path + required: true + description: Security ID + schema: + type: string + format: uuid + get: + summary: Retrieve a security referenced by family investment data + tags: + - Securities + security: + - apiKeyAuth: [] + responses: + '200': + description: security retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/Security" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: security not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/security_prices": + get: + summary: List security price history referenced by family investment data + tags: + - Security Prices + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: security_id + in: query + required: false + description: Filter by security ID + schema: + type: string + format: uuid + - name: currency + in: query + required: false + description: Filter by currency code + schema: + type: string + - name: start_date + in: query + required: false + description: Filter prices from this date + schema: + type: string + format: date + - name: end_date + in: query + required: false + description: Filter prices until this date + schema: + type: string + format: date + - name: provisional + in: query + required: false + description: Filter by provisional price status. When supplied, must be true + or false. + schema: + type: boolean + responses: + '200': + description: security prices listed + content: + application/json: + schema: + "$ref": "#/components/schemas/SecurityPriceCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/security_prices/{id}": + parameters: + - name: id + in: path + required: true + description: Security price ID + schema: + type: string + format: uuid + get: + summary: Retrieve a security price referenced by family investment data + tags: + - Security Prices + security: + - apiKeyAuth: [] + responses: + '200': + description: security price retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/SecurityPrice" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: security price not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/tags": get: summary: List tags diff --git a/spec/requests/api/v1/securities_spec.rb b/spec/requests/api/v1/securities_spec.rb new file mode 100644 index 000000000..b250ece3c --- /dev/null +++ b/spec/requests/api/v1/securities_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Securities', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + display_key: key, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + # Persist an invalid key shape intentionally so rswag can document 403. + ApiKey.new( + user: user, + name: 'No Read Docs Key', + key: key, + display_key: key, + scopes: [], + source: 'web' + ).tap { |api_key| api_key.save!(validate: false) } + end + + let(:'X-Api-Key') { api_key.plain_key } + + let(:account) do + Account.create!( + family: family, + name: 'Investment Account', + balance: 50_000, + currency: 'USD', + accountable: Investment.create! + ) + end + + let!(:security) do + Security.create!( + ticker: 'VTI', + name: 'Vanguard Total Stock Market ETF', + country_code: 'US', + exchange_operating_mic: 'ARCX' + ) + end + + let!(:holding) do + Holding.create!( + account: account, + security: security, + date: Date.current, + qty: 100, + price: 250.50, + amount: 25_050, + currency: 'USD' + ) + end + + path '/api/v1/securities' do + get 'List securities referenced by family investment data' do + tags 'Securities' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + parameter name: :page, in: :query, type: :integer, required: false, + description: 'Page number (default: 1)' + parameter name: :per_page, in: :query, type: :integer, required: false, + description: 'Items per page (default: 25, max: 100)' + parameter name: :ticker, in: :query, required: false, + description: 'Filter by ticker symbol', + schema: { type: :string } + parameter name: :exchange_operating_mic, in: :query, required: false, + description: 'Filter by exchange operating MIC', + schema: { type: :string } + parameter name: :kind, in: :query, required: false, + description: 'Filter by security kind', + schema: { type: :string, enum: %w[standard cash] } + parameter name: :offline, in: :query, required: false, + description: 'Filter by offline status. When supplied, must be true or false.', + schema: { type: :boolean } + + response '200', 'securities listed' do + schema '$ref' => '#/components/schemas/SecurityCollection' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '422', 'invalid filter' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:kind) { 'unsupported' } + + run_test! + end + end + end + + path '/api/v1/securities/{id}' do + parameter name: :id, in: :path, required: true, description: 'Security ID', + schema: { type: :string, format: :uuid } + + get 'Retrieve a security referenced by family investment data' do + tags 'Securities' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { security.id } + + response '200', 'security retrieved' do + schema '$ref' => '#/components/schemas/Security' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '404', 'security not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/security_prices_spec.rb b/spec/requests/api/v1/security_prices_spec.rb new file mode 100644 index 000000000..335cf1317 --- /dev/null +++ b/spec/requests/api/v1/security_prices_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Security Prices', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + display_key: key, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + # Persist an invalid key shape intentionally so rswag can document 403. + ApiKey.new( + user: user, + name: 'No Read Docs Key', + key: key, + display_key: key, + scopes: [], + source: 'web' + ).tap { |api_key| api_key.save!(validate: false) } + end + + let(:'X-Api-Key') { api_key.plain_key } + + let(:account) do + Account.create!( + family: family, + name: 'Investment Account', + balance: 50_000, + currency: 'USD', + accountable: Investment.create! + ) + end + + let!(:security) do + Security.create!( + ticker: 'VTI', + name: 'Vanguard Total Stock Market ETF', + country_code: 'US', + exchange_operating_mic: 'ARCX' + ) + end + + let!(:holding) do + Holding.create!( + account: account, + security: security, + date: Date.current, + qty: 100, + price: 250.50, + amount: 25_050, + currency: 'USD' + ) + end + + let!(:security_price) do + Security::Price.create!( + security: security, + date: Date.current, + price: 250.1234, + currency: 'USD' + ) + end + + path '/api/v1/security_prices' do + get 'List security price history referenced by family investment data' do + tags 'Security Prices' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + parameter name: :page, in: :query, type: :integer, required: false, + description: 'Page number (default: 1)' + parameter name: :per_page, in: :query, type: :integer, required: false, + description: 'Items per page (default: 25, max: 100)' + parameter name: :security_id, in: :query, required: false, + description: 'Filter by security ID', + schema: { type: :string, format: :uuid } + parameter name: :currency, in: :query, required: false, + description: 'Filter by currency code', + schema: { type: :string } + parameter name: :start_date, in: :query, required: false, + description: 'Filter prices from this date', + schema: { type: :string, format: :date } + parameter name: :end_date, in: :query, required: false, + description: 'Filter prices until this date', + schema: { type: :string, format: :date } + parameter name: :provisional, in: :query, required: false, + description: 'Filter by provisional price status. When supplied, must be true or false.', + schema: { type: :boolean } + + response '200', 'security prices listed' do + schema '$ref' => '#/components/schemas/SecurityPriceCollection' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '422', 'invalid filter' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:security_id) { 'not-a-uuid' } + + run_test! + end + end + end + + path '/api/v1/security_prices/{id}' do + parameter name: :id, in: :path, required: true, description: 'Security price ID', + schema: { type: :string, format: :uuid } + + get 'Retrieve a security price referenced by family investment data' do + tags 'Security Prices' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { security_price.id } + + response '200', 'security price retrieved' do + schema '$ref' => '#/components/schemas/SecurityPrice' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '404', 'security price not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index cf86c73e0..cc33e18f2 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -826,6 +826,74 @@ RSpec.configure do |config| pagination: { '$ref' => '#/components/schemas/Pagination' } } }, + Security: { + type: :object, + required: %w[id ticker kind offline created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + ticker: { type: :string }, + name: { type: :string, nullable: true }, + kind: { type: :string, enum: %w[standard cash] }, + country_code: { type: :string, nullable: true }, + exchange_mic: { type: :string, nullable: true }, + exchange_acronym: { type: :string, nullable: true }, + exchange_operating_mic: { type: :string, nullable: true }, + exchange_name: { type: :string, nullable: true }, + offline: { type: :boolean }, + offline_reason: { type: :string, nullable: true }, + website_url: { type: :string, nullable: true }, + logo_url: { type: :string, nullable: true }, + first_provider_price_on: { type: :string, format: :date, nullable: true }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + SecurityCollection: { + type: :object, + required: %w[securities pagination], + properties: { + securities: { + type: :array, + items: { '$ref' => '#/components/schemas/Security' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, + SecurityPrice: { + type: :object, + required: %w[id date price price_amount currency provisional security created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + date: { type: :string, format: :date }, + price: { type: :string, description: 'Formatted security price' }, + price_amount: { type: :string, description: 'Exact decimal security price' }, + currency: { type: :string }, + provisional: { type: :boolean }, + security: { + type: :object, + required: %w[id ticker], + properties: { + id: { type: :string, format: :uuid }, + ticker: { type: :string }, + name: { type: :string, nullable: true }, + exchange_operating_mic: { type: :string, nullable: true } + } + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + SecurityPriceCollection: { + type: :object, + required: %w[security_prices pagination], + properties: { + security_prices: { + type: :array, + items: { '$ref' => '#/components/schemas/SecurityPrice' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, Money: { type: :object, required: %w[amount currency formatted], diff --git a/test/controllers/api/v1/securities_controller_test.rb b/test/controllers/api/v1/securities_controller_test.rb new file mode 100644 index 000000000..04b235b79 --- /dev/null +++ b/test/controllers/api/v1/securities_controller_test.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::SecuritiesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + @user.api_keys.active.destroy_all + + @api_key = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + source: "web", + display_key: "test_read_#{SecureRandom.hex(8)}" + ) + + @account = accounts(:investment) + @holding_security = securities(:aapl) + @holding_ticker = @holding_security.ticker + @trade_ticker = "AAPL#{SecureRandom.hex(4).upcase}" + + @trade_security = Security.create!( + ticker: @trade_ticker, + name: "Apple Inc.", + country_code: "US", + exchange_operating_mic: "XNAS" + ) + @account.entries.create!( + name: "Buy AAPL", + date: Date.parse("2024-01-16"), + amount: 1800, + currency: "USD", + entryable: Trade.new( + security: @trade_security, + qty: 10, + price: 180, + currency: "USD" + ) + ) + + @unreferenced_security = Security.create!(ticker: "MSFT#{SecureRandom.hex(4).upcase}", name: "Microsoft Corp.", country_code: "US") + + other_account = families(:empty).accounts.create!( + name: "Other Investment Account", + accountable: Investment.new, + balance: 1000, + currency: "USD" + ) + @other_security = Security.create!(ticker: "GOOG#{SecureRandom.hex(4).upcase}", name: "Alphabet Inc.", country_code: "US") + other_account.holdings.create!( + security: @other_security, + date: Date.parse("2024-01-15"), + qty: 1, + price: 100, + amount: 100, + currency: "USD" + ) + end + + test "lists securities referenced by accessible family investment data" do + get api_v1_securities_url, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + security_ids = response_data["securities"].map { |security| security["id"] } + + assert_includes security_ids, @holding_security.id + assert_includes security_ids, @trade_security.id + assert_not_includes security_ids, @unreferenced_security.id + assert_not_includes security_ids, @other_security.id + assert response_data.key?("pagination") + end + + test "shows a scoped security" do + get api_v1_security_url(@holding_security), headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + + assert_equal @holding_security.id, response_data["id"] + assert_equal @holding_ticker, response_data["ticker"] + assert_equal @holding_security.exchange_operating_mic, response_data["exchange_operating_mic"] + assert_equal "standard", response_data["kind"] + assert_not response_data.key?("price_provider") + end + + test "returns not found for another family's security" do + get api_v1_security_url(@other_security), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "returns not found for malformed security id" do + get api_v1_security_url("not-a-uuid"), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "filters securities by ticker" do + get api_v1_securities_url, params: { ticker: @trade_ticker.downcase }, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_equal [ @trade_security.id ], response_data["securities"].map { |security| security["id"] } + end + + test "filters securities by exchange operating mic" do + get api_v1_securities_url, params: { exchange_operating_mic: " xnas " }, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_equal [ @holding_security.id, @trade_security.id ], response_data["securities"].map { |security| security["id"] } + end + + test "caps per_page at documented maximum" do + get api_v1_securities_url, params: { per_page: 250 }, headers: api_headers(@api_key) + + assert_response :success + assert_equal 100, JSON.parse(response.body).dig("pagination", "per_page") + end + + test "rejects invalid kind filter" do + get api_v1_securities_url, params: { kind: "unsupported" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "rejects malformed offline filter" do + get api_v1_securities_url, params: { offline: "maybe" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + assert_includes response_data["errors"], "offline must be true or false" + end + + test "rejects blank offline filter" do + get api_v1_securities_url, params: { offline: "" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + assert_includes response_data["errors"], "offline must be true or false" + end + + test "requires authentication" do + get api_v1_securities_url + + assert_response :unauthorized + end + + test "requires read scope" do + api_key_without_read = ApiKey.new( + user: @user, + name: "No Read Key", + scopes: [], + source: "web", + display_key: "no_read_#{SecureRandom.hex(8)}" + ) + api_key_without_read.save!(validate: false) + + get api_v1_securities_url, headers: api_headers(api_key_without_read) + + assert_response :forbidden + ensure + api_key_without_read&.destroy + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.plain_key } + end +end diff --git a/test/controllers/api/v1/security_prices_controller_test.rb b/test/controllers/api/v1/security_prices_controller_test.rb new file mode 100644 index 000000000..156b97d32 --- /dev/null +++ b/test/controllers/api/v1/security_prices_controller_test.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::SecurityPricesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + @user.api_keys.active.destroy_all + + @api_key = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + source: "web", + display_key: "test_read_#{SecureRandom.hex(8)}" + ) + + @account = accounts(:investment) + @security = securities(:aapl) + @ticker = @security.ticker + @security_price = security_prices(:one) + @eur_price = Security::Price.create!( + security: @security, + date: @security_price.date, + price: BigDecimal("250.5000"), + currency: "EUR" + ) + + other_account = families(:empty).accounts.create!( + name: "Other Investment Account", + accountable: Investment.new, + balance: 1000, + currency: "USD" + ) + @other_security = Security.create!(ticker: "GOOG#{SecureRandom.hex(4).upcase}", name: "Alphabet Inc.", country_code: "US") + other_account.holdings.create!( + security: @other_security, + date: Date.parse("2024-01-15"), + qty: 1, + price: 100, + amount: 100, + currency: "USD" + ) + @other_price = Security::Price.create!( + security: @other_security, + date: Date.parse("2024-01-15"), + price: 100, + currency: "USD" + ) + end + + test "lists prices for securities referenced by accessible family investment data" do + get api_v1_security_prices_url, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + price_ids = response_data["security_prices"].map { |price| price["id"] } + + assert_includes price_ids, @security_price.id + assert_not_includes price_ids, @other_price.id + assert response_data.key?("pagination") + end + + test "shows a scoped security price" do + get api_v1_security_price_url(@security_price), headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + + assert_equal @security_price.id, response_data["id"] + assert_equal @security_price.date.iso8601, response_data["date"] + assert_equal "215.0000", response_data["price_amount"] + assert_equal @security.id, response_data.dig("security", "id") + end + + test "returns not found for another family's security price" do + get api_v1_security_price_url(@other_price), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "returns not found for malformed security price id" do + get api_v1_security_price_url("not-a-uuid"), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "filters security prices by security_id" do + get api_v1_security_prices_url, params: { security_id: @security.id }, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_includes response_data["security_prices"].map { |price| price["id"] }, @security_price.id + assert response_data["security_prices"].all? { |price| price.dig("security", "id") == @security.id } + end + + test "filters security prices by date range and provisional status" do + get api_v1_security_prices_url, + params: { start_date: @security_price.date.iso8601, end_date: @security_price.date.iso8601, currency: "USD", provisional: false }, + headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_equal [ @security_price.id ], response_data["security_prices"].map { |price| price["id"] } + end + + test "rejects blank provisional filter" do + get api_v1_security_prices_url, + params: { provisional: "" }, + headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + assert_includes response_data["errors"], "provisional must be true or false" + end + + test "filters security prices by currency" do + get api_v1_security_prices_url, + params: { currency: " usd " }, + headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_includes response_data["security_prices"].map { |price| price["id"] }, @security_price.id + assert_not_includes response_data["security_prices"].map { |price| price["id"] }, @eur_price.id + end + + test "rejects malformed provisional filter" do + get api_v1_security_prices_url, + params: { provisional: "maybe" }, + headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + assert_includes response_data["errors"], "provisional must be true or false" + end + + test "caps per_page at documented maximum" do + get api_v1_security_prices_url, params: { per_page: 250 }, headers: api_headers(@api_key) + + assert_response :success + assert_equal 100, JSON.parse(response.body).dig("pagination", "per_page") + end + + test "rejects malformed security_id filter" do + get api_v1_security_prices_url, params: { security_id: "not-a-uuid" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "rejects invalid date filters" do + get api_v1_security_prices_url, params: { start_date: "01/15/2024" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "requires authentication" do + get api_v1_security_prices_url + + assert_response :unauthorized + end + + test "requires read scope" do + api_key_without_read = ApiKey.new( + user: @user, + name: "No Read Key", + scopes: [], + source: "web", + display_key: "no_read_#{SecureRandom.hex(8)}" + ) + api_key_without_read.save!(validate: false) + + get api_v1_security_prices_url, headers: api_headers(api_key_without_read) + + assert_response :forbidden + ensure + api_key_without_read&.destroy + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.plain_key } + end +end