Add RSwag coverage for /chat and /transactions API endpoints (#210)

* Add RSwag coverage for chat API

* Linter

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

* Add transaction rswag

* FIX linter

---------

Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: sokie <sokysrm@gmail.com>
This commit is contained in:
Juan José Mata
2025-12-17 14:14:17 +01:00
committed by GitHub
parent 5f8a295479
commit 9d54719007
12 changed files with 2274 additions and 203 deletions

2
.rspec Normal file
View File

@@ -0,0 +1,2 @@
--require spec_helper
--pattern spec/requests/api/v1/**/*_spec.rb

View File

@@ -123,4 +123,8 @@ group :test do
gem "webmock"
gem "climate_control"
gem "simplecov", require: false
gem "rspec-rails"
gem "rswag-api"
gem "rswag-specs"
gem "rswag-ui"
end

View File

@@ -177,6 +177,7 @@ GEM
ruby-statistics (>= 4.0.1)
ruby2_keywords
thor (>= 0.19, < 2)
diff-lcs (1.6.2)
docile (1.4.1)
doorkeeper (5.8.2)
railties (>= 5)
@@ -284,6 +285,9 @@ GEM
activesupport (>= 5.0.0)
jmespath (1.6.2)
json (2.12.2)
json-schema (5.2.2)
addressable (~> 2.8)
bigdecimal (~> 3.1)
json-jwt (1.16.7)
activesupport (>= 4.2)
aes_key_wrap
@@ -527,6 +531,34 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.6)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.2)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.6)
rswag-api (2.16.0)
activesupport (>= 5.2, < 8.1)
railties (>= 5.2, < 8.1)
rswag-specs (2.16.0)
activesupport (>= 5.2, < 8.1)
json-schema (>= 2.2, < 6.0)
railties (>= 5.2, < 8.1)
rspec-core (>= 2.14)
rswag-ui (2.16.0)
actionpack (>= 5.2, < 8.1)
railties (>= 5.2, < 8.1)
rubocop (1.76.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -753,6 +785,10 @@ DEPENDENCIES
redis (~> 5.4)
rotp (~> 6.3)
rqrcode (~> 3.0)
rspec-rails
rswag-api
rswag-specs
rswag-ui
rubocop-rails-omakase
ruby-lsp-rails
ruby-openai

View File

@@ -1,228 +1,59 @@
# Chat API Documentation
The Chat API allows external applications to interact with Sure's AI chat functionality.
The Chat API allows external applications to interact with Sure's AI chat functionality. The OpenAPI description is generated directly from executable request specs, ensuring it always reflects the behaviour of the running Rails application.
## Authentication
## Generated OpenAPI specification
All chat endpoints require authentication via OAuth2 or API keys. The chat endpoints also require the user to have AI features enabled (`ai_enabled: true`).
- The source of truth for the documentation lives in [`spec/requests/api/v1/chats_spec.rb`](../../spec/requests/api/v1/chats_spec.rb). These specs authenticate against the Rails stack, exercise every chat endpoint, and capture real response shapes.
- Regenerate the OpenAPI document with:
## Endpoints
```sh
RAILS_ENV=test bundle exec rake rswag:specs:swaggerize
```
### List Chats
```
GET /api/v1/chats
```
The task compiles the request specs and writes the result to [`docs/api/openapi.yaml`](openapi.yaml).
**Required Scope:** `read`
- Run just the documentation specs with:
**Response:**
```json
{
"chats": [
{
"id": "uuid",
"title": "Chat title",
"last_message_at": "2024-01-01T00:00:00Z",
"message_count": 5,
"error": null,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
],
"pagination": {
"page": 1,
"per_page": 20,
"total_count": 50,
"total_pages": 3
}
}
```
```sh
bundle exec rspec spec/requests/api/v1/chats_spec.rb
```
### Get Chat
```
GET /api/v1/chats/:id
```
## Authentication requirements
**Required Scope:** `read`
All chat endpoints require an OAuth2 access token or API key that grants the appropriate scope. The authenticated user must also have AI features enabled (`ai_enabled: true`).
**Response:**
```json
{
"id": "uuid",
"title": "Chat title",
"error": null,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"messages": [
{
"id": "uuid",
"type": "user_message",
"role": "user",
"content": "Hello AI",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
},
{
"id": "uuid",
"type": "assistant_message",
"role": "assistant",
"content": "Hello! How can I help you?",
"model": "gpt-4",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"tool_calls": []
}
],
"pagination": {
"page": 1,
"per_page": 50,
"total_count": 2,
"total_pages": 1
}
}
```
## Available endpoints
### Create Chat
```
POST /api/v1/chats
```
| Endpoint | Scope | Description |
| --- | --- | --- |
| `GET /api/v1/chats` | `read` | List chats for the authenticated user with pagination metadata. |
| `GET /api/v1/chats/{id}` | `read` | Retrieve a chat, including ordered messages and optional pagination. |
| `POST /api/v1/chats` | `write` | Create a chat and optionally seed it with an initial user message. |
| `PATCH /api/v1/chats/{id}` | `write` | Update a chat title. |
| `DELETE /api/v1/chats/{id}` | `write` | Permanently delete a chat. |
| `POST /api/v1/chats/{chat_id}/messages` | `write` | Append a user message to a chat. |
| `POST /api/v1/chats/{chat_id}/messages/retry` | `write` | Retry the last assistant response in a chat. |
**Required Scope:** `write`
Refer to the generated [`openapi.yaml`](openapi.yaml) for request/response schemas, reusable components (pagination, errors, messages, tool calls), and security definitions.
**Request Body:**
```json
{
"title": "Optional chat title",
"message": "Initial message to AI",
"model": "gpt-4" // optional, defaults to gpt-4
}
```
## AI response behaviour
**Response:** Same as Get Chat endpoint
- Chat creation and message submission queue AI processing jobs asynchronously; the API responds immediately with the user message payload.
- Poll `GET /api/v1/chats/{id}` to detect new assistant messages (`type: "assistant_message"`).
- Supported models today: `gpt-4` (default), `gpt-4-turbo`, and `gpt-3.5-turbo`.
- Assistant responses may include structured tool calls (`tool_calls`) that reference financial data fetches and their results.
### Update Chat
```
PATCH /api/v1/chats/:id
```
## Error responses
**Required Scope:** `write`
**Request Body:**
```json
{
"title": "New chat title"
}
```
**Response:** Same as Get Chat endpoint
### Delete Chat
```
DELETE /api/v1/chats/:id
```
**Required Scope:** `write`
**Response:** 204 No Content
### Create Message
```
POST /api/v1/chats/:chat_id/messages
```
**Required Scope:** `write`
**Request Body:**
```json
{
"content": "User message",
"model": "gpt-4" // optional, defaults to gpt-4
}
```
**Response:**
```json
{
"id": "uuid",
"chat_id": "uuid",
"type": "user_message",
"role": "user",
"content": "User message",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"ai_response_status": "pending",
"ai_response_message": "AI response is being generated"
}
```
### Retry Last Message
```
POST /api/v1/chats/:chat_id/messages/retry
```
**Required Scope:** `write`
Retries the last assistant message in the chat.
**Response:**
```json
{
"message": "Retry initiated",
"message_id": "uuid"
}
```
## AI Response Handling
AI responses are processed asynchronously. When you create a message or chat with an initial message, the API returns immediately with the user message. The AI response is generated in the background.
### Checking for AI Responses
Currently, you need to poll the chat endpoint to check for new AI responses. Look for new messages with `type: "assistant_message"`.
### Available AI Models
- `gpt-4` (default)
- `gpt-4-turbo`
- `gpt-3.5-turbo`
### Tool Calls
The AI assistant can make tool calls to access user financial data. These appear in the `tool_calls` array of assistant messages:
```json
{
"tool_calls": [
{
"id": "uuid",
"function_name": "get_accounts",
"function_arguments": {},
"function_result": { ... },
"created_at": "2024-01-01T00:00:00Z"
}
]
}
```
## Error Handling
All endpoints return standard error responses:
Errors conform to the shared `ErrorResponse` schema in the OpenAPI document:
```json
{
"error": "error_code",
"message": "Human readable error message",
"details": ["Additional error details"] // optional
"details": ["Optional array of extra context"]
}
```
Common error codes:
- `unauthorized` - Invalid or missing authentication
- `forbidden` - Insufficient permissions or AI not enabled
- `not_found` - Resource not found
- `unprocessable_entity` - Invalid request data
- `rate_limit_exceeded` - Too many requests
## Rate Limits
Chat API endpoints are subject to the standard API rate limits based on your API key tier.
Common error codes include `unauthorized`, `forbidden`, `feature_disabled`, `not_found`, `unprocessable_entity`, and `rate_limit_exceeded`.

888
docs/api/openapi.yaml Normal file
View File

@@ -0,0 +1,888 @@
---
openapi: 3.0.3
info:
title: Sure API
version: v1
description: OpenAPI documentation generated from executable request specs.
servers:
- url: https://api.sure.app
description: Production
- url: http://localhost:3000
description: Local development
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Pagination:
type: object
required:
- page
- per_page
- total_count
- total_pages
properties:
page:
type: integer
minimum: 1
per_page:
type: integer
minimum: 1
total_count:
type: integer
minimum: 0
total_pages:
type: integer
minimum: 0
ErrorResponse:
type: object
required:
- error
properties:
error:
type: string
message:
type: string
nullable: true
details:
oneOf:
- type: array
items:
type: string
- type: object
nullable: true
ToolCall:
type: object
required:
- id
- function_name
- function_arguments
- created_at
properties:
id:
type: string
format: uuid
function_name:
type: string
function_arguments:
type: object
additionalProperties: true
function_result:
type: object
additionalProperties: true
nullable: true
created_at:
type: string
format: date-time
Message:
type: object
required:
- id
- type
- role
- content
- created_at
- updated_at
properties:
id:
type: string
format: uuid
type:
type: string
enum:
- user_message
- assistant_message
role:
type: string
enum:
- user
- assistant
content:
type: string
model:
type: string
nullable: true
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
tool_calls:
type: array
items:
"$ref": "#/components/schemas/ToolCall"
nullable: true
MessageResponse:
allOf:
- "$ref": "#/components/schemas/Message"
- type: object
required:
- chat_id
properties:
chat_id:
type: string
format: uuid
ai_response_status:
type: string
enum:
- pending
- complete
- failed
nullable: true
ai_response_message:
type: string
nullable: true
ChatResource:
type: object
required:
- id
- title
- created_at
- updated_at
properties:
id:
type: string
format: uuid
title:
type: string
error:
type: string
nullable: true
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
ChatSummary:
allOf:
- "$ref": "#/components/schemas/ChatResource"
- type: object
required:
- message_count
properties:
message_count:
type: integer
minimum: 0
last_message_at:
type: string
format: date-time
nullable: true
ChatDetail:
allOf:
- "$ref": "#/components/schemas/ChatResource"
- type: object
required:
- messages
properties:
messages:
type: array
items:
"$ref": "#/components/schemas/Message"
pagination:
"$ref": "#/components/schemas/Pagination"
nullable: true
ChatCollection:
type: object
required:
- chats
- pagination
properties:
chats:
type: array
items:
"$ref": "#/components/schemas/ChatSummary"
pagination:
"$ref": "#/components/schemas/Pagination"
RetryResponse:
type: object
required:
- message
- message_id
properties:
message:
type: string
message_id:
type: string
format: uuid
Account:
type: object
required:
- id
- name
- account_type
properties:
id:
type: string
format: uuid
name:
type: string
account_type:
type: string
Category:
type: object
required:
- id
- name
- classification
- color
- icon
properties:
id:
type: string
format: uuid
name:
type: string
classification:
type: string
color:
type: string
icon:
type: string
Merchant:
type: object
required:
- id
- name
properties:
id:
type: string
format: uuid
name:
type: string
Tag:
type: object
required:
- id
- name
- color
properties:
id:
type: string
format: uuid
name:
type: string
color:
type: string
Transfer:
type: object
required:
- id
- amount
- currency
properties:
id:
type: string
format: uuid
amount:
type: string
currency:
type: string
other_account:
"$ref": "#/components/schemas/Account"
nullable: true
Transaction:
type: object
required:
- id
- date
- amount
- currency
- name
- classification
- account
- tags
- created_at
- updated_at
properties:
id:
type: string
format: uuid
date:
type: string
format: date
amount:
type: string
currency:
type: string
name:
type: string
notes:
type: string
nullable: true
classification:
type: string
account:
"$ref": "#/components/schemas/Account"
category:
"$ref": "#/components/schemas/Category"
nullable: true
merchant:
"$ref": "#/components/schemas/Merchant"
nullable: true
tags:
type: array
items:
"$ref": "#/components/schemas/Tag"
transfer:
"$ref": "#/components/schemas/Transfer"
nullable: true
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
TransactionCollection:
type: object
required:
- transactions
- pagination
properties:
transactions:
type: array
items:
"$ref": "#/components/schemas/Transaction"
pagination:
"$ref": "#/components/schemas/Pagination"
DeleteResponse:
type: object
required:
- message
properties:
message:
type: string
paths:
"/api/v1/chats":
get:
summary: List chats
tags:
- Chats
security:
- bearerAuth: []
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: Bearer token with read scope
responses:
'200':
description: chats listed
content:
application/json:
schema:
"$ref": "#/components/schemas/ChatCollection"
'403':
description: AI features disabled
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
post:
summary: Create chat
tags:
- Chats
security:
- bearerAuth: []
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: Bearer token with write scope
responses:
'201':
description: chat created
content:
application/json:
schema:
"$ref": "#/components/schemas/ChatDetail"
'422':
description: validation error
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
title:
type: string
example: Monthly budget review
message:
type: string
description: Initial message in the chat
model:
type: string
description: Optional OpenAI model identifier
required:
- title
- message
required: true
"/api/v1/chats/{id}":
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: Bearer token with read scope
- name: id
in: path
required: true
description: Chat ID
schema:
type: string
get:
summary: Retrieve a chat
tags:
- Chats
security:
- bearerAuth: []
responses:
'200':
description: chat retrieved
content:
application/json:
schema:
"$ref": "#/components/schemas/ChatDetail"
'404':
description: chat not found
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
patch:
summary: Update a chat
tags:
- Chats
security:
- bearerAuth: []
parameters: []
responses:
'200':
description: chat updated
content:
application/json:
schema:
"$ref": "#/components/schemas/ChatDetail"
'404':
description: chat not found
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'422':
description: validation error
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
title:
type: string
example: Updated chat title
required: true
delete:
summary: Delete a chat
tags:
- Chats
security:
- bearerAuth: []
responses:
'204':
description: chat deleted
'404':
description: chat not found
"/api/v1/chats/{chat_id}/messages":
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: Bearer token with write scope
- name: chat_id
in: path
required: true
description: Chat ID
schema:
type: string
post:
summary: Create a message
tags:
- Chat Messages
security:
- bearerAuth: []
parameters: []
responses:
'201':
description: message created
content:
application/json:
schema:
"$ref": "#/components/schemas/MessageResponse"
'404':
description: chat not found
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'422':
description: validation error
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
content:
type: string
model:
type: string
required:
- content
required: true
"/api/v1/chats/{chat_id}/messages/retry":
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: Bearer token with write scope
- name: chat_id
in: path
required: true
description: Chat ID
schema:
type: string
post:
summary: Retry the last assistant response
tags:
- Chat Messages
security:
- bearerAuth: []
responses:
'202':
description: retry started
content:
application/json:
schema:
"$ref": "#/components/schemas/RetryResponse"
'404':
description: chat not found
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'422':
description: no assistant message available
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/transactions":
get:
summary: List transactions
tags:
- Transactions
security:
- bearerAuth: []
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: Bearer token with read scope
- 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: account_id
in: query
required: false
description: Filter by account ID
schema:
type: string
- name: category_id
in: query
required: false
description: Filter by category ID
schema:
type: string
- name: merchant_id
in: query
required: false
description: Filter by merchant ID
schema:
type: string
- name: start_date
in: query
format: date
required: false
description: Filter transactions from this date
schema:
type: string
- name: end_date
in: query
format: date
required: false
description: Filter transactions until this date
schema:
type: string
- name: min_amount
in: query
required: false
description: Filter by minimum amount
schema:
type: number
- name: max_amount
in: query
required: false
description: Filter by maximum amount
schema:
type: number
- name: type
in: query
enum:
- income
- expense
required: false
description: "Filter by transaction type:\n * `income` \n * `expense` \n "
schema:
type: string
- name: search
in: query
required: false
description: Search by name, notes, or merchant name
schema:
type: string
responses:
'200':
description: transactions filtered by date range
content:
application/json:
schema:
"$ref": "#/components/schemas/TransactionCollection"
post:
summary: Create transaction
tags:
- Transactions
security:
- bearerAuth: []
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: Bearer token with write scope
responses:
'201':
description: transaction created
content:
application/json:
schema:
"$ref": "#/components/schemas/Transaction"
'422':
description: validation error - missing required fields
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
transaction:
type: object
properties:
account_id:
type: string
format: uuid
description: Account ID (required)
date:
type: string
format: date
description: Transaction date
amount:
type: number
description: Transaction amount
name:
type: string
description: Transaction name/description
notes:
type: string
description: Additional notes
currency:
type: string
description: Currency code (defaults to family currency)
category_id:
type: string
format: uuid
description: Category ID
merchant_id:
type: string
format: uuid
description: Merchant ID
nature:
type: string
enum:
- income
- expense
- inflow
- outflow
description: Transaction nature (determines sign)
tag_ids:
type: array
items:
type: string
format: uuid
description: Array of tag IDs
required:
- account_id
- date
- amount
- name
required:
- transaction
required: true
"/api/v1/transactions/{id}":
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: Bearer token
- name: id
in: path
required: true
description: Transaction ID
schema:
type: string
get:
summary: Retrieve a transaction
tags:
- Transactions
security:
- bearerAuth: []
responses:
'200':
description: transaction retrieved
content:
application/json:
schema:
"$ref": "#/components/schemas/Transaction"
'404':
description: transaction not found
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
patch:
summary: Update a transaction
tags:
- Transactions
security:
- bearerAuth: []
parameters: []
responses:
'200':
description: transaction updated
content:
application/json:
schema:
"$ref": "#/components/schemas/Transaction"
'404':
description: transaction not found
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
transaction:
type: object
properties:
date:
type: string
format: date
amount:
type: number
name:
type: string
notes:
type: string
category_id:
type: string
format: uuid
merchant_id:
type: string
format: uuid
nature:
type: string
enum:
- income
- expense
- inflow
- outflow
tag_ids:
type: array
items:
type: string
format: uuid
required: true
delete:
summary: Delete a transaction
tags:
- Transactions
security:
- bearerAuth: []
responses:
'200':
description: transaction deleted
content:
application/json:
schema:
"$ref": "#/components/schemas/DeleteResponse"
'404':
description: transaction not found
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"

159
docs/api/transactions.md Normal file
View File

@@ -0,0 +1,159 @@
# Transactions API Documentation
The Transactions API allows external applications to manage financial transactions within Sure. The OpenAPI description is generated directly from executable request specs, ensuring it always reflects the behaviour of the running Rails application.
## Generated OpenAPI specification
- The source of truth for the documentation lives in [`spec/requests/api/v1/transactions_spec.rb`](../../spec/requests/api/v1/transactions_spec.rb). These specs authenticate against the Rails stack, exercise every transaction endpoint, and capture real response shapes.
- Regenerate the OpenAPI document with:
```sh
SWAGGER_DRY_RUN=0 bundle exec rspec spec/requests --format Rswag::Specs::SwaggerFormatter
```
The task compiles the request specs and writes the result to [`docs/api/openapi.yaml`](openapi.yaml).
- Run just the documentation specs with:
```sh
bundle exec rspec spec/requests/api/v1/transactions_spec.rb
```
## Authentication requirements
All transaction endpoints require an OAuth2 access token or API key that grants the appropriate scope (`read` or `read_write`).
## Available endpoints
| Endpoint | Scope | Description |
| --- | --- | --- |
| `GET /api/v1/transactions` | `read` | List transactions with filtering and pagination. |
| `GET /api/v1/transactions/{id}` | `read` | Retrieve a single transaction with full details. |
| `POST /api/v1/transactions` | `write` | Create a new transaction. |
| `PATCH /api/v1/transactions/{id}` | `write` | Update an existing transaction. |
| `DELETE /api/v1/transactions/{id}` | `write` | Permanently delete a transaction. |
Refer to the generated [`openapi.yaml`](openapi.yaml) for request/response schemas, reusable components (pagination, errors, accounts, categories, merchants, tags), and security definitions.
## Filtering options
The `GET /api/v1/transactions` endpoint supports the following query parameters for filtering:
| Parameter | Type | Description |
| --- | --- | --- |
| `page` | integer | Page number (default: 1) |
| `per_page` | integer | Items per page (default: 25, max: 100) |
| `account_id` | uuid | Filter by a single account ID |
| `account_ids[]` | uuid[] | Filter by multiple account IDs |
| `category_id` | uuid | Filter by a single category ID |
| `category_ids[]` | uuid[] | Filter by multiple category IDs |
| `merchant_id` | uuid | Filter by a single merchant ID |
| `merchant_ids[]` | uuid[] | Filter by multiple merchant IDs |
| `tag_ids[]` | uuid[] | Filter by tag IDs |
| `start_date` | date | Filter transactions from this date (inclusive) |
| `end_date` | date | Filter transactions until this date (inclusive) |
| `min_amount` | number | Filter by minimum amount |
| `max_amount` | number | Filter by maximum amount |
| `type` | string | Filter by transaction type: `income` or `expense` |
| `search` | string | Search by name, notes, or merchant name |
## Transaction object
A transaction response includes:
```json
{
"id": "uuid",
"date": "2024-01-15",
"amount": "$75.50",
"currency": "USD",
"name": "Grocery shopping",
"notes": "Weekly groceries",
"classification": "expense",
"account": {
"id": "uuid",
"name": "Checking Account",
"account_type": "depository"
},
"category": {
"id": "uuid",
"name": "Groceries",
"classification": "expense",
"color": "#4CAF50",
"icon": "shopping-cart"
},
"merchant": {
"id": "uuid",
"name": "Whole Foods"
},
"tags": [
{
"id": "uuid",
"name": "Essential",
"color": "#2196F3"
}
],
"transfer": null,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
## Creating transactions
When creating a transaction, the `nature` field determines how the amount is stored:
| Nature | Behaviour |
| --- | --- |
| `income` / `inflow` | Amount is stored as negative (credit) |
| `expense` / `outflow` | Amount is stored as positive (debit) |
Example request body:
```json
{
"transaction": {
"account_id": "uuid",
"date": "2024-01-15",
"amount": 75.50,
"name": "Grocery shopping",
"nature": "expense",
"category_id": "uuid",
"merchant_id": "uuid",
"tag_ids": ["uuid", "uuid"]
}
}
```
## Transfer transactions
If a transaction is part of a transfer between accounts, the `transfer` field will be populated with details about the linked transaction:
```json
{
"transfer": {
"id": "uuid",
"amount": "$500.00",
"currency": "USD",
"other_account": {
"id": "uuid",
"name": "Savings Account",
"account_type": "depository"
}
}
}
```
## Error responses
Errors conform to the shared `ErrorResponse` schema in the OpenAPI document:
```json
{
"error": "error_code",
"message": "Human readable error message",
"errors": ["Optional array of validation errors"]
}
```
Common error codes include `unauthorized`, `not_found`, `validation_failed`, and `internal_server_error`.

3
lib/tasks/rswag.rake Normal file
View File

@@ -0,0 +1,3 @@
# frozen_string_literal: true
require "rswag/specs"

78
spec/rails_helper.rb Normal file
View File

@@ -0,0 +1,78 @@
# This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?
# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file
# that will avoid rails generators crashing because migrations haven't been run yet
# return unless Rails.env.test?
require 'rspec/rails'
# Add additional requires below this line. Rails is not loaded until this point!
# Requires supporting ruby files with custom matchers and macros, etc, in
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
# run as spec files by default. This means that files in spec/support that end
# in _spec.rb will both be required and run as specs, causing the specs to be
# run twice. It is recommended that you do not name files matching this glob to
# end with _spec.rb. You can configure this pattern with the --pattern
# option on the command line or in ~/.rspec, .rspec or `.rspec-local`.
#
# The following line is provided for convenience purposes. It has the downside
# of increasing the boot-up time by auto-requiring all files in the support
# directory. Alternatively, in the individual `*_spec.rb` files, manually
# require only the support files necessary.
#
# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f }
# Ensures that the test database schema matches the current schema file.
# If there are pending migrations it will invoke `db:test:prepare` to
# recreate the test database by loading the schema.
# If you are not using ActiveRecord, you can remove these lines.
begin
ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
abort e.to_s.strip
end
RSpec.configure do |config|
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_paths = [
Rails.root.join('spec/fixtures')
]
# If you're not using ActiveRecord, or you'd prefer not to run each of your
# examples within a transaction, remove the following line or assign false
# instead of true.
config.use_transactional_fixtures = true
# You can uncomment this line to turn off ActiveRecord support entirely.
# config.use_active_record = false
# RSpec Rails uses metadata to mix in different behaviours to your tests,
# for example enabling you to call `get` and `post` in request specs. e.g.:
#
# RSpec.describe UsersController, type: :request do
# # ...
# end
#
# The different available types are documented in the features, such as in
# https://rspec.info/features/8-0/rspec-rails
#
# You can also this infer these behaviours automatically by location, e.g.
# /spec/models would pull in the same behaviour as `type: :model` but this
# behaviour is considered legacy and will be removed in a future version.
#
# To enable this behaviour uncomment the line below.
# config.infer_spec_type_from_file_location!
config.include ActiveJob::TestHelper
config.before do
ActiveJob::Base.queue_adapter = :test
end
# Filter lines from Rails gems in backtraces.
config.filter_rails_from_backtrace!
# arbitrary gems may also be filtered via:
# config.filter_gems_from_backtrace("gem name")
end

View File

@@ -0,0 +1,357 @@
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'API V1 Chats', 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',
ai_enabled: true
)
end
let(:oauth_application) do
Doorkeeper::Application.create!(
name: 'API Docs',
redirect_uri: 'https://example.com/callback',
scopes: 'read read_write'
)
end
let(:access_token) do
Doorkeeper::AccessToken.create!(
application: oauth_application,
resource_owner_id: user.id,
scopes: 'read_write',
expires_in: 2.hours,
token: SecureRandom.hex(32)
)
end
let(:Authorization) { "Bearer #{access_token.token}" }
let!(:chat) do
user.chats.create!(title: 'Budget planning').tap do |record|
record.messages.create!(
type: 'UserMessage',
content: 'How should I budget for a vacation?',
ai_model: 'gpt-4'
)
assistant_message = record.messages.create!(
type: 'AssistantMessage',
content: "Let's review your spending patterns first.",
ai_model: 'gpt-4'
)
assistant_message.tool_calls.create!(
provider_id: 'openai',
type: 'ToolCall::Function',
function_name: 'get_accounts',
function_arguments: { 'scope' => 'spending' },
function_result: { 'total_spend' => 1500 }
)
record.messages.create!(
type: 'AssistantMessage',
content: 'Does this align with your savings goals?',
ai_model: 'gpt-4'
)
end
end
let!(:another_chat) do
user.chats.create!(title: 'Retirement planning').tap do |record|
record.messages.create!(
type: 'UserMessage',
content: 'How much should I contribute to my IRA?',
ai_model: 'gpt-4'
)
end
end
path '/api/v1/chats' do
get 'List chats' do
tags 'Chats'
security [ { bearerAuth: [] } ]
produces 'application/json'
parameter name: :Authorization, in: :header, required: true, schema: { type: :string },
description: 'Bearer token with read scope'
response '200', 'chats listed' do
schema '$ref' => '#/components/schemas/ChatCollection'
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('chats')).to be_present
expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages')
end
end
response '403', 'AI features disabled' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:user) do
family.users.create!(
email: 'no-ai@example.com',
password: 'password123',
password_confirmation: 'password123',
ai_enabled: false
)
end
run_test!
end
end
post 'Create chat' do
tags 'Chats'
security [ { bearerAuth: [] } ]
consumes 'application/json'
produces 'application/json'
parameter name: :Authorization, in: :header, required: true, schema: { type: :string },
description: 'Bearer token with write scope'
parameter name: :chat_params, in: :body, required: true, schema: {
type: :object,
properties: {
title: { type: :string, example: 'Monthly budget review' },
message: { type: :string, description: 'Initial message in the chat' },
model: { type: :string, description: 'Optional OpenAI model identifier' }
},
required: %w[title message]
}
let(:chat_params) do
{
title: 'Travel planning',
message: 'Can you help me plan a summer trip?',
model: 'gpt-4-turbo'
}
end
response '201', 'chat created' do
schema '$ref' => '#/components/schemas/ChatDetail'
run_test! do |response|
payload = JSON.parse(response.body)
chat_record = Chat.find(payload.fetch('id'))
expect(chat_record.messages.first.content).to eq('Can you help me plan a summer trip?')
end
end
response '422', 'validation error' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:chat_params) { { title: '' } }
run_test!
end
end
end
path '/api/v1/chats/{id}' do
parameter name: :Authorization, in: :header, required: true, schema: { type: :string },
description: 'Bearer token with read scope'
parameter name: :id, in: :path, type: :string, required: true, description: 'Chat ID'
get 'Retrieve a chat' do
tags 'Chats'
security [ { bearerAuth: [] } ]
produces 'application/json'
let(:id) { chat.id }
response '200', 'chat retrieved' do
schema '$ref' => '#/components/schemas/ChatDetail'
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('messages').size).to be >= 1
end
end
response '404', 'chat not found' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:id) { SecureRandom.uuid }
run_test!
end
end
patch 'Update a chat' do
tags 'Chats'
security [ { bearerAuth: [] } ]
consumes 'application/json'
produces 'application/json'
let(:id) { chat.id }
parameter name: :chat_params, in: :body, required: true, schema: {
type: :object,
properties: {
title: { type: :string, example: 'Updated chat title' }
}
}
let(:chat_params) { { title: 'Updated budget plan' } }
response '200', 'chat updated' do
schema '$ref' => '#/components/schemas/ChatDetail'
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('title')).to eq('Updated budget plan')
end
end
response '404', 'chat not found' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:id) { SecureRandom.uuid }
run_test!
end
response '422', 'validation error' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:chat_params) { { title: '' } }
run_test!
end
end
delete 'Delete a chat' do
tags 'Chats'
security [ { bearerAuth: [] } ]
produces 'application/json'
let(:id) { another_chat.id }
response '204', 'chat deleted' do
run_test!
end
response '404', 'chat not found' do
let(:id) { SecureRandom.uuid }
run_test!
end
end
end
path '/api/v1/chats/{chat_id}/messages' do
parameter name: :Authorization, in: :header, required: true, schema: { type: :string },
description: 'Bearer token with write scope'
parameter name: :chat_id, in: :path, type: :string, required: true, description: 'Chat ID'
post 'Create a message' do
tags 'Chat Messages'
security [ { bearerAuth: [] } ]
consumes 'application/json'
produces 'application/json'
let(:chat_id) { chat.id }
parameter name: :message_params, in: :body, required: true, schema: {
type: :object,
properties: {
content: { type: :string },
model: { type: :string }
},
required: %w[content]
}
let(:message_params) do
{
content: 'Please summarise the last conversation.',
model: 'gpt-4'
}
end
response '201', 'message created' do
schema '$ref' => '#/components/schemas/MessageResponse'
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('ai_response_status')).to eq('pending')
end
end
response '404', 'chat not found' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:chat_id) { SecureRandom.uuid }
run_test!
end
response '422', 'validation error' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:message_params) { { content: '' } }
run_test!
end
end
end
path '/api/v1/chats/{chat_id}/messages/retry' do
parameter name: :Authorization, in: :header, required: true, schema: { type: :string },
description: 'Bearer token with write scope'
parameter name: :chat_id, in: :path, type: :string, required: true, description: 'Chat ID'
post 'Retry the last assistant response' do
tags 'Chat Messages'
security [ { bearerAuth: [] } ]
produces 'application/json'
let(:chat_id) { chat.id }
response '202', 'retry started' do
schema '$ref' => '#/components/schemas/RetryResponse'
before do
allow_any_instance_of(AssistantMessage).to receive(:valid?).and_return(true)
end
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('message')).to eq('Retry initiated')
end
end
response '404', 'chat not found' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:chat_id) { SecureRandom.uuid }
run_test!
end
response '422', 'no assistant message available' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:chat) do
user.chats.create!(title: 'Empty conversation')
end
let(:chat_id) { chat.id }
run_test!
end
end
end
end

View File

@@ -0,0 +1,360 @@
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'API V1 Transactions', 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(:oauth_application) do
Doorkeeper::Application.create!(
name: 'API Docs',
redirect_uri: 'https://example.com/callback',
scopes: 'read read_write'
)
end
let(:access_token) do
Doorkeeper::AccessToken.create!(
application: oauth_application,
resource_owner_id: user.id,
scopes: 'read_write',
expires_in: 2.hours,
token: SecureRandom.hex(32)
)
end
let(:Authorization) { "Bearer #{access_token.token}" }
let(:account) do
Account.create!(
family: family,
name: 'Checking Account',
balance: 1000,
currency: 'USD',
accountable: Depository.create!
)
end
let(:category) do
family.categories.create!(
name: 'Groceries',
classification: 'expense',
color: '#4CAF50',
lucide_icon: 'shopping-cart'
)
end
let(:merchant) do
family.merchants.create!(name: 'Whole Foods')
end
let(:tag) do
family.tags.create!(name: 'Essential', color: '#2196F3')
end
let!(:transaction) do
entry = account.entries.create!(
name: 'Grocery shopping',
date: Date.current,
amount: 75.50,
currency: 'USD',
entryable: Transaction.new(
category: category,
merchant: merchant
)
)
entry.transaction.tags << tag
entry.transaction
end
let!(:another_transaction) do
entry = account.entries.create!(
name: 'Coffee',
date: Date.current - 1.day,
amount: 5.00,
currency: 'USD',
entryable: Transaction.new
)
entry.transaction
end
path '/api/v1/transactions' do
get 'List transactions' do
tags 'Transactions'
security [ { bearerAuth: [] } ]
produces 'application/json'
parameter name: :Authorization, in: :header, required: true, schema: { type: :string },
description: 'Bearer token with read scope'
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: :account_id, in: :query, type: :string, required: false,
description: 'Filter by account ID'
parameter name: :category_id, in: :query, type: :string, required: false,
description: 'Filter by category ID'
parameter name: :merchant_id, in: :query, type: :string, required: false,
description: 'Filter by merchant ID'
parameter name: :start_date, in: :query, type: :string, format: :date, required: false,
description: 'Filter transactions from this date'
parameter name: :end_date, in: :query, type: :string, format: :date, required: false,
description: 'Filter transactions until this date'
parameter name: :min_amount, in: :query, type: :number, required: false,
description: 'Filter by minimum amount'
parameter name: :max_amount, in: :query, type: :number, required: false,
description: 'Filter by maximum amount'
parameter name: :type, in: :query, type: :string, enum: %w[income expense], required: false,
description: 'Filter by transaction type'
parameter name: :search, in: :query, type: :string, required: false,
description: 'Search by name, notes, or merchant name'
response '200', 'transactions listed' do
schema '$ref' => '#/components/schemas/TransactionCollection'
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('transactions')).to be_present
expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages')
end
end
response '200', 'transactions filtered by account' do
schema '$ref' => '#/components/schemas/TransactionCollection'
let(:account_id) { account.id }
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('transactions')).to be_present
end
end
response '200', 'transactions filtered by date range' do
schema '$ref' => '#/components/schemas/TransactionCollection'
let(:start_date) { (Date.current - 7.days).to_s }
let(:end_date) { Date.current.to_s }
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('transactions')).to be_present
end
end
end
post 'Create transaction' do
tags 'Transactions'
security [ { bearerAuth: [] } ]
consumes 'application/json'
produces 'application/json'
parameter name: :Authorization, in: :header, required: true, schema: { type: :string },
description: 'Bearer token with write scope'
parameter name: :body, in: :body, required: true, schema: {
type: :object,
properties: {
transaction: {
type: :object,
properties: {
account_id: { type: :string, format: :uuid, description: 'Account ID (required)' },
date: { type: :string, format: :date, description: 'Transaction date' },
amount: { type: :number, description: 'Transaction amount' },
name: { type: :string, description: 'Transaction name/description' },
notes: { type: :string, description: 'Additional notes' },
currency: { type: :string, description: 'Currency code (defaults to family currency)' },
category_id: { type: :string, format: :uuid, description: 'Category ID' },
merchant_id: { type: :string, format: :uuid, description: 'Merchant ID' },
nature: { type: :string, enum: %w[income expense inflow outflow], description: 'Transaction nature (determines sign)' },
tag_ids: { type: :array, items: { type: :string, format: :uuid }, description: 'Array of tag IDs' }
},
required: %w[account_id date amount name]
}
},
required: %w[transaction]
}
let(:body) do
{
transaction: {
account_id: account.id,
date: Date.current.to_s,
amount: 50.00,
name: 'Test purchase',
nature: 'expense',
category_id: category.id,
merchant_id: merchant.id
}
}
end
response '201', 'transaction created' do
schema '$ref' => '#/components/schemas/Transaction'
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('name')).to eq('Test purchase')
expect(payload.fetch('account').fetch('id')).to eq(account.id)
end
end
response '422', 'validation error - missing account_id' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:body) do
{
transaction: {
date: Date.current.to_s,
amount: 50.00,
name: 'Test purchase'
}
}
end
run_test!
end
response '422', 'validation error - missing required fields' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:body) do
{
transaction: {
account_id: account.id
}
}
end
run_test!
end
end
end
path '/api/v1/transactions/{id}' do
parameter name: :Authorization, in: :header, required: true, schema: { type: :string },
description: 'Bearer token'
parameter name: :id, in: :path, type: :string, required: true, description: 'Transaction ID'
get 'Retrieve a transaction' do
tags 'Transactions'
security [ { bearerAuth: [] } ]
produces 'application/json'
let(:id) { transaction.id }
response '200', 'transaction retrieved' do
schema '$ref' => '#/components/schemas/Transaction'
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('id')).to eq(transaction.id)
expect(payload.fetch('name')).to eq('Grocery shopping')
expect(payload.fetch('category').fetch('name')).to eq('Groceries')
expect(payload.fetch('merchant').fetch('name')).to eq('Whole Foods')
expect(payload.fetch('tags').first.fetch('name')).to eq('Essential')
end
end
response '404', 'transaction not found' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:id) { SecureRandom.uuid }
run_test!
end
end
patch 'Update a transaction' do
tags 'Transactions'
security [ { bearerAuth: [] } ]
consumes 'application/json'
produces 'application/json'
let(:id) { transaction.id }
parameter name: :body, in: :body, required: true, schema: {
type: :object,
properties: {
transaction: {
type: :object,
properties: {
date: { type: :string, format: :date },
amount: { type: :number },
name: { type: :string },
notes: { type: :string },
category_id: { type: :string, format: :uuid },
merchant_id: { type: :string, format: :uuid },
nature: { type: :string, enum: %w[income expense inflow outflow] },
tag_ids: { type: :array, items: { type: :string, format: :uuid } }
}
}
}
}
let(:body) do
{
transaction: {
name: 'Updated grocery shopping',
notes: 'Weekly groceries'
}
}
end
response '200', 'transaction updated' do
schema '$ref' => '#/components/schemas/Transaction'
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('name')).to eq('Updated grocery shopping')
expect(payload.fetch('notes')).to eq('Weekly groceries')
end
end
response '404', 'transaction not found' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:id) { SecureRandom.uuid }
run_test!
end
end
delete 'Delete a transaction' do
tags 'Transactions'
security [ { bearerAuth: [] } ]
produces 'application/json'
let(:id) { another_transaction.id }
response '200', 'transaction deleted' do
schema '$ref' => '#/components/schemas/DeleteResponse'
run_test! do |response|
payload = JSON.parse(response.body)
expect(payload.fetch('message')).to eq('Transaction deleted successfully')
end
end
response '404', 'transaction not found' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:id) { SecureRandom.uuid }
run_test!
end
end
end
end

94
spec/spec_helper.rb Normal file
View File

@@ -0,0 +1,94 @@
# This file was generated by the `rails generate rspec:install` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# The generated `.rspec` file contains `--require spec_helper` which will cause
# this file to always be loaded, without a need to explicitly require it in any
# files.
#
# Given that it is always loaded, you are encouraged to keep this file as
# light-weight as possible. Requiring heavyweight dependencies from this file
# will add to the boot time of your test suite on EVERY test run, even for an
# individual file that may not need all of that loaded. Instead, consider making
# a separate helper file that requires the additional dependencies and performs
# the additional setup, and require it from the spec files that actually need
# it.
#
# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|
# rspec-expectations config goes here. You can use an alternate
# assertion/expectation library such as wrong or the stdlib/minitest
# assertions if you prefer.
config.expect_with :rspec do |expectations|
# This option will default to `true` in RSpec 4. It makes the `description`
# and `failure_message` of custom matchers include text for helper methods
# defined using `chain`, e.g.:
# be_bigger_than(2).and_smaller_than(4).description
# # => "be bigger than 2 and smaller than 4"
# ...rather than:
# # => "be bigger than 2"
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
# rspec-mocks config goes here. You can use an alternate test double
# library (such as bogus or mocha) by changing the `mock_with` option here.
config.mock_with :rspec do |mocks|
# Prevents you from mocking or stubbing a method that does not exist on
# a real object. This is generally recommended, and will default to
# `true` in RSpec 4.
mocks.verify_partial_doubles = true
end
# This option will default to `:apply_to_host_groups` in RSpec 4 (and will
# have no way to turn it off -- the option exists only for backwards
# compatibility in RSpec 3). It causes shared context metadata to be
# inherited by the metadata hash of host groups and examples, rather than
# triggering implicit auto-inclusion in groups with matching metadata.
config.shared_context_metadata_behavior = :apply_to_host_groups
# The settings below are suggested to provide a good initial experience
# with RSpec, but feel free to customize to your heart's content.
=begin
# This allows you to limit a spec run to individual examples or groups
# you care about by tagging them with `:focus` metadata. When nothing
# is tagged with `:focus`, all examples get run. RSpec also provides
# aliases for `it`, `describe`, and `context` that include `:focus`
# metadata: `fit`, `fdescribe` and `fcontext`, respectively.
config.filter_run_when_matching :focus
# Allows RSpec to persist some state between runs in order to support
# the `--only-failures` and `--next-failure` CLI options. We recommend
# you configure your source control system to ignore this file.
config.example_status_persistence_file_path = "spec/examples.txt"
# Limits the available syntax to the non-monkey patched syntax that is
# recommended. For more details, see:
# https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
config.disable_monkey_patching!
# Many RSpec users commonly either run the entire suite or an individual
# file, and it's useful to allow more verbose output when running an
# individual spec file.
if config.files_to_run.one?
# Use the documentation formatter for detailed output,
# unless a formatter has already been configured
# (e.g. via a command-line flag).
config.default_formatter = "doc"
end
# Print the 10 slowest examples and example groups at the
# end of the spec run, to help surface which specs are running
# particularly slow.
config.profile_examples = 10
# Run specs in random order to surface order dependencies. If you find an
# order dependency and want to debug it, you can fix the order by providing
# the seed, which is printed after each run.
# --seed 1234
config.order = :random
# Seed global randomization in this process using the `--seed` CLI option.
# Setting this allows you to use `--seed` to deterministically reproduce
# test failures related to randomization by passing the same `--seed` value
# as the one that triggered the failure.
Kernel.srand config.seed
=end
end

259
spec/swagger_helper.rb Normal file
View File

@@ -0,0 +1,259 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.configure do |config|
config.openapi_root = Rails.root.join('docs', 'api').to_s
config.openapi_specs = {
'openapi.yaml' => {
openapi: '3.0.3',
info: {
title: 'Sure API',
version: 'v1',
description: 'OpenAPI documentation generated from executable request specs.'
},
servers: [
{
url: 'https://api.sure.app',
description: 'Production'
},
{
url: 'http://localhost:3000',
description: 'Local development'
}
],
components: {
securitySchemes: {
bearerAuth: {
type: :http,
scheme: :bearer,
bearerFormat: :JWT
}
},
schemas: {
Pagination: {
type: :object,
required: %w[page per_page total_count total_pages],
properties: {
page: { type: :integer, minimum: 1 },
per_page: { type: :integer, minimum: 1 },
total_count: { type: :integer, minimum: 0 },
total_pages: { type: :integer, minimum: 0 }
}
},
ErrorResponse: {
type: :object,
required: %w[error],
properties: {
error: { type: :string },
message: { type: :string, nullable: true },
details: {
oneOf: [
{ type: :array, items: { type: :string } },
{ type: :object }
],
nullable: true
}
}
},
ToolCall: {
type: :object,
required: %w[id function_name function_arguments created_at],
properties: {
id: { type: :string, format: :uuid },
function_name: { type: :string },
function_arguments: { type: :object, additionalProperties: true },
function_result: { type: :object, additionalProperties: true, nullable: true },
created_at: { type: :string, format: :'date-time' }
}
},
Message: {
type: :object,
required: %w[id type role content created_at updated_at],
properties: {
id: { type: :string, format: :uuid },
type: { type: :string, enum: %w[user_message assistant_message] },
role: { type: :string, enum: %w[user assistant] },
content: { type: :string },
model: { type: :string, nullable: true },
created_at: { type: :string, format: :'date-time' },
updated_at: { type: :string, format: :'date-time' },
tool_calls: {
type: :array,
items: { '$ref' => '#/components/schemas/ToolCall' },
nullable: true
}
}
},
MessageResponse: {
allOf: [
{ '$ref' => '#/components/schemas/Message' },
{
type: :object,
required: %w[chat_id],
properties: {
chat_id: { type: :string, format: :uuid },
ai_response_status: { type: :string, enum: %w[pending complete failed], nullable: true },
ai_response_message: { type: :string, nullable: true }
}
}
]
},
ChatResource: {
type: :object,
required: %w[id title created_at updated_at],
properties: {
id: { type: :string, format: :uuid },
title: { type: :string },
error: { type: :string, nullable: true },
created_at: { type: :string, format: :'date-time' },
updated_at: { type: :string, format: :'date-time' }
}
},
ChatSummary: {
allOf: [
{ '$ref' => '#/components/schemas/ChatResource' },
{
type: :object,
required: %w[message_count],
properties: {
message_count: { type: :integer, minimum: 0 },
last_message_at: { type: :string, format: :'date-time', nullable: true }
}
}
]
},
ChatDetail: {
allOf: [
{ '$ref' => '#/components/schemas/ChatResource' },
{
type: :object,
required: %w[messages],
properties: {
messages: {
type: :array,
items: { '$ref' => '#/components/schemas/Message' }
},
pagination: {
'$ref' => '#/components/schemas/Pagination',
nullable: true
}
}
}
]
},
ChatCollection: {
type: :object,
required: %w[chats pagination],
properties: {
chats: {
type: :array,
items: { '$ref' => '#/components/schemas/ChatSummary' }
},
pagination: { '$ref' => '#/components/schemas/Pagination' }
}
},
RetryResponse: {
type: :object,
required: %w[message message_id],
properties: {
message: { type: :string },
message_id: { type: :string, format: :uuid }
}
},
Account: {
type: :object,
required: %w[id name account_type],
properties: {
id: { type: :string, format: :uuid },
name: { type: :string },
account_type: { type: :string }
}
},
Category: {
type: :object,
required: %w[id name classification color icon],
properties: {
id: { type: :string, format: :uuid },
name: { type: :string },
classification: { type: :string },
color: { type: :string },
icon: { type: :string }
}
},
Merchant: {
type: :object,
required: %w[id name],
properties: {
id: { type: :string, format: :uuid },
name: { type: :string }
}
},
Tag: {
type: :object,
required: %w[id name color],
properties: {
id: { type: :string, format: :uuid },
name: { type: :string },
color: { type: :string }
}
},
Transfer: {
type: :object,
required: %w[id amount currency],
properties: {
id: { type: :string, format: :uuid },
amount: { type: :string },
currency: { type: :string },
other_account: { '$ref' => '#/components/schemas/Account', nullable: true }
}
},
Transaction: {
type: :object,
required: %w[id date amount currency name classification account tags created_at updated_at],
properties: {
id: { type: :string, format: :uuid },
date: { type: :string, format: :date },
amount: { type: :string },
currency: { type: :string },
name: { type: :string },
notes: { type: :string, nullable: true },
classification: { type: :string },
account: { '$ref' => '#/components/schemas/Account' },
category: { '$ref' => '#/components/schemas/Category', nullable: true },
merchant: { '$ref' => '#/components/schemas/Merchant', nullable: true },
tags: {
type: :array,
items: { '$ref' => '#/components/schemas/Tag' }
},
transfer: { '$ref' => '#/components/schemas/Transfer', nullable: true },
created_at: { type: :string, format: :'date-time' },
updated_at: { type: :string, format: :'date-time' }
}
},
TransactionCollection: {
type: :object,
required: %w[transactions pagination],
properties: {
transactions: {
type: :array,
items: { '$ref' => '#/components/schemas/Transaction' }
},
pagination: { '$ref' => '#/components/schemas/Pagination' }
}
},
DeleteResponse: {
type: :object,
required: %w[message],
properties: {
message: { type: :string }
}
}
}
}
}
}
config.openapi_format = :yaml
end