Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 87 additions & 12 deletions lib/common/README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,94 @@
## Common::Exceptions
## Common

Common::Exceptions is a library intended to help make exception classes serializable.
This library provides shared utilities, base classes, and infrastructure used across vets-api. The components here are designed to be composable — most can be used independently or together.

It is particularly suited for helping to render JSONAPI style errors. It is divided into
two types, `internal` exceptions that are raised within the various models and controllers
of your Rails application and `external` exceptions that are raised by backend services.
## Components

The external exceptions will need to be setup properly using custom middlewares.
### Common::Exceptions

## Purpose
A library of serializable exception classes designed to render JSONAPI-compliant error responses. Exceptions are divided into internal exceptions (raised within models and controllers) and external exceptions (raised by backend service middleware). Messages and HTTP status codes are configured via i18n locales.

To be able to jump out of the call stack and render an error response as part of an
orchestration layer for various backend service integrations.
See [exceptions/README.md](exceptions/README.md) for the full class hierarchy and usage.

## Usage
### Common::Client

Each exception class has a unique way of being invoked. To customize the messages for these
error classes an i18n locales file is available.
A library for building backend service integrations. Provides a base client class, configuration conventions, Faraday middleware for key casing and error handling, and optional Redis-backed token session management.

See [client/README.md](client/README.md) for setup and usage.

### Common::Models

Base classes and mixins for non-ActiveRecord models: attribute declaration via Virtus, filterable and sortable collections with optional Redis caching, a Redis-backed persistence class, and an immutable value object base via Dry::Struct.

See [models/README.md](models/README.md) for the full class reference.

### Common::FileHelpers

Utility methods for working with temporary files, particularly in the context of file uploads and ClamAV virus scanning.

```ruby
path = Common::FileHelpers.random_file_path('.pdf')
path = Common::FileHelpers.generate_random_file(file_bytes, '.pdf')
path = Common::FileHelpers.generate_clamav_temp_file(file_bytes, file_name)
Common::FileHelpers.delete_file_if_exists(path)
```

`generate_clamav_temp_file` writes to `clamav_tmp/` specifically, which is the directory ClamAV has permission to scan.

### Common::HashHelpers

Utilities for deep transformations on nested hashes, arrays, and `ActionController::Parameters`.

```ruby
Common::HashHelpers.deep_transform_parameters!(params) { |k| k.underscore }
Common::HashHelpers.deep_remove_blanks(hash) # removes blank values (but not false)
Common::HashHelpers.deep_compact(hash) # removes nil values
Common::HashHelpers.deep_to_h(open_struct) # recursively converts OpenStructs to hashes
```

### Common::PdfHelpers

Utilities for working with PDF files. Currently provides PDF unlocking/decryption via HexaPDF. Raises `Common::Exceptions::UnprocessableEntity` on invalid password or corrupt PDF, with the error detail drawn from i18n.

```ruby
Common::PdfHelpers.unlock_pdf(input_file, password, output_file)
```

### Common::S3Helpers

Handles S3 file uploads, using `Aws::S3::TransferManager` when available and falling back to a basic upload otherwise.

```ruby
Common::S3Helpers.upload_file(
s3_resource: s3,
bucket: 'my-bucket',
key: 'path/to/file.pdf',
file_path: '/tmp/local_file.pdf',
content_type: 'application/pdf',
server_side_encryption: 'AES256'
)
```

Pass `return_object: true` to get the `Aws::S3::Object` back instead of `true`.

### Common::VirusScan

ClamAV-based virus scanning for uploaded files. Scans files from `clamav_tmp/` directly, or copies files from other locations when the `clamav_scan_file_from_other_location` Flipper flag is enabled. Emits structured audit log entries for every scan attempt, including file metadata, scan result, duration, and upload context.

```ruby
is_safe = Common::VirusScan.scan(file_path, upload_context: 'form_526')
```

Returns `true` only when the file is confirmed clean. Returns `false` when the file is not confirmed clean, including when it is infected and when scanning is skipped or blocked (for example, if the file is outside `clamav_tmp/` and `clamav_scan_file_from_other_location` is disabled).

Mock mode can be enabled via `Settings.clamav.mock`, but because Settings/Parameter Store values may arrive as strings or integers, callers should cast it to a real boolean before use (for example, `ActiveModel::Type::Boolean.new.cast(Settings.clamav.mock)`).

### Common::ConvertToPdf

