diff --git a/context/content-negotiation.md b/context/content-negotiation.md new file mode 100644 index 0000000..56eda57 --- /dev/null +++ b/context/content-negotiation.md @@ -0,0 +1,108 @@ +# Content Negotiation + +This guide explains how to combine `protocol-media` with an HTTP parser to select an application representation. + +## Overview + +HTTP content negotiation has two separate responsibilities: + + - The HTTP layer parses the `Accept` header and orders ranges according to HTTP quality and preference rules. + - The application layer maps those ordered ranges to representations it can produce. + +Keeping this boundary explicit allows `protocol-http` to implement standard HTTP parsing without depending on `protocol-media`, while application frameworks can use {ruby Protocol::Media::Map} for their own policy. + +## Register Representations + +Register the concrete representations the application can produce. Registration order determines the fallback for wildcard ranges when there is no exact registration: + +``` ruby +require "json" +require "protocol/media/map" + +representations = Protocol::Media::Map.new +representations["application/json"] = lambda do |record, version: "1"| + JSON.generate(record.merge(schema_version: version)) +end +representations["text/plain"] = ->(record){record.inspect} +``` + +Register concrete response types rather than client ranges. A server generally produces `application/json`, even when a client requests `application/*` or `*/*`. + +## Parse HTTP Preferences + +The HTTP parser remains responsible for header syntax and quality ordering. Its range objects can be passed directly to {ruby Protocol::Media::Map#for} because the map only requires `type` and `subtype`: + +``` ruby +require "protocol/http/header/accept" + +accept = Protocol::HTTP::Header::Accept.parse( + "text/*;q=0.5, application/json;q=1.0", +) + +ranges = accept.media_ranges.sort +renderer, range = representations.for(ranges) + +renderer.call({id: 10, name: "Example"}) +# => "{\"id\":10,\"name\":\"Example\",\"schema_version\":\"1\"}" +``` + +{ruby Protocol::Media::Map#for} returns both the mapped object and the original range. Returning the original object preserves HTTP-specific state such as parameters and quality factors. + +## Handle Request Parameters + +Applications can inspect parameters on the selected HTTP range without teaching the media map about HTTP semantics: + +``` ruby +renderer, range = representations.for(ranges) + +if renderer + version = range.parameters.fetch("version", "1") + record = {id: 10, name: "Example"} + body = renderer.call(record, version: version) +end +``` + +Parameters do not participate in map compatibility. They are application inputs associated with the selected range, not separate map keys. + +## Handle Missing Headers + +HTTP defines a missing `Accept` header as accepting any media type. The HTTP-facing application should install that policy explicitly: + +``` ruby +if accept_header && !accept_header.empty? + ranges = Protocol::HTTP::Header::Accept.parse(accept_header).media_ranges.sort +else + ranges = [Protocol::Media::Range.parse("*/*")] +end + +renderer, range = representations.for(ranges) +``` + +This policy belongs at the HTTP application boundary rather than in {ruby Protocol::Media::Map}, which can also be used outside HTTP. + +## Handle No Match + +When no registered representation matches, {ruby Protocol::Media::Map#for} returns `nil`. An HTTP application would normally return `406 Not Acceptable`: + +``` ruby +if match = representations.for(ranges) + renderer, range = match + response_body = renderer.call(record) +else + response_body = nil + response_status = 406 +end +``` + +## Best Practices + + - Sort HTTP ranges before passing them to the map; the map preserves the supplied order. + - Register concrete server representations in deterministic fallback order. + - Keep protocol-specific parameters on the original range object. + - Handle missing headers and `406 Not Acceptable` at the HTTP or framework boundary. + +## Common Pitfalls + +{ruby Protocol::Media::Map} does not parse a comma-separated `Accept` header, sort quality factors, or decide HTTP response status. Passing a complete header string to the map attempts to parse it as one media range. Parse the header with an HTTP implementation first. + +Map keys are identified by type and subtype. Registering the same type with different parameters replaces the existing entry rather than creating a parameter-specific variant. diff --git a/context/getting-started.md b/context/getting-started.md new file mode 100644 index 0000000..842d951 --- /dev/null +++ b/context/getting-started.md @@ -0,0 +1,124 @@ +# Getting Started + +This guide explains how to represent, compare, and map media types with `protocol-media`. + +## Installation + +Add the gem to your project: + +~~~ bash +$ bundle add protocol-media +~~~ + +## Core Concepts + +Applications commonly need to identify response formats, compare wildcard ranges, and associate formats with handlers. `protocol-media` separates those concerns into three small objects: + + - {ruby Protocol::Media::Type} represents a concrete media type such as `application/json`. + - {ruby Protocol::Media::Range} represents a concrete or wildcard range such as `text/*`. + - {ruby Protocol::Media::Map} associates supported types or ranges with application objects. + +The gem deliberately does not parse HTTP headers or provide a media type registry. Protocol implementations can supply compatible range objects, while registry data and extension lookup are provided by `protocol-media-registry`. + +## Media Types + +Use {ruby Protocol::Media::Type.parse} when an application needs a concrete content type and its parameters: + +``` ruby +require "protocol/media/type" + +type = Protocol::Media::Type.parse("application/json; charset=utf-8") + +type.type # => "application" +type.subtype # => "json" +type.parameters # => {"charset" => "utf-8"} +type.name # => "application/json" +``` + +Media type names are normalized to lowercase. Parameters are retained for serialization but do not change the type name. + +A concrete {ruby Protocol::Media::Type} cannot contain wildcards: + +``` ruby +Protocol::Media::Type.parse("text/*") +# Raises ArgumentError. +``` + +## Media Ranges + +Use {ruby Protocol::Media::Range.parse} when matching one or more compatible media types: + +``` ruby +require "protocol/media/range" +require "protocol/media/type" + +range = Protocol::Media::Range.parse("image/*") +png = Protocol::Media::Type.parse("image/png") +json = Protocol::Media::Type.parse("application/json") + +range.match?(png) # => true +range.match?(json) # => false +``` + +`*/*` matches every media type, while `text/*` matches every subtype under `text`. Partial wildcard tokens such as `text/*+json` are not valid ranges. + +### Compatible Range Objects + +{ruby Protocol::Media::Range.for} parses strings but preserves non-string objects. This allows an HTTP implementation to provide its own richer range object without depending on `protocol-media`: + +``` ruby +range = Protocol::Media::Range.for("text/*") +# => Protocol::Media::Range + +http_range = Struct.new(:type, :subtype, :parameters).new( + "application", + "json", + {"q" => "0.8"}, +) + +Protocol::Media::Range.for(http_range).equal?(http_range) +# => true +``` + +Compatible objects must expose `type` and `subtype`. Other attributes, such as HTTP quality factors, remain owned by the protocol-specific object. + +## Media Maps + +Use {ruby Protocol::Media::Map} when an application supports several representations and needs to select a compatible handler: + +``` ruby +require "json" +require "protocol/media/map" + +renderers = Protocol::Media::Map.new +renderers["application/json"] = ->(record){JSON.generate(record)} +renderers["text/plain"] = ->(record){record.inspect} + +renderer = renderers["text/*"] +renderer.call({id: 10, name: "Example"}) +# => "{:id=>10, :name=>\"Example\"}" +``` + +Exact registrations take priority. If no exact registration exists, the first compatible registration is returned. Register concrete server representations in the order they should be used for wildcard requests. + +Parameters do not distinguish map entries: + +``` ruby +renderers["application/json; version=2"] = :versioned_json +renderers["application/json"] +# => :versioned_json +``` + +Use the original matched range, rather than separate parameterized registrations, when request parameters affect rendering. The [Content Negotiation](../content-negotiation/index) guide shows this pattern with an HTTP `Accept` header. + +## Registry Data + +`protocol-media` models media types but does not bundle the media type registry. Use `protocol-media-registry` when mapping file extensions or registered names: + +``` ruby +require "protocol/media/registry" + +record = Protocol::Media::Registry.for_extension(".json") +record.type.name +# => "application/json" +``` diff --git a/context/index.yaml b/context/index.yaml new file mode 100644 index 0000000..ba08876 --- /dev/null +++ b/context/index.yaml @@ -0,0 +1,16 @@ +# Automatically generated context index for Utopia::Project guides. +# Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`. +--- +description: Provides abstractions for working with media types. +metadata: + documentation_uri: https://socketry.github.io/protocol-media/ + source_code_uri: https://github.com/socketry/protocol-media.git +files: +- path: getting-started.md + title: Getting Started + description: This guide explains how to represent, compare, and map media types + with `protocol-media`. +- path: content-negotiation.md + title: Content Negotiation + description: This guide explains how to combine `protocol-media` with an HTTP parser + to select an application representation. diff --git a/guides/content-negotiation/readme.md b/guides/content-negotiation/readme.md new file mode 100644 index 0000000..56eda57 --- /dev/null +++ b/guides/content-negotiation/readme.md @@ -0,0 +1,108 @@ +# Content Negotiation + +This guide explains how to combine `protocol-media` with an HTTP parser to select an application representation. + +## Overview + +HTTP content negotiation has two separate responsibilities: + + - The HTTP layer parses the `Accept` header and orders ranges according to HTTP quality and preference rules. + - The application layer maps those ordered ranges to representations it can produce. + +Keeping this boundary explicit allows `protocol-http` to implement standard HTTP parsing without depending on `protocol-media`, while application frameworks can use {ruby Protocol::Media::Map} for their own policy. + +## Register Representations + +Register the concrete representations the application can produce. Registration order determines the fallback for wildcard ranges when there is no exact registration: + +``` ruby +require "json" +require "protocol/media/map" + +representations = Protocol::Media::Map.new +representations["application/json"] = lambda do |record, version: "1"| + JSON.generate(record.merge(schema_version: version)) +end +representations["text/plain"] = ->(record){record.inspect} +``` + +Register concrete response types rather than client ranges. A server generally produces `application/json`, even when a client requests `application/*` or `*/*`. + +## Parse HTTP Preferences + +The HTTP parser remains responsible for header syntax and quality ordering. Its range objects can be passed directly to {ruby Protocol::Media::Map#for} because the map only requires `type` and `subtype`: + +``` ruby +require "protocol/http/header/accept" + +accept = Protocol::HTTP::Header::Accept.parse( + "text/*;q=0.5, application/json;q=1.0", +) + +ranges = accept.media_ranges.sort +renderer, range = representations.for(ranges) + +renderer.call({id: 10, name: "Example"}) +# => "{\"id\":10,\"name\":\"Example\",\"schema_version\":\"1\"}" +``` + +{ruby Protocol::Media::Map#for} returns both the mapped object and the original range. Returning the original object preserves HTTP-specific state such as parameters and quality factors. + +## Handle Request Parameters + +Applications can inspect parameters on the selected HTTP range without teaching the media map about HTTP semantics: + +``` ruby +renderer, range = representations.for(ranges) + +if renderer + version = range.parameters.fetch("version", "1") + record = {id: 10, name: "Example"} + body = renderer.call(record, version: version) +end +``` + +Parameters do not participate in map compatibility. They are application inputs associated with the selected range, not separate map keys. + +## Handle Missing Headers + +HTTP defines a missing `Accept` header as accepting any media type. The HTTP-facing application should install that policy explicitly: + +``` ruby +if accept_header && !accept_header.empty? + ranges = Protocol::HTTP::Header::Accept.parse(accept_header).media_ranges.sort +else + ranges = [Protocol::Media::Range.parse("*/*")] +end + +renderer, range = representations.for(ranges) +``` + +This policy belongs at the HTTP application boundary rather than in {ruby Protocol::Media::Map}, which can also be used outside HTTP. + +## Handle No Match + +When no registered representation matches, {ruby Protocol::Media::Map#for} returns `nil`. An HTTP application would normally return `406 Not Acceptable`: + +``` ruby +if match = representations.for(ranges) + renderer, range = match + response_body = renderer.call(record) +else + response_body = nil + response_status = 406 +end +``` + +## Best Practices + + - Sort HTTP ranges before passing them to the map; the map preserves the supplied order. + - Register concrete server representations in deterministic fallback order. + - Keep protocol-specific parameters on the original range object. + - Handle missing headers and `406 Not Acceptable` at the HTTP or framework boundary. + +## Common Pitfalls + +{ruby Protocol::Media::Map} does not parse a comma-separated `Accept` header, sort quality factors, or decide HTTP response status. Passing a complete header string to the map attempts to parse it as one media range. Parse the header with an HTTP implementation first. + +Map keys are identified by type and subtype. Registering the same type with different parameters replaces the existing entry rather than creating a parameter-specific variant. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md new file mode 100644 index 0000000..842d951 --- /dev/null +++ b/guides/getting-started/readme.md @@ -0,0 +1,124 @@ +# Getting Started + +This guide explains how to represent, compare, and map media types with `protocol-media`. + +## Installation + +Add the gem to your project: + +~~~ bash +$ bundle add protocol-media +~~~ + +## Core Concepts + +Applications commonly need to identify response formats, compare wildcard ranges, and associate formats with handlers. `protocol-media` separates those concerns into three small objects: + + - {ruby Protocol::Media::Type} represents a concrete media type such as `application/json`. + - {ruby Protocol::Media::Range} represents a concrete or wildcard range such as `text/*`. + - {ruby Protocol::Media::Map} associates supported types or ranges with application objects. + +The gem deliberately does not parse HTTP headers or provide a media type registry. Protocol implementations can supply compatible range objects, while registry data and extension lookup are provided by `protocol-media-registry`. + +## Media Types + +Use {ruby Protocol::Media::Type.parse} when an application needs a concrete content type and its parameters: + +``` ruby +require "protocol/media/type" + +type = Protocol::Media::Type.parse("application/json; charset=utf-8") + +type.type # => "application" +type.subtype # => "json" +type.parameters # => {"charset" => "utf-8"} +type.name # => "application/json" +``` + +Media type names are normalized to lowercase. Parameters are retained for serialization but do not change the type name. + +A concrete {ruby Protocol::Media::Type} cannot contain wildcards: + +``` ruby +Protocol::Media::Type.parse("text/*") +# Raises ArgumentError. +``` + +## Media Ranges + +Use {ruby Protocol::Media::Range.parse} when matching one or more compatible media types: + +``` ruby +require "protocol/media/range" +require "protocol/media/type" + +range = Protocol::Media::Range.parse("image/*") +png = Protocol::Media::Type.parse("image/png") +json = Protocol::Media::Type.parse("application/json") + +range.match?(png) # => true +range.match?(json) # => false +``` + +`*/*` matches every media type, while `text/*` matches every subtype under `text`. Partial wildcard tokens such as `text/*+json` are not valid ranges. + +### Compatible Range Objects + +{ruby Protocol::Media::Range.for} parses strings but preserves non-string objects. This allows an HTTP implementation to provide its own richer range object without depending on `protocol-media`: + +``` ruby +range = Protocol::Media::Range.for("text/*") +# => Protocol::Media::Range + +http_range = Struct.new(:type, :subtype, :parameters).new( + "application", + "json", + {"q" => "0.8"}, +) + +Protocol::Media::Range.for(http_range).equal?(http_range) +# => true +``` + +Compatible objects must expose `type` and `subtype`. Other attributes, such as HTTP quality factors, remain owned by the protocol-specific object. + +## Media Maps + +Use {ruby Protocol::Media::Map} when an application supports several representations and needs to select a compatible handler: + +``` ruby +require "json" +require "protocol/media/map" + +renderers = Protocol::Media::Map.new +renderers["application/json"] = ->(record){JSON.generate(record)} +renderers["text/plain"] = ->(record){record.inspect} + +renderer = renderers["text/*"] +renderer.call({id: 10, name: "Example"}) +# => "{:id=>10, :name=>\"Example\"}" +``` + +Exact registrations take priority. If no exact registration exists, the first compatible registration is returned. Register concrete server representations in the order they should be used for wildcard requests. + +Parameters do not distinguish map entries: + +``` ruby +renderers["application/json; version=2"] = :versioned_json +renderers["application/json"] +# => :versioned_json +``` + +Use the original matched range, rather than separate parameterized registrations, when request parameters affect rendering. The [Content Negotiation](../content-negotiation/index) guide shows this pattern with an HTTP `Accept` header. + +## Registry Data + +`protocol-media` models media types but does not bundle the media type registry. Use `protocol-media-registry` when mapping file extensions or registered names: + +``` ruby +require "protocol/media/registry" + +record = Protocol::Media::Registry.for_extension(".json") +record.type.name +# => "application/json" +``` diff --git a/guides/links.yaml b/guides/links.yaml new file mode 100644 index 0000000..3a35720 --- /dev/null +++ b/guides/links.yaml @@ -0,0 +1,4 @@ +getting-started: + order: 1 +content-negotiation: + order: 2 diff --git a/readme.md b/readme.md index 4bc6064..148178a 100644 --- a/readme.md +++ b/readme.md @@ -2,32 +2,14 @@ Provides a small representation of media types which can be shared by protocol implementations and registry backends. -``` ruby -require "protocol/media/type" +## Usage -type = Protocol::Media::Type.parse("text/plain; charset=utf-8") -type.type # => "text" -type.subtype # => "plain" -type.parameters # => {"charset" => "utf-8"} -``` +Please see the [project documentation](https://socketry.github.io/protocol-media/) for more details. -Media ranges can represent wildcard types and match compatible concrete media types: + - [Getting Started](https://socketry.github.io/protocol-media/guides/getting-started/index) - This guide explains how to represent, compare, and map media types with `protocol-media`. -``` ruby -require "protocol/media/range" + - [Content Negotiation](https://socketry.github.io/protocol-media/guides/content-negotiation/index) - This guide explains how to combine `protocol-media` with an HTTP parser to select an application representation. -range = Protocol::Media::Range.parse("text/*") -range.match?(type) # => true -``` +## See Also -Media maps associate supported media types with objects and select compatible entries: - -``` ruby -require "protocol/media/map" - -map = Protocol::Media::Map.new -map[type] = :text -map[range] # => :text -``` - -Registry data and indexed lookup are provided separately by `protocol-media-registry`. + - [Protocol::Media::Registry](https://github.com/socketry/protocol-media-registry) provides registry data and indexed lookup by media type or file extension.