feat(api): expose rule export endpoints (#1602)

* feat(api): expose rule export endpoints

* fix(api): tighten rule export contracts

* fix(api): document balance sheet auth errors

* test(api): align rule API key fixtures

* Update docs/api/openapi.yaml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

* Quick win

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
ghost
2026-05-01 11:47:06 -06:00
committed by GitHub
parent 352c301e4b
commit 783309188f
11 changed files with 795 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
# frozen_string_literal: true
class Api::V1::RulesController < Api::V1::BaseController
include Pagy::Backend
BOOLEAN_FILTERS = {
"true" => true,
"1" => true,
"false" => false,
"0" => false
}.freeze
RESOURCE_TYPES = %w[transaction].freeze
before_action :ensure_read_scope
before_action :set_rule, only: :show
def index
return render_invalid_resource_type_filter if invalid_resource_type_filter?
@per_page = safe_per_page_param
rules_query = current_resource_owner.family.rules
.includes(:actions, conditions: :sub_conditions)
.order(:created_at, :id)
rules_query = rules_query.where(resource_type: params[:resource_type]) if params[:resource_type].present?
if params[:active].present?
active = parse_boolean_filter(params[:active])
return if performed?
rules_query = rules_query.where(active: active)
end
@pagy, @rules = pagy(
rules_query,
page: safe_page_param,
limit: @per_page
)
render :index
end
def show
render :show
end
private
def set_rule
@rule = current_resource_owner.family.rules
.includes(:actions, conditions: :sub_conditions)
.find(params[:id])
end
def ensure_read_scope
authorize_scope!(:read)
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1
end
def safe_per_page_param
per_page = params[:per_page].to_i
case per_page
when 1..100
per_page
else
25
end
end
def parse_boolean_filter(value)
normalized = value.to_s.downcase
return BOOLEAN_FILTERS[normalized] if BOOLEAN_FILTERS.key?(normalized)
render json: {
error: "validation_failed",
message: "active must be one of: true, false, 1, 0"
}, status: :unprocessable_entity
nil
end
def invalid_resource_type_filter?
params[:resource_type].present? && !params[:resource_type].in?(RESOURCE_TYPES)
end
def render_invalid_resource_type_filter
render json: {
error: "validation_failed",
message: "resource_type must be one of: #{RESOURCE_TYPES.join(", ")}"
}, status: :unprocessable_entity
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
json.id action.id
json.action_type action.action_type
json.value action.value
json.created_at action.created_at.iso8601
json.updated_at action.updated_at.iso8601

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
json.id condition.id
json.condition_type condition.condition_type
json.operator condition.operator
json.value condition.value
if condition.compound?
json.sub_conditions condition.sub_conditions do |sub_condition|
json.partial! "api/v1/rules/condition", condition: sub_condition
end
else
json.sub_conditions []
end
json.created_at condition.created_at.iso8601
json.updated_at condition.updated_at.iso8601

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
json.id rule.id
json.name rule.name
json.resource_type rule.resource_type
json.active rule.active
json.effective_date rule.effective_date&.iso8601
json.conditions rule.conditions.select { |condition| condition.parent_id.nil? } do |condition|
json.partial! "api/v1/rules/condition", condition: condition
end
json.actions rule.actions do |action|
json.partial! "api/v1/rules/action", action: action
end
json.created_at rule.created_at.iso8601
json.updated_at rule.updated_at.iso8601

View File

@@ -0,0 +1,14 @@
# frozen_string_literal: true
json.data @rules do |rule|
json.partial! "api/v1/rules/rule", rule: rule
end
json.meta do
json.current_page @pagy.page
json.next_page @pagy.next
json.prev_page @pagy.prev
json.total_pages @pagy.pages
json.total_count @pagy.count
json.per_page @per_page
end

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
json.data do
json.partial! "api/v1/rules/rule", rule: @rule
end

View File

@@ -422,6 +422,7 @@ Rails.application.routes.draw do
resources :accounts, only: [ :index, :show ]
resources :categories, only: [ :index, :show ]
resources :merchants, only: %i[index show]
resources :rules, only: [ :index, :show ]
resources :tags, only: %i[index show create update destroy]
resources :transactions, only: [ :index, :show, :create, :update, :destroy ]

View File

@@ -463,6 +463,138 @@ components:
type: array
items:
"$ref": "#/components/schemas/TagDetail"
RuleAction:
type: object
required:
- id
- action_type
- created_at
- updated_at
properties:
id:
type: string
format: uuid
action_type:
type: string
value:
type: string
nullable: true
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
RuleCondition:
type: object
required:
- id
- condition_type
- operator
- sub_conditions
- created_at
- updated_at
properties:
id:
type: string
format: uuid
condition_type:
type: string
operator:
type: string
value:
type: string
nullable: true
sub_conditions:
type: array
items:
"$ref": "#/components/schemas/RuleCondition"
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
Rule:
type: object
required:
- id
- resource_type
- active
- conditions
- actions
- created_at
- updated_at
properties:
id:
type: string
format: uuid
name:
type: string
nullable: true
resource_type:
type: string
enum:
- transaction
active:
type: boolean
effective_date:
type: string
format: date
nullable: true
conditions:
type: array
items:
"$ref": "#/components/schemas/RuleCondition"
actions:
type: array
items:
"$ref": "#/components/schemas/RuleAction"
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
RuleResponse:
type: object
required:
- data
properties:
data:
"$ref": "#/components/schemas/Rule"
RuleCollection:
type: object
required:
- data
- meta
properties:
data:
type: array
items:
"$ref": "#/components/schemas/Rule"
meta:
type: object
required:
- current_page
- total_pages
- total_count
- per_page
properties:
current_page:
type: integer
next_page:
type: integer
nullable: true
prev_page:
type: integer
nullable: true
total_pages:
type: integer
total_count:
type: integer
per_page:
type: integer
Transfer:
type: object
required:
@@ -2216,6 +2348,105 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/rules":
get:
summary: List rules
tags:
- Rules
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: resource_type
in: query
required: false
description: Filter by rule resource type
schema:
type: string
enum:
- transaction
- name: active
in: query
required: false
description: Filter by active status
schema:
type: boolean
responses:
'200':
description: rules listed
content:
application/json:
schema:
"$ref": "#/components/schemas/RuleCollection"
'401':
description: unauthorized
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'403':
description: forbidden - requires read scope
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'422':
description: unsupported resource type
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/rules/{id}":
parameters:
- name: id
in: path
required: true
description: Rule ID
schema:
type: string
format: uuid
get:
summary: Retrieve a rule
tags:
- Rules
security:
- apiKeyAuth: []
responses:
'200':
description: rule retrieved
content:
application/json:
schema:
"$ref": "#/components/schemas/RuleResponse"
'401':
description: unauthorized
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'403':
description: forbidden - requires read scope
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'404':
description: rule not found
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/tags":
get:
summary: List tags

View File

@@ -0,0 +1,160 @@
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'API V1 Rules', 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,
scopes: %w[read],
source: 'web'
)
end
let(:api_key_without_read_scope) do
key = ApiKey.generate_secure_key
# Valid persisted API keys can only be read/read_write; this intentionally
# bypasses validations to document the runtime insufficient-scope response.
ApiKey.new(
user: user,
name: 'No Read Docs Key',
key: key,
scopes: [],
display_key: key,
source: 'mobile'
).tap { |api_key| api_key.save!(validate: false) }
end
let(:'X-Api-Key') { api_key.plain_key }
let!(:rule) do
family.rules.build(
name: 'Coffee cleanup',
resource_type: 'transaction',
active: true,
effective_date: Date.new(2024, 1, 1)
).tap do |rule|
rule.conditions.build(condition_type: 'transaction_name', operator: 'like', value: 'coffee')
rule.actions.build(action_type: 'set_transaction_name', value: 'Coffee')
rule.save!
end
end
path '/api/v1/rules' do
get 'List rules' do
tags 'Rules'
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: :resource_type, in: :query, required: false,
description: 'Filter by rule resource type',
schema: { type: :string, enum: %w[transaction] }
parameter name: :active, in: :query, required: false,
description: 'Filter by active status',
schema: { type: :boolean }
response '200', 'rules listed' do
schema '$ref' => '#/components/schemas/RuleCollection'
run_test!
end
response '401', 'unauthorized' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:'X-Api-Key') { nil }
run_test!
end
response '403', 'forbidden - requires read scope' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
run_test!
end
response '422', 'invalid active filter' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:active) { 'not_boolean' }
run_test!
end
response '422', 'unsupported resource type' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:resource_type) { 'account' }
run_test!
end
end
end
path '/api/v1/rules/{id}' do
parameter name: :id, in: :path, type: :string, required: true, description: 'Rule ID'
get 'Retrieve a rule' do
tags 'Rules'
security [ { apiKeyAuth: [] } ]
produces 'application/json'
let(:id) { rule.id }
response '200', 'rule retrieved' do
schema '$ref' => '#/components/schemas/RuleResponse'
run_test!
end
response '401', 'unauthorized' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:'X-Api-Key') { nil }
run_test!
end
response '403', 'forbidden - requires read scope' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
run_test!
end
response '404', 'rule not found' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:id) { SecureRandom.uuid }
run_test!
end
end
end
end

View File

@@ -305,6 +305,83 @@ RSpec.configure do |config|
type: :array,
items: { '$ref' => '#/components/schemas/TagDetail' }
},
RuleAction: {
type: :object,
required: %w[id action_type created_at updated_at],
properties: {
id: { type: :string, format: :uuid },
action_type: { type: :string },
value: { type: :string, nullable: true },
created_at: { type: :string, format: :'date-time' },
updated_at: { type: :string, format: :'date-time' }
}
},
RuleCondition: {
type: :object,
required: %w[id condition_type operator sub_conditions created_at updated_at],
properties: {
id: { type: :string, format: :uuid },
condition_type: { type: :string },
operator: { type: :string },
value: { type: :string, nullable: true },
sub_conditions: {
type: :array,
items: { '$ref' => '#/components/schemas/RuleCondition' }
},
created_at: { type: :string, format: :'date-time' },
updated_at: { type: :string, format: :'date-time' }
}
},
Rule: {
type: :object,
required: %w[id resource_type active conditions actions created_at updated_at],
properties: {
id: { type: :string, format: :uuid },
name: { type: :string, nullable: true },
resource_type: { type: :string, enum: %w[transaction] },
active: { type: :boolean },
effective_date: { type: :string, format: :date, nullable: true },
conditions: {
type: :array,
items: { '$ref' => '#/components/schemas/RuleCondition' }
},
actions: {
type: :array,
items: { '$ref' => '#/components/schemas/RuleAction' }
},
created_at: { type: :string, format: :'date-time' },
updated_at: { type: :string, format: :'date-time' }
}
},
RuleResponse: {
type: :object,
required: %w[data],
properties: {
data: { '$ref' => '#/components/schemas/Rule' }
}
},
RuleCollection: {
type: :object,
required: %w[data meta],
properties: {
data: {
type: :array,
items: { '$ref' => '#/components/schemas/Rule' }
},
meta: {
type: :object,
required: %w[current_page total_pages total_count per_page],
properties: {
current_page: { type: :integer },
next_page: { type: :integer, nullable: true },
prev_page: { type: :integer, nullable: true },
total_pages: { type: :integer },
total_count: { type: :integer },
per_page: { type: :integer }
}
}
}
},
Transfer: {
type: :object,
required: %w[id amount currency],

View File

@@ -0,0 +1,174 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::RulesControllerTest < 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)}"
)
Redis.new.del("api_rate_limit:#{@api_key.id}")
@rule = @family.rules.build(
name: "Coffee cleanup",
resource_type: "transaction",
active: true,
effective_date: Date.new(2024, 1, 1)
)
@rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "coffee")
@rule.actions.build(action_type: "set_transaction_name", value: "Coffee")
@rule.save!
end
test "should list rules" do
get api_v1_rules_url, headers: api_headers(@api_key)
assert_response :success
json_response = JSON.parse(response.body)
assert json_response["data"].any? { |rule| rule["id"] == @rule.id }
assert_equal @family.rules.count, json_response["meta"]["total_count"]
end
test "should not list another family's rules" do
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_rule = other_family.rules.build(name: "Other", resource_type: "transaction", active: true)
other_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "other")
other_rule.actions.build(action_type: "set_transaction_name", value: "Other")
other_rule.save!
get api_v1_rules_url, headers: api_headers(@api_key)
assert_response :success
rule_ids = JSON.parse(response.body)["data"].map { |rule| rule["id"] }
assert_includes rule_ids, @rule.id
assert_not_includes rule_ids, other_rule.id
end
test "should require authentication when listing rules" do
get api_v1_rules_url
assert_response :unauthorized
end
test "should require read scope when listing rules" do
api_key_without_read = api_key_without_read_scope
get api_v1_rules_url, headers: api_headers(api_key_without_read)
assert_response :forbidden
json_response = JSON.parse(response.body)
assert_equal "insufficient_scope", json_response["error"]
ensure
api_key_without_read&.destroy
end
test "should filter rules by active status" do
inactive_rule = @family.rules.build(name: "Inactive", resource_type: "transaction", active: false)
inactive_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "ignore")
inactive_rule.actions.build(action_type: "set_transaction_name", value: "Ignore")
inactive_rule.save!
get api_v1_rules_url, params: { active: true }, headers: api_headers(@api_key)
assert_response :success
json_response = JSON.parse(response.body)
rule_ids = json_response["data"].map { |rule| rule["id"] }
assert_includes rule_ids, @rule.id
assert_not_includes rule_ids, inactive_rule.id
end
test "should reject invalid active filter" do
get api_v1_rules_url, params: { active: "not_boolean" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "validation_failed", json_response["error"]
end
test "should reject unsupported resource type filter" do
get api_v1_rules_url, params: { resource_type: "account" }, headers: api_headers(@api_key)
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "validation_failed", json_response["error"]
end
test "should show rule with conditions and actions" do
get api_v1_rule_url(@rule), headers: api_headers(@api_key)
assert_response :success
rule = JSON.parse(response.body)["data"]
assert_equal @rule.id, rule["id"]
assert_equal "Coffee cleanup", rule["name"]
assert_equal "transaction", rule["resource_type"]
assert_equal true, rule["active"]
assert_equal "2024-01-01", rule["effective_date"]
assert_equal 1, rule["conditions"].length
assert_equal "transaction_name", rule["conditions"].first["condition_type"]
assert_equal "like", rule["conditions"].first["operator"]
assert_equal "coffee", rule["conditions"].first["value"]
assert_equal 1, rule["actions"].length
assert_equal "set_transaction_name", rule["actions"].first["action_type"]
assert_equal "Coffee", rule["actions"].first["value"]
end
test "should require authentication when showing a rule" do
get api_v1_rule_url(@rule)
assert_response :unauthorized
end
test "should require read scope when showing a rule" do
api_key_without_read = api_key_without_read_scope
get api_v1_rule_url(@rule), headers: api_headers(api_key_without_read)
assert_response :forbidden
json_response = JSON.parse(response.body)
assert_equal "insufficient_scope", json_response["error"]
ensure
api_key_without_read&.destroy
end
test "should not show another family's rule" do
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_rule = other_family.rules.build(name: "Other", resource_type: "transaction", active: true)
other_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "other")
other_rule.actions.build(action_type: "set_transaction_name", value: "Other")
other_rule.save!
get api_v1_rule_url(other_rule), headers: api_headers(@api_key)
assert_response :not_found
json_response = JSON.parse(response.body)
assert_equal "record_not_found", json_response["error"]
end
private
def api_key_without_read_scope
# Valid persisted API keys can only be read/read_write; this intentionally
# bypasses validations to exercise the runtime insufficient-scope guard.
ApiKey.new(
user: @user,
name: "No Read Key",
scopes: [],
display_key: "test_no_read_#{SecureRandom.hex(8)}",
source: "mobile"
).tap { |api_key| api_key.save!(validate: false) }
end
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end