From 286719da40d7ad3d65fea095d55f83a3f930e7aa Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 00:46:26 +1200 Subject: [PATCH] Introduce content parameter enumerations. --- guides/parameters/readme.md | 20 ++++++++ lib/protocol/content/parameters.rb | 1 + lib/protocol/content/parameters/builder.rb | 8 ++++ .../content/parameters/enumeration.rb | 44 ++++++++++++++++++ releases.md | 1 + test/protocol/content/parameters.rb | 46 +++++++++++++++++++ 6 files changed, 120 insertions(+) create mode 100644 lib/protocol/content/parameters/enumeration.rb diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index 30fd7c9..e861d91 100644 --- a/guides/parameters/readme.md +++ b/guides/parameters/readme.md @@ -79,6 +79,26 @@ arguments = parameters.parse!(media_type, input) user.update(arguments["user"]) ``` +## Constrain Enumerations + +Use an enumeration to accept an exact set of values: + +``` ruby +parameters = Protocol::Content::Parameters.build do + field "status", enumeration("draft", "published") +end +``` + +The hash form maps accepted input values to corresponding output values: + +``` ruby +parameters = Protocol::Content::Parameters.build do + field "enabled", enumeration("true" => true, "false" => false) +end +``` + +Enumeration matching is exact. Values not present in the enumeration produce an `invalid_type` error. + ## Convert Fields Built-in types match `String` values exactly and convert compatible values to `Integer` and `Float`. A custom converter can be supplied as any object responding to `#call`: diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index 96c445b..bc60d78 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -5,6 +5,7 @@ require_relative "default" require_relative "parameters/type" +require_relative "parameters/enumeration" require_relative "parameters/error" require_relative "parameters/result" require_relative "parameters/value" diff --git a/lib/protocol/content/parameters/builder.rb b/lib/protocol/content/parameters/builder.rb index 1b7c26b..9821d15 100644 --- a/lib/protocol/content/parameters/builder.rb +++ b/lib/protocol/content/parameters/builder.rb @@ -46,6 +46,14 @@ def upload(name, required: false, multiple: false) return add(UploadField.new(name, required:, multiple:)) end + # Construct an enumeration converter from accepted values or an input-to-output mapping. + # @parameter values [Array(Object)] The accepted values. + # @parameter options [Hash] Additional input-to-output mappings. + # @returns [Enumeration] The enumeration converter. + def enumeration(*values, **options) + return Enumeration.build(*values, **options) + end + # Declare an array of scalar values or nested argument hierarchies. # @parameter name [String] The array field name. # @parameter type [Module | #call | Nil] The expected element type or converter. diff --git a/lib/protocol/content/parameters/enumeration.rb b/lib/protocol/content/parameters/enumeration.rb new file mode 100644 index 0000000..3a603c2 --- /dev/null +++ b/lib/protocol/content/parameters/enumeration.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Protocol + module Content + module Parameters + # Converts an exact set of input values to corresponding output values. + class Enumeration + # Construct an enumeration from accepted values or an input-to-output mapping. + # @parameter values [Array(Object)] The accepted values. + # @parameter options [Hash] Additional input-to-output mappings. + # @returns [Enumeration] The enumeration converter. + def self.build(*values, **options) + mapping = values.to_h{|value| [value, value]} + mapping.update(options) + return new(mapping) + end + + # Initialize an enumeration from an input-to-output mapping. + # @parameter mapping [Hash] The accepted inputs and corresponding outputs. + def initialize(mapping) + @mapping = mapping.freeze + end + + # The accepted input values and corresponding output values. + attr :mapping + + # Convert an accepted input value. + # @parameter value [Object] The input value. + # @returns [Object] The corresponding output value. + # @raises [ArgumentError] If the input value is not accepted. + def call(value) + if @mapping.key?(value) + return @mapping[value] + end + + raise ArgumentError, "Invalid enumeration value: #{value.inspect}!" + end + end + end + end +end diff --git a/releases.md b/releases.md index 048df2e..e9866d3 100644 --- a/releases.md +++ b/releases.md @@ -3,6 +3,7 @@ ## Unreleased - Add declarative content parameter filtering, conversion, validation, and upload handling. + - Add exact enumeration validation and input mapping for content parameters. ## v0.1.0 diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 25c7b25..977ba6f 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -300,6 +300,52 @@ def multipart_body(*parts) expect(result["value"]).to be_a(type) end + it "accepts enumerated values" do + statuses = ["draft", "published"] + enumeration = subject::Enumeration.build(*statuses) + parameters = subject.build do + field "status", enumeration + field "coordinates", enumeration([1, 2]) + end + + valid = parse_json(parameters, '{"status":"published","coordinates":[1,2]}') + invalid = parse_json(parameters, '{"status":"deleted"}') + + expect(valid.value).to be == {"status" => "published", "coordinates" => [1, 2]} + expect(invalid.value).to be == {} + expect(invalid.errors.map(&:path)).to be == [["status"]] + expect(invalid.errors.map(&:code)).to be == [:invalid_type] + end + + it "maps enumerated input values" do + parameters = subject.build do + enumeration = enumeration("pending", "yes" => true, "no" => false, "none" => nil) + field "enabled", enumeration + field "disabled", enumeration + field "unspecified", enumeration + field "status", enumeration + end + + result = parse_json(parameters, '{"enabled":"yes","disabled":"no","unspecified":"none","status":"pending"}') + + expect(result).to be(:valid?) + expect(result.value).to be == { + "enabled" => true, + "disabled" => false, + "unspecified" => nil, + "status" => "pending", + } + end + + it "owns its enumeration mapping" do + mapping = {"draft" => "unpublished"} + enumeration = subject::Enumeration.build(**mapping) + mapping["published"] = "published" + + expect(enumeration.mapping).to be(:frozen?) + expect(enumeration.mapping).to be == {"draft" => "unpublished"} + end + it "collects custom converter failures" do converter = ->(_value){raise ArgumentError} parameters = subject.build do