Skip to content
Merged
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
108 changes: 108 additions & 0 deletions context/content-negotiation.md
Original file line number Diff line number Diff line change
@@ -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.
124 changes: 124 additions & 0 deletions context/getting-started.md
Original file line number Diff line number Diff line change
@@ -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"
```
16 changes: 16 additions & 0 deletions context/index.yaml
Original file line number Diff line number Diff line change
@@ -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.
108 changes: 108 additions & 0 deletions guides/content-negotiation/readme.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading