diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a73cb..990bdd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ All notable changes to ActionFigure will be documented in this file. - New built-in error statuses: **`Gone`** (410), **`Locked`** (423), and **`UnavailableForLegalReasons`** (451). - `ActionFigure.status_code_for(status_symbol)` — resolves a Rack status symbol to its numeric code (accepts both `:unprocessable_content` and `:unprocessable_entity` on every supported Rack version). - `register_error` validates its status symbol against Rack's status table and rejects helper names reserved by the formatter contract, so typos fail at boot instead of on the first rendered error. +- `:rfc_9457` formatter implementing RFC 9457 Problem Details for HTTP APIs. + Errors render as `application/problem+json` with `type`, `title`, `status`, + `detail`, `instance`, and extension members. Success responses use the same + vocabulary (`type`, `title`, named resource key). Defaults derive from class + names and status symbols; all members accept override kwargs. +- Generated error helpers now accept `errors: nil` (previously required) and + forward `**extras` to `error_response`. Existing formatters are unaffected + when called without extras. ### Changed diff --git a/README.md b/README.md index 828ce02..5da6ffd 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,8 @@ Every action class has three responsibilities: | Feature | Description | |---------|-------------| | [Validation](docs/validation.md) | Two-layer validation powered by dry-validation: structural schemas with type coercion, plus validation rules. Includes cross-parameter helpers like `exclusive_rule`, `any_rule`, `one_rule`, and `all_rule`. | -| [Response Formatters](docs/response-formatters.md) | Four built-in formats: Default, JSend, JSON:API, and Wrapped. Each provides response helpers (`Ok`, `Created`, `NotFound`, etc.) that return render-ready hashes. | +| [Response Formatters](docs/response-formatters.md) | Five built-in formats: Default, JSend, JSON:API, Wrapped, and RFC 9457. Each provides response helpers (`Ok`, `Created`, `NotFound`, etc.) that return render-ready hashes. | +| [Problem Details](docs/problem-details.md) | RFC 9457 formatter (`:rfc_9457`) that renders errors as `application/problem+json` problem documents with `type`, `title`, `status`, `detail`, and `instance` members. Success responses mirror the same vocabulary. | | [Status Codes](docs/status-codes.md) | Which 4xx codes are domain concerns (handled by action classes) vs perimeter concerns (handled by middleware, router, or infrastructure). | | [Custom Formatters](docs/custom-formatters.md) | Define your own response envelope by implementing the formatter interface. Registration validates your module at load time. | | [Actions](docs/actions.md) | Automatic entry point discovery, context injection via keyword arguments, per-class API versioning, and `entry_point` for disambiguation. | diff --git a/docs/custom-formatters.md b/docs/custom-formatters.md index ea1afb0..7d70eab 100644 --- a/docs/custom-formatters.md +++ b/docs/custom-formatters.md @@ -103,6 +103,8 @@ ActionFigure::Formatter::REQUIRED_METHODS **Migration note (pre-0.7 formatters):** If you have a formatter written against the pre-0.7 eight-method contract that does not define `error_response`, registration will now **fail** — `REQUIRED_METHODS` requires `error_response`. Add an `error_response(errors:, status:)` method to your formatter before upgrading to 0.7. +**`error_response` contract update (0.7+):** Generated error helpers now call `error_response` with `errors: nil` (previously `errors:` was required and always provided). If your formatter declares `error_response(errors:, status:)` strictly, it will continue to work for calls that pass `errors:`. However, if your formatter wants to support pass-through kwargs such as `detail:`, `instance:`, or custom extension members (as the `:rfc_9457` formatter does), declare the signature as `error_response(errors: nil, status:, **extras)` and forward `**extras` in your implementation. A strict formatter that declares only `(errors:, status:)` will raise `ArgumentError` when a caller passes extra kwargs — this is documented-correct behavior, not a bug. Only add `**extras` if your formatter intends to handle or forward those members. + If any required method is missing, registration raises an `ArgumentError` that lists exactly which methods are absent: ```ruby diff --git a/docs/problem-details.md b/docs/problem-details.md new file mode 100644 index 0000000..2ac2d84 --- /dev/null +++ b/docs/problem-details.md @@ -0,0 +1,113 @@ +# Problem Details (RFC 9457) + +## Overview + +The `:rfc_9457` formatter renders errors as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents with `Content-Type: application/problem+json`. Success responses mirror the same `type`/`title` vocabulary, though RFC 9457 itself only specifies error documents. + +```ruby +class Projects::FindAction + include ActionFigure[:rfc_9457] + + def show(params:) + project = Project.find_by(id: params[:id]) + return NotFound( + errors: { id: ["not found"] }, + type: "https://api.example.com/problems/project-not-found", + title: "Project not found", + detail: "No project with id #{params[:id]} exists.", + instance: "/projects/#{params[:id]}" + ) unless project + + Ok(resource: project, type: "project-found", title: "Project found") + end +end +``` + +## Error Responses + +Error helpers (`NotFound`, `Conflict`, etc.) produce a problem document: + +| Member | Default | Override kwarg | +|------------|------------------------------------------------------------|----------------| +| `type` | `"--error"`; `UnprocessableContent` uses `"unprocessable-content-error"` (see Defaults below) | `type:` | +| `title` | HTTP status phrase, e.g. `"Not Found"` | `title:` | +| `status` | Numeric HTTP code, e.g. `404` | — | +| `detail` | Omitted | `detail:` | +| `instance` | Omitted | `instance:` | +| `errors` | Extension member; omitted when nil | `errors:` | + +Any additional kwargs become extension members. + +The render hash includes `content_type: "application/problem+json"`. Rails honors this directly; a plain Rack app can read the key. + +### Defaults + +`UnprocessableContent` has a fixed default type of `"unprocessable-content-error"`. The framework calls this helper automatically on schema validation failure, so a stable type is provided without configuration: + +```ruby +# Schema failure — type is already "unprocessable-content-error": +UnprocessableContent(errors: result.errors.to_h) + +# Override when you want a domain-specific URI: +UnprocessableContent( + errors: user.errors.messages, + type: "https://api.example.com/problems/validation-error", + title: "Validation failed" +) +``` + +All other error helpers derive `type` mechanically from the action class and HTTP status — `Projects::FindAction` + `NotFound` → `"projects-find-not-found-error"`. This is intentionally awkward. The RFC recommends a stable URI, and your API clients deserve one: + +```ruby +# Awkward default — a signal to replace it: +# type: "projects-find-not-found-error" + +# What you should write instead: +NotFound( + type: "https://api.example.com/problems/project-not-found", + title: "Project not found", + errors: { id: ["not found"] } +) +``` + +## Success Responses + +Success helpers (`Ok`, `Created`, `Accepted`) build a mirrored vocabulary: + +| Member | Default | Override kwarg | +|-------------------|----------------------------------------------------|----------------| +| `type` | `"-"` (see below) | `type:` | +| `title` | `" "` (see below) | `title:` | +| `` | Resource under its class-derived name or `data` | `as:` | +| `meta` | Omitted when nil | `meta:` | + +The resource key and name derive from the resource's class: + +```ruby +Created(resource: user) # User instance → key :user +# type: "user-created", title: "User created", user: { ... } + +Created(resource: some_hash) # Hash → key :data +# type: "resource-created", title: "Resource created", data: { ... } + +Created(resource: h, as: :project) # explicit override +# type: "project-created", title: "Project created", project: { ... } +``` + +`as:`, `type:`, and `title:` can be used independently — `as:` drives the resource key and the derived defaults; `type:` and `title:` override only their own member. + +Trailing `Action` is stripped from class names: `Projects::CreateAction` → `"projects-create"`. + +`Ok(resource: user)` defaults to `type: "user-ok"` / `title: "User ok"` — deliberately awkward for the same reason as error defaults. Pass `type:` and `title:` to give your clients something meaningful. + +Success responses use plain `application/json` (no `content_type:` key). + +`NoContent` returns `{ status: :no_content }` with no body — inherited from the base formatter. + +## Registering Additional Error Statuses + +`ActionFigure.register_error` works the same way as with any other formatter. Registered error helpers automatically use `error_response`, so they produce problem documents in `:rfc_9457` action classes without any extra configuration. + +## Custom `error_response` Contract + +If you write a custom formatter that you want to compose with RFC 9457 style, note that `error_response` now receives `errors: nil, status:, **extras`. Accept `**extras` to let `detail:`, `instance:`, and extension members flow through. diff --git a/docs/response-formatters.md b/docs/response-formatters.md index dcf6589..af741f4 100644 --- a/docs/response-formatters.md +++ b/docs/response-formatters.md @@ -12,7 +12,7 @@ class UsersController < ApplicationController end ``` -The **formatter** determines the shape of the JSON envelope wrapping your data. ActionFigure ships with four built-in formatters: Default, JSend, JSON:API, and Wrapped. +The **formatter** determines the shape of the JSON envelope wrapping your data. ActionFigure ships with five built-in formatters: Default, JSend, JSON:API, Wrapped, and RFC 9457. ## Choosing a Format @@ -39,6 +39,11 @@ class Users::CreateAction include ActionFigure[:wrapped] end +# Explicit RFC 9457 +class Users::CreateAction + include ActionFigure[:rfc_9457] +end + # Uses the configured default (Default unless changed) class Users::CreateAction include ActionFigure @@ -988,9 +993,159 @@ end } ``` +## RFC 9457 Format + +The RFC 9457 formatter renders errors as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents (`Content-Type: application/problem+json`). Success responses use the same `type`/`title` vocabulary, though RFC 9457 itself only covers errors. + +### Success Responses + +Success responses place the resource under a key derived from its class name and carry `type` and `title` members. + +**`Created` -- returning a model resource:** + +```ruby +def call(params:) + user = User.create(params) + return UnprocessableContent(errors: user.errors.messages) if user.errors.any? + + Created( + resource: user, + type: "https://api.example.com/success/user-created", + title: "User created" + ) +end +``` + +```json +{ + "type": "https://api.example.com/success/user-created", + "title": "User created", + "user": { "id": 42, "name": "Jane Doe", "email": "jane@example.com" } +} +``` + +Without explicit `type:` and `title:`, the formatter derives them from the resource class and status — `User` + `Created` → `type: "user-created"`, `title: "User created"`. For `Ok`, `User` + `Ok` → `type: "user-ok"`, `title: "User ok"` — deliberately awkward. Pass explicit values your clients can rely on. + +Hash and primitive resources fall back to the key `data` and the name `resource`: + +```ruby +Created(resource: { order_id: 7, status: "queued" }) +# -> type: "resource-created", title: "Resource created", data: { ... } +``` + +Override the derived name with `as:`: + +```ruby +Created(resource: some_hash, as: :order) +# -> type: "order-created", title: "Order created", order: { ... } +``` + +**`Accepted` -- with no resource:** + +```ruby +def call(params:) + OrderFulfillmentJob.perform_later(params[:order_id]) + Accepted() +end +``` + +```json +{ + "type": "resource-accepted", + "title": "Resource accepted" +} +``` + +### Failure Responses + +Failure responses produce RFC 9457 problem documents. All members except `type`, `title`, and `status` are optional. + +**`UnprocessableContent` -- validation errors:** + +`UnprocessableContent` defaults to `type: "unprocessable-content-error"`, so schema validation failures work without any configuration: + +```ruby +def call(params:) + user = User.new(params) + return UnprocessableContent(errors: user.errors.messages) unless user.save + resource = UserBlueprint.render_as_hash(user) + Created(resource:, type: "https://api.example.com/success/user-created", title: "User created") +end +``` + +```json +{ + "type": "unprocessable-content-error", + "title": "Unprocessable Content", + "status": 422, + "errors": { + "email": ["has already been taken"], + "name": ["can't be blank"] + } +} +``` + +Override `type:` and `title:` when you want a domain-specific URI: + +```ruby +UnprocessableContent( + errors: user.errors.messages, + type: "https://api.example.com/problems/validation-error", + title: "Validation failed" +) +``` + +**`NotFound` -- with `detail` and `instance`:** + +```ruby +def call(params:) + user = User.find_by(id: params[:id]) + return NotFound( + type: "https://api.example.com/problems/user-not-found", + title: "User not found", + detail: "No user with id #{params[:id]} exists.", + instance: "/users/#{params[:id]}" + ) unless user + Ok(resource: user, type: "https://api.example.com/success/user-ok", title: "User found") +end +``` + +```json +{ + "type": "https://api.example.com/problems/user-not-found", + "title": "User not found", + "status": 404, + "detail": "No user with id 99 exists.", + "instance": "/users/99" +} +``` + +Extra kwargs become extension members in the problem document: + +```ruby +PaymentRequired( + type: "https://api.example.com/problems/quota-exceeded", + title: "Quota exceeded", + balance: 0, + limit: 100 +) +``` + +```json +{ + "type": "https://api.example.com/problems/quota-exceeded", + "title": "Quota exceeded", + "status": 402, + "balance": 0, + "limit": 100 +} +``` + +`UnprocessableContent` defaults to `type: "unprocessable-content-error"`. All other error helpers derive `type` from the action class and status — `Projects::FindAction` + `NotFound` → `"projects-find-not-found-error"`. Provide a stable URI; the default is intentionally unattractive. + ## The `meta:` Keyword -The `meta:` keyword argument is available on `Ok`, `Created`, and `Accepted`. It accepts any hash, which is included as a top-level `"meta"` key in all four formatters. When `meta:` is `nil` (the default), the key is omitted entirely from the response. In the default formatter, providing `meta:` wraps the response in `{ "data": resource, "meta": meta }` — without `meta:`, the resource is the entire body. +The `meta:` keyword argument is available on `Ok`, `Created`, and `Accepted`. It accepts any hash, which is included as a top-level `"meta"` key in all five formatters. When `meta:` is `nil` (the default), the key is omitted entirely from the response. In the default formatter, providing `meta:` wraps the response in `{ "data": resource, "meta": meta }` — without `meta:`, the resource is the entire body. Common uses for `meta:`: diff --git a/lib/action_figure.rb b/lib/action_figure.rb index 564e4f7..0af1c59 100644 --- a/lib/action_figure.rb +++ b/lib/action_figure.rb @@ -10,6 +10,7 @@ require_relative "action_figure/formatters/json_api" require_relative "action_figure/formatters/default" require_relative "action_figure/formatters/wrapped" +require_relative "action_figure/formatters/rfc_9457" # ActionFigure provides explicit, purpose-driven operation classes for Rails controller actions. module ActionFigure @@ -35,6 +36,7 @@ class InitializationNotSupportedError < StandardError; end register_formatter(jsonapi: Formatters::JsonApi) register_formatter(default: Formatters::Default) register_formatter(wrapped: Formatters::Wrapped) + register_formatter(rfc_9457: Formatters::Rfc9457) # rubocop:disable Naming/VariableNumber def self.[](format = configuration.format) format_modules.compute_if_absent(format) { build_format_module(format, fetch(format)) } diff --git a/lib/action_figure/error_registry.rb b/lib/action_figure/error_registry.rb index 39b9837..1cc3582 100644 --- a/lib/action_figure/error_registry.rb +++ b/lib/action_figure/error_registry.rb @@ -32,8 +32,8 @@ module ErrorRegistry module Helpers; end def self.define_helper(name, status_symbol) - Helpers.define_method(name) do |errors:| - error_response(errors: errors, status: status_symbol) + Helpers.define_method(name) do |errors: nil, **extras| + error_response(errors: errors, status: status_symbol, **extras) end end diff --git a/lib/action_figure/formatters/rfc_9457.rb b/lib/action_figure/formatters/rfc_9457.rb new file mode 100644 index 0000000..0f8f9e7 --- /dev/null +++ b/lib/action_figure/formatters/rfc_9457.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "rack/utils" + +module ActionFigure + module Formatters + # Implements RFC 9457 (Problem Details for HTTP APIs) response helpers. + # Errors render as application/problem+json documents. Success responses + # mirror the same type/title vocabulary. + # + # Defaults for type and title are derived mechanically from class names and + # status symbols — deliberately unattractive. Pass type: and title: kwargs + # to provide stable, documented values your clients can rely on. + module Rfc9457 + include ActionFigure::Formatter + + def Ok(resource:, meta: nil, as: nil, type: nil, title: nil) + build_success(resource: resource, meta: meta, as: as, + type: type, title: title, status: :ok) + end + + def Created(resource:, meta: nil, as: nil, type: nil, title: nil) + build_success(resource: resource, meta: meta, as: as, + type: type, title: title, status: :created) + end + + def Accepted(resource: nil, meta: nil, as: nil, type: nil, title: nil) + build_success(resource: resource, meta: meta, as: as, + type: type, title: title, status: :accepted) + end + + def UnprocessableContent(errors: nil, type: "unprocessable-content-error", **extras) + error_response(status: :unprocessable_content, errors: errors, type: type, **extras) + end + + # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize + def error_response(status:, errors: nil, type: nil, title: nil, detail: nil, instance: nil, **extras) + name = action_name_for_error + word = status_word(status) + body = {} + body[:type] = type || derive_type(name, "#{word}-error") + body[:title] = title || rack_status_title(status) + body[:status] = ActionFigure.status_code_for(status) + body[:detail] = detail if detail + body[:instance] = instance if instance + body[:errors] = errors unless errors.nil? + extras.each { |k, v| body[k] = v } + { json: body, status: status, content_type: "application/problem+json" } + end + # rubocop:enable Metrics/ParameterLists, Metrics/AbcSize + + PRIMITIVE_CLASSES = [Hash, Array, String, Numeric, Integer, Float, Symbol, + TrueClass, FalseClass, NilClass].freeze + + private + + # rubocop:disable Metrics/ParameterLists + def build_success(resource:, meta:, as:, type:, title:, status:) + name, key = resource_info(resource, as) + word = status_word(status) + body = {} + body[:type] = type || derive_type(name, word) + body[:title] = title || derive_title(name, word) + body[key] = resource unless resource.nil? && status == :accepted + body[:meta] = meta if meta + { json: body, status: status } + end + # rubocop:enable Metrics/ParameterLists + + # Returns [name, key] where name drives type/title derivation and key + # is the JSON member. Primitives and anonymous classes use name "resource" + # and key :data; model-like objects use their dasherized class name for both. + def resource_info(resource, as_kwarg) + if as_kwarg + name = dasherize_class_name(as_kwarg.to_s) + return [name, as_kwarg.to_sym] + end + + klass = resource.class + return ["resource", :data] if primitive_class?(klass) || klass.name.nil? + + name = dasherize_class_name(klass.name) + [name, name.gsub("-", "_").to_sym] + end + + # Returns the action class's dasherized name (Action suffix stripped) + # for use as the error type prefix. + def action_name_for_error + klass_name = self.class.name + return "action" if klass_name.nil? + + dasherize_class_name(klass_name) + end + + # Converts a Ruby class name string to a dasherized, Action-stripped slug. + # Examples: + # "User" -> "user" + # "Admin::UserProfile" -> "admin-user-profile" + # "Projects::CreateAction" -> "projects-create" + def dasherize_class_name(name) + name + .sub(/Action\z/, "") # strip trailing "Action" + .gsub("::", "-") # namespace separator -> dash + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2') # e.g. XMLParser -> XML-Parser + .gsub(/([a-z\d])([A-Z])/, '\1-\2') # camelCase -> camel-Case + .downcase + .squeeze("-") # collapse any double-dashes from empty namespace parts + .sub(/\A-/, "") # remove leading dash if Action was the only segment + .sub(/-\z/, "") # remove trailing dash + end + + # Status word derived from the Rack status symbol (underscores -> dashes). + # Examples: :ok -> "ok", :not_found -> "not-found", :created -> "created" + def status_word(status_symbol) + status_symbol.to_s.tr("_", "-") + end + + def derive_type(name, word) + "#{name}-#{word}" + end + + # Builds a human title from a dasherized name and a status word. + # Examples: ("user", "created") -> "User created" + # ("admin-user-profile", "created") -> "Admin user profile created" + def derive_title(name, word) + phrase = "#{name.gsub("-", " ")} #{word.tr("-", " ")}" + phrase[0].upcase + phrase[1..] + end + + def rack_status_title(status_symbol) + code = ActionFigure.status_code_for(status_symbol) + Rack::Utils::HTTP_STATUS_CODES[code] || status_symbol.to_s.tr("_", " ").capitalize + end + + def primitive_class?(klass) + PRIMITIVE_CLASSES.any? { |p| klass <= p } + end + end + end +end diff --git a/test/action_figure/error_generation_test.rb b/test/action_figure/error_generation_test.rb index aac17eb..987174e 100644 --- a/test/action_figure/error_generation_test.rb +++ b/test/action_figure/error_generation_test.rb @@ -55,6 +55,57 @@ def NotFound(errors:) = { json: { custom: errors }, status: :not_found } assert_equal({ custom: { base: ["x"] } }, result[:json], "generation must not overwrite a hand-defined NotFound") end + + def test_generated_helper_errors_kwarg_is_optional + consumer = build_consumer(:default) + # Should not raise — errors: is now optional + result = consumer.NotFound() + assert_equal :not_found, result[:status] + end + + def test_generated_helper_forwards_extra_kwargs_to_error_response + # Build a formatter whose error_response accepts and echoes **extras + custom = Module.new do + include ActionFigure::Formatter + + def Ok(resource:, _meta: nil) = { json: { data: resource }, status: :ok } + + def Created(resource:, _meta: nil) = { json: { data: resource }, status: :created } + + def Accepted(resource: nil, _meta: nil) = { json: { data: resource }, status: :accepted } + + def error_response(errors:, status:, **extras) + { json: { errors: errors, extras: extras }, status: status } + end + end + ActionFigure.register_formatter(extras_echo: custom) + consumer = Class.new { include ActionFigure[:extras_echo] }.new + result = consumer.NotFound(errors: { base: ["x"] }, detail: "oops", instance: "/foo") + assert_equal "oops", result[:json][:extras][:detail] + assert_equal "/foo", result[:json][:extras][:instance] + end + + def test_generated_helper_extras_raise_on_strict_formatter + consumer = build_consumer(:jsend) # error_response(errors:, status:) — no **extras + assert_raises(ArgumentError) do + consumer.NotFound(errors: { base: ["x"] }, detail: "extra kwarg not accepted") + end + end + + def test_named_error_helpers_are_generated_for_rfc_9457_format + consumer = build_consumer(:rfc_9457) + assert consumer.respond_to?(:NotFound), "rfc_9457 should generate NotFound" + assert consumer.respond_to?(:Conflict), "rfc_9457 should generate Conflict" + end + + def test_rfc_9457_error_helper_returns_problem_document + consumer = build_consumer(:rfc_9457) + result = consumer.NotFound(errors: { id: ["missing"] }) + assert_equal :not_found, result[:status] + assert_equal "application/problem+json", result[:content_type] + assert_equal 404, result[:json][:status] + assert_equal "Not Found", result[:json][:title] + end end class RegisterErrorRoundTripTest < Minitest::Test diff --git a/test/action_figure/formatters/rfc_9457_test.rb b/test/action_figure/formatters/rfc_9457_test.rb new file mode 100644 index 0000000..40c25e8 --- /dev/null +++ b/test/action_figure/formatters/rfc_9457_test.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require "test_helper" +require "action_figure/formatters/rfc_9457" + +class Rfc9457FormatterTest < Minitest::Test + def formatter + Object.new.extend(ActionFigure::Formatters::Rfc9457) + end + + # --- Ok --- + + def test_ok_returns_200 + result = formatter.Ok(resource: { id: 1 }) + assert_equal :ok, result[:status] + end + + def test_ok_derives_type_and_title_from_hash_resource + result = formatter.Ok(resource: { id: 1 }) + assert_equal "resource-ok", result[:json][:type] + assert_equal "Resource ok", result[:json][:title] + end + + def test_ok_places_hash_resource_under_data_key + result = formatter.Ok(resource: { id: 1 }) + assert_equal({ id: 1 }, result[:json][:data]) + end + + def test_ok_derives_type_and_key_from_model_class + user = User.new(name: "Tad") + result = formatter.Ok(resource: user) + assert_equal "user-ok", result[:json][:type] + assert_equal "User ok", result[:json][:title] + assert_equal user, result[:json][:user] + refute result[:json].key?(:data) + end + + def test_ok_as_kwarg_overrides_derived_name + result = formatter.Ok(resource: { id: 1 }, as: :project) + assert_equal "project-ok", result[:json][:type] + assert_equal "Project ok", result[:json][:title] + assert_equal({ id: 1 }, result[:json][:project]) + refute result[:json].key?(:data) + end + + def test_ok_type_kwarg_overrides_derived_type + result = formatter.Ok(resource: { id: 1 }, type: "my-type") + assert_equal "my-type", result[:json][:type] + end + + def test_ok_title_kwarg_overrides_derived_title + result = formatter.Ok(resource: { id: 1 }, title: "My title") + assert_equal "My title", result[:json][:title] + end + + def test_ok_without_meta_omits_meta_key + result = formatter.Ok(resource: { id: 1 }) + refute result[:json].key?(:meta) + end + + def test_ok_with_meta_includes_meta_key + result = formatter.Ok(resource: { id: 1 }, meta: { page: 2 }) + assert_equal({ page: 2 }, result[:json][:meta]) + end + + def test_ok_has_no_content_type_key + result = formatter.Ok(resource: { id: 1 }) + refute result.key?(:content_type) + end + + # --- Created --- + + def test_created_returns_201 + result = formatter.Created(resource: { id: 1 }) + assert_equal :created, result[:status] + end + + def test_created_derives_type_and_title_from_hash_resource + result = formatter.Created(resource: { id: 1 }) + assert_equal "resource-created", result[:json][:type] + assert_equal "Resource created", result[:json][:title] + end + + def test_created_derives_type_and_key_from_model_class + user = User.new(name: "Tad") + result = formatter.Created(resource: user) + assert_equal "user-created", result[:json][:type] + assert_equal "User created", result[:json][:title] + assert_equal user, result[:json][:user] + refute result[:json].key?(:data) + end + + def test_created_with_namespaced_class + # Admin::UserProfile -> key :admin_user_profile, type "admin-user-profile-created" + klass = Class.new do + def self.name = "Admin::UserProfile" + end + obj = klass.new + result = formatter.Created(resource: obj) + assert_equal "admin-user-profile-created", result[:json][:type] + assert_equal "Admin user profile created", result[:json][:title] + assert result[:json].key?(:admin_user_profile) + end + + def test_created_strips_action_suffix_from_resource_class + klass = Class.new do + def self.name = "Projects::CreateAction" + end + result = formatter.Created(resource: klass.new) + assert_equal "projects-create-created", result[:json][:type] + assert result[:json].key?(:projects_create) + end + + def test_created_anonymous_class_falls_back_to_resource + klass = Class.new # name is nil + result = formatter.Created(resource: klass.new) + assert_equal "resource-created", result[:json][:type] + assert_equal "Resource created", result[:json][:title] + assert result[:json].key?(:data) + end + + # --- Accepted --- + + def test_accepted_returns_202 + result = formatter.Accepted() + assert_equal :accepted, result[:status] + end + + def test_accepted_with_no_resource_uses_generic_defaults + result = formatter.Accepted() + assert_equal "resource-accepted", result[:json][:type] + assert_equal "Resource accepted", result[:json][:title] + refute result[:json].key?(:data) + end + + def test_accepted_with_resource_places_it_under_derived_key + user = User.new(name: "Tad") + result = formatter.Accepted(resource: user) + assert_equal "user-accepted", result[:json][:type] + assert result[:json].key?(:user) + end + + # --- NoContent --- + + def test_no_content_returns_204 + result = formatter.NoContent() + assert_equal :no_content, result[:status] + end + + def test_no_content_has_no_json_body + result = formatter.NoContent() + refute result.key?(:json) + end + + # --- error_response --- + + def test_error_response_includes_type_title_status_members + f = Object.new.tap do |o| + o.extend(ActionFigure::Formatters::Rfc9457) + # Stub self.class.name so we can test derivation + end + result = f.error_response(errors: { id: ["x"] }, status: :not_found) + assert_equal "Not Found", result[:json][:title] + assert_equal 404, result[:json][:status] + assert result[:json][:type].end_with?("-not-found-error") + assert_equal "application/problem+json", result[:content_type] + end + + def test_error_response_omits_errors_key_when_nil + f = Object.new.extend(ActionFigure::Formatters::Rfc9457) + result = f.error_response(errors: nil, status: :not_found) + refute result[:json].key?(:errors) + end + + def test_error_response_includes_errors_when_given + f = Object.new.extend(ActionFigure::Formatters::Rfc9457) + result = f.error_response(errors: { id: ["not found"] }, status: :not_found) + assert_equal({ id: ["not found"] }, result[:json][:errors]) + end + + def test_error_response_includes_detail_when_given + f = Object.new.extend(ActionFigure::Formatters::Rfc9457) + result = f.error_response(errors: nil, status: :not_found, detail: "Project 42 not found") + assert_equal "Project 42 not found", result[:json][:detail] + end + + def test_error_response_includes_instance_when_given + f = Object.new.extend(ActionFigure::Formatters::Rfc9457) + result = f.error_response(errors: nil, status: :not_found, instance: "/projects/42") + assert_equal "/projects/42", result[:json][:instance] + end + + def test_error_response_omits_detail_and_instance_when_not_given + f = Object.new.extend(ActionFigure::Formatters::Rfc9457) + result = f.error_response(errors: nil, status: :not_found) + refute result[:json].key?(:detail) + refute result[:json].key?(:instance) + end + + def test_error_response_passes_arbitrary_extra_kwargs_as_extension_members + f = Object.new.extend(ActionFigure::Formatters::Rfc9457) + result = f.error_response(errors: nil, status: :not_found, balance: 0) + assert_equal 0, result[:json][:balance] + end + + def test_error_response_type_overridden_by_kwarg + f = Object.new.extend(ActionFigure::Formatters::Rfc9457) + result = f.error_response(errors: nil, status: :not_found, type: "https://example.com/not-found") + assert_equal "https://example.com/not-found", result[:json][:type] + end + + def test_error_response_title_overridden_by_kwarg + f = Object.new.extend(ActionFigure::Formatters::Rfc9457) + result = f.error_response(errors: nil, status: :not_found, title: "Custom Not Found") + assert_equal "Custom Not Found", result[:json][:title] + end + + def test_error_response_status_member_is_numeric + f = Object.new.extend(ActionFigure::Formatters::Rfc9457) + result = f.error_response(errors: nil, status: :conflict) + assert_equal 409, result[:json][:status] + end +end + +class Rfc9457FormatterAncestorsTest < Minitest::Test + def test_includes_action_figure_formatter + assert_includes ActionFigure::Formatters::Rfc9457.ancestors, ActionFigure::Formatter + end +end diff --git a/test/integration/rails_controller_test.rb b/test/integration/rails_controller_test.rb index f61d3cb..5a350b4 100644 --- a/test/integration/rails_controller_test.rb +++ b/test/integration/rails_controller_test.rb @@ -192,6 +192,46 @@ def create end end +# --- Action Classes: RFC 9457 --- + +class Rfc9457CreateAction + include ActionFigure[:rfc_9457] + + params_schema do + required(:name).filled(:string) + end + + def create(params:) + user = User.create!(name: params[:name]) + Created(resource: user, type: "user-created", title: "User created") + end +end + +class Rfc9457NotFoundAction + include ActionFigure[:rfc_9457] + + def show(params:) + NotFound( + errors: { id: ["not found"] }, + detail: "User #{params[:id]} does not exist", + instance: "/rfc9457/users/#{params[:id]}" + ) + end +end + +# --- Controllers: RFC 9457 --- + +class Rfc9457UsersController < ActionController::Base + def create + render Rfc9457CreateAction.create(params: request.request_parameters) + end + + def show + result = Rfc9457NotFoundAction.show(params: { id: params[:id] }) + render result + end +end + # --- Routes --- RailsIntegrationTestApp.routes.draw do @@ -214,6 +254,10 @@ def create scope "/authored" do resources :users, only: %i[create], controller: "authored_users" end + + scope "/rfc9457" do + resources :users, only: %i[create show], controller: "rfc9457_users" + end end # --- Tests --- @@ -436,3 +480,65 @@ def test_create_without_current_user_returns_403 assert_includes body[:errors][:base], "not authorized" end end + +class Rfc9457FormatterIntegrationTest < ActionDispatch::IntegrationTest + include ActionFigure::Testing::Minitest + + def setup + User.delete_all + end + + def test_create_returns_201_with_user_in_body + action_result = Rfc9457CreateAction.create(params: { name: "Tad" }) + assert_Created(action_result) + + post "/rfc9457/users", params: { name: "Tad" }, as: :json + assert_response :created + + body = JSON.parse(response.body, symbolize_names: true) + assert_equal "user-created", body[:type] + assert_equal "User created", body[:title] + assert_equal "Tad", body[:user][:name] + end + + def test_create_returns_422_with_problem_document + action_result = Rfc9457CreateAction.create(params: {}) + assert_UnprocessableContent(action_result) + + post "/rfc9457/users", params: {}, as: :json + assert_response :unprocessable_content + + body = JSON.parse(response.body, symbolize_names: true) + assert_equal 422, body[:status] + assert_equal Rack::Utils::HTTP_STATUS_CODES[422], body[:title] + assert body[:errors].key?(:name), "expected errors to include :name" + end + + def test_create_sets_content_type_to_problem_json_on_error + post "/rfc9457/users", params: {}, as: :json + assert_includes response.content_type, "application/problem+json" + end + + def test_success_does_not_set_problem_json_content_type + post "/rfc9457/users", params: { name: "Tad" }, as: :json + assert_response :created + refute_includes response.content_type, "application/problem+json" + end + + def test_not_found_returns_problem_document_with_detail_and_instance + get "/rfc9457/users/99", as: :json + assert_response :not_found + + body = JSON.parse(response.body, symbolize_names: true) + assert_equal 404, body[:status] + assert_equal "Not Found", body[:title] + assert_equal "User 99 does not exist", body[:detail] + assert_equal "/rfc9457/users/99", body[:instance] + assert_equal({ id: ["not found"] }, body[:errors]) + end + + def test_not_found_sets_content_type_to_problem_json + get "/rfc9457/users/99", as: :json + assert_includes response.content_type, "application/problem+json" + end +end