Converts image files to PDF using MiniMagick. Always writes the uploaded content to a ClamAV temp file first. For files that are already PDFs, returns the path to that temp-file copy (the caller is responsible for cleaning it up). For image files, converts to PDF and returns the output path (the intermediate temp file is cleaned up automatically). Raises `IOError` for unsupported file types.

Comment thread
AdamKing0126 marked this conversation as resolved.
```ruby
output_path = Common::ConvertToPdf.new(uploaded_file).run
```

The output file is written to a random temp path. The caller is responsible for cleaning it up after use.
82 changes: 82 additions & 0 deletions lib/common/exceptions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
## Common::Exceptions

This directory contains the full exception hierarchy used across vets-api. All exceptions are designed to be serializable as JSONAPI-compliant error responses.

## Purpose

To provide a consistent, structured set of exception classes that can be raised anywhere in the application and reliably rendered as well-formed error responses. Each exception maps to an i18n key in `config/locales/exceptions.en.yml`, which controls the title, detail, HTTP status code, and whether the error gets reported to the error tracking backend.

## Class Hierarchy

### Base Classes

**`BaseError`** — the root class all exceptions inherit from. Defines the interface (`errors`, `status_code`, `message`) and i18n lookup conventions. Subclasses must implement `errors`. Also exposes `log_to_sentry?` (legacy naming — controls whether the exception is reported to the error tracking backend).

**`SerializableError`** — a plain Ruby object (not an exception itself) that wraps error attributes into a JSONAPI-compatible structure. Fields include `title`, `detail`, `code`, `status`, `source`, `links`, and `meta`. Used internally by exception classes when building their `errors` array.

**`BackendServiceException`** — raised by middleware when a backend service returns an error response. Accepts an i18n key (e.g. `RX139`, `EVSS144`) to look up the appropriate response shape. Falls back to `VA900` for unmapped errors. Stores the original status and body from the upstream response for debugging.

### 4xx Client Errors

| Class | HTTP Status | Description |
|-------|-------------|-------------|
| `AmbiguousRequest` | 400 | Request cannot be disambiguated |
Comment thread
AdamKing0126 marked this conversation as resolved.
| `BadRequest` | 400 | Generic bad request |
| `FilterNotAllowed` | 400 | A filter parameter is not permitted for this resource |
| `InvalidField` | 400 | A field name in the request is not recognized |
| `InvalidFieldValue` | 400 | A field value in the request is invalid |
| `InvalidFiltersSyntax` | 400 | Filter parameter syntax is malformed |
| `InvalidPaginationParams` | 400 | Pagination parameters are out of range or invalid |
| `InvalidResource` | 400 | The requested resource type is not valid |
| `InvalidSortCriteria` | 400 | Sort parameter refers to a non-sortable field |
| `MaxArraySizeExceeded` | 400 | An array parameter exceeds the allowed size |
| `NoQueryParamsAllowed` | 400 | Query parameters were provided but are not allowed |
| `ParameterMissing` | 400 | A required parameter is absent |
| `ParametersMissing` | 400 | Multiple required parameters are absent |
| `ValidationErrors` | 422 | Model validation failed; wraps ActiveModel errors |
| `ValidationErrorsBadRequest` | 400 | Validation failure rendered as 400 instead of 422 |
| `DetailedSchemaErrors` | 422 | Schema validation failure with field-level detail |
| `SchemaValidationErrors` | 422 | JSON schema validation failure |
| `UnprocessableEntity` | 422 | Generic unprocessable entity |
| `UpstreamUnprocessableEntity` | 422 | Upstream service returned 422 |
| `Unauthorized` | 401 | Authentication required |
| `TokenValidationError` | 401 | Token is invalid or expired |
| `MessageAuthenticityError` | 403 | CSRF or message authenticity check failed |
| `Forbidden` | 403 | Authenticated but not authorized |
| `UnexpectedForbidden` | 403 | Forbidden response was not expected in this context |
| `RecordNotFound` | 404 | A specific record could not be found |
| `ResourceNotFound` | 404 | A resource type or endpoint could not be found |
| `RoutingError` | 404 | No route matched the request |
| `PayloadTooLarge` | 413 | Request body exceeds the allowed size |
| `TooManyRequests` | 429 | Rate limit exceeded |
| `FailedDependency` | 424 | A required dependency failed |

### 5xx Server and Service Errors

| Class | HTTP Status | Description |
|-------|-------------|-------------|
| `InternalServerError` | 500 | Generic server error |
| `NotImplemented` | 501 | Endpoint or feature is not yet implemented |
| `BadGateway` | 502 | Upstream service returned an unexpected response |
| `ServiceUnavailable` | 503 | Upstream service is not available |
| `ServiceOutage` | 503 | A known service outage is in effect |
| `GatewayTimeout` | 504 | Upstream service did not respond in time |
| `Timeout` | 500 | Generic timeout |
| `ClientDisconnected` | 499 | Client disconnected before the response was sent |
| `ExternalServerInternalServerError` | 500 | An external service returned a 500 |
Comment thread
AdamKing0126 marked this conversation as resolved.
| `OpenIdServiceError` | `%{status}` | OpenID Connect service error with caller-supplied HTTP status |
| `ServiceError` | 500 | Generic service-level error from a backend integration |
| `UpstreamPartialFailure` | 502 | Upstream returned partial data; used to signal degraded responses |
| `PrescriptionRefillResponseMismatch` | 502 | Prescription service response did not match expected shape |

## Usage

Raise exceptions directly, or via middleware. Each class has a unique initializer — check the individual file for accepted arguments.

```ruby
raise Common::Exceptions::RecordNotFound, @prescription.id
raise Common::Exceptions::UnprocessableEntity.new(detail: 'Password is incorrect', source: 'MyService')
raise Common::Exceptions::BackendServiceException.new('RX139', { detail: message }, status, body)
```

To customize the message, detail, or error reporting behavior for any exception, add or update its key in `config/locales/exceptions.en.yml`.
80 changes: 80 additions & 0 deletions lib/common/models/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
## Common model classes

This directory provides base classes and mixins under the `Common` namespace for building serializable, sortable, filterable, and Redis-backed data models across vets-api.

## Purpose

To establish consistent conventions for non-ActiveRecord models: how they declare attributes, how collections of them are paginated and filtered, and how short-lived objects are stored and retrieved from Redis.

## Classes

### `Common::Base`

The foundation for non-ActiveRecord model objects. Includes `ActiveModel::Serialization`, `Virtus.model`, and `Comparable`. Subclasses declare typed attributes via Virtus and can mark them as `sortable` or `filterable` for use with `Common::Collection`.

```ruby
class Prescription < Common::Base
attribute :prescription_id, Integer, sortable: { order: 'ASC', default: true }
attribute :status, String, filterable: %w[eq not_eq]
attribute :dispensed_date, Common::UTCTime, sortable: { order: 'DESC' }
end
```

Tracks attribute changes (`changed?`, `changed`, `changes`) relative to the values at initialization.

### `Common::Collection`

A wrapper around an array of model objects that adds filtering, sorting, and pagination. Accepts a `cache_key` to transparently back the collection with Redis (1-hour TTL by default).

```ruby
collection = Common::Collection.fetch(Prescription, cache_key: cache_key, ttl: 3600) do
{ data: prescriptions, metadata: {}, errors: {} }
end

collection.find_by(status: { eq: 'active' })
.sort('-dispensed_date')
.paginate(page: 1, per_page: 10)
```

Filtering operators: `eq`, `not_eq`, `lteq`, `gteq`, `match`. Only attributes declared as `filterable` on the model class can be used. Sort fields must be declared as `sortable`.

### `Common::RedisStore`

An ActiveModel-compatible base class for objects that are persisted to Redis rather than a relational database. Subclasses configure their namespace, TTL, and key attribute using class-level declarations.

```ruby
class MySession < Common::RedisStore
redis_store 'my_session'
redis_ttl 3600
redis_key :token

attribute :token, String
attribute :user_id, Integer
end

session = MySession.find(token)
session = MySession.find_or_build(token)
session.save
session.destroy
```

Requires `redis_store` and `redis_key` to be set. Objects are serialized to JSON via Oj. Invalid objects are automatically removed from Redis on retrieval.

### `Common::Resource`

A thin subclass of `Dry::Struct` for immutable, typed value objects. Prefer this over `Common::Base` when the object should be strictly typed and immutable by design.

```ruby
class AddressInfo < Common::Resource
attribute :street, Types::Strict::String
attribute :city, Types::Strict::String
end
```

## Supporting Classes

**`Common::CacheAside`** — a concern for `Common::RedisStore` subclasses that implements the cache-aside pattern. Wraps a block, checks the cache first, and stores the result if the response reports itself as cacheable (`response.cache?`).

**`Common::UTCTime`** — a Virtus attribute type that coerces all time values to UTC. Use it in `Common::Base` subclasses to ensure consistent time handling.

**`Common::Ascending` / `Common::Descending`** — internal wrappers used by `Common::Collection#sort` to support multi-field sorting with mixed directions.
Loading