Engineering HTTP Content Negotiation in GitLab Pages: RFC 9110, Semantic Web, and Production-Grade Request Resolution
How I implemented media-type negotiation for Linked Data resources in GitLab Pages using Go, bounded parsing, MIME resolution, virtual filesystem lookups, and HTTP cache semantics.
Static hosting appears straightforward: receive a request, resolve a path, read a file, and return a response.
However, that model becomes limiting when a URL represents a logical resource rather than a particular file.
Consider:
GET /alice HTTP/1.1
Host: example.gitlab.io
Accept: text/turtle
The client is not necessarily asking for an HTML document. It may be requesting an RDF representation of a person, an ontology, or another Linked Data resource.
A browser might request the same URL with:
Accept: text/html
A semantic crawler might request:
Accept: application/rdf+xml
And an application consuming structured linked data might request:
Accept: application/ld+json
All three requests refer to the same URI, but they express different representation preferences.
This is the problem addressed by HTTP content negotiation.
I implemented this capability in GitLab Pages through Merge Request !1384, addressing the long-standing Issue #1135.
The implementation introduces media-type-aware resolution for extensionless paths, allowing GitLab Pages to serve Semantic Web representations while preserving its existing HTML fallback behavior.
This article explores the engineering decisions behind the implementation, including HTTP semantics, parsing strategy, resource bounds, MIME-to-extension resolution, virtual filesystem integration, cache correctness, and testing.
1. Understanding the Architectural Problem
1.1 Traditional static-file resolution
A conventional static hosting system generally follows a predictable mapping:
Request URL Resolved file
/about /about.html
/docs /docs.html
/ /index.html
The URL-to-file mapping is primarily path-driven.
This works well for traditional websites, but it introduces a limitation when multiple representations of the same logical resource need to be exposed through one URI.
For example, a Semantic Web publisher might maintain:
public/
├── alice.html
├── alice.rdf
├── alice.ttl
├── alice.jsonld
├── alice.nt
└── index.html
The existing extensionless resolution mechanism has no reason to distinguish these representations based on the request headers.
Consequently, a request for /alice may resolve to alice.html, even when the client explicitly prefers RDF or Turtle.
1.2 The desired behavior
The objective was to extend the existing resolution mechanism without changing the established behavior of ordinary static websites.
The expected mapping becomes:
| Request | Accept header | Preferred representation |
|---|---|---|
/alice |
text/html |
alice.html |
/alice |
application/rdf+xml |
alice.rdf |
/alice |
text/turtle |
alice.ttl |
/alice |
application/ld+json |
alice.jsonld |
/alice |
application/n-triples |
alice.nt |
The same principle must work for directory index resources.
For example:
GET /
Accept: application/rdf+xml
should be able to resolve:
/index.rdf
when that representation exists.
The important architectural constraint was that content negotiation should extend the existing file resolution pipeline rather than introduce an independent serving mechanism.
This keeps the behavior integrated with GitLab Pages' existing virtual filesystem and content-type detection infrastructure.
2. HTTP Content Negotiation and RFC 9110
The implementation is based on the HTTP Accept request header, specified in RFC 9110, Section 12.5.1.
The header allows a client to communicate which response media types it can accept and how it prioritizes them.
Consider:
Accept: text/turtle;q=0.9,
application/rdf+xml;q=0.8,
text/html;q=0.5
The client expresses three preferences:
- Turtle is preferred.
- RDF/XML is an acceptable alternative.
- HTML is less preferred.
The q parameter is called a quality value.
RFC 9110 defines its valid range as:
0 <= q <= 1
A missing quality value defaults to 1.
A value of 0 means that the corresponding media range is not acceptable.
2.1 Media-range specificity
HTTP also supports wildcard media ranges.
There are three relevant forms:
| Media range | Specificity |
|---|---|
text/html |
2 |
text/* |
1 |
*/* |
0 |
For equal quality values, the implementation uses specificity as a secondary ordering criterion.
For example:
Accept: */*;q=0.8,
text/*;q=0.8,
text/html;q=0.8
The resulting candidate ordering is:
text/html
text/*
*/*
The original header position is retained as a further tie-breaker.
2.2 An important RFC distinction
There is a subtle difference between sorting media-range candidates and calculating the effective quality of a concrete representation.
RFC 9110 specifies that the most specific matching media range determines the quality associated with a representation.
For example:
Accept: text/*;q=0.3,
text/html;q=0.9
The effective quality for text/html is determined by the explicit text/html range, not by treating both entries as independent preferences for that representation.
This distinction matters when implementing a general-purpose HTTP negotiation engine.
In this implementation, the candidate parser establishes an ordered list of supported media-type preferences, using quality, specificity, and original order. The filesystem resolver then attempts those candidates in order.
The implementation is deliberately scoped to the supported media types and file-extension resolution required by the GitLab Pages feature.
3. Designing a Bounded Accept Header Parser
The parser is one of the most important parts of this implementation.
An HTTP header is external input. It must be handled defensively, especially in infrastructure that processes requests at scale.
A parser that works correctly for ordinary browser requests can still behave poorly when presented with malformed, oversized, or adversarial input.
The implementation introduces a dedicated parser in:
internal/serving/disk/negotiate.go
Instead of mixing header parsing directly into filesystem resolution, the logic is separated into smaller responsibilities:
parseAcceptHeader()
|
+-- parseCandidate()
| |
| +-- mime.ParseMediaType()
| +-- parseQuality()
| +-- mimeSpecificity()
|
+-- sortCandidates()
|
+-- return ordered candidates
This separation makes the parser easier to reason about and allows it to be tested independently.
3.1 Candidate representation
Each parsed media range is represented by a small structure:
type mimeCandidate struct {
mediaType string
quality float64
specificity int
order int
}
Each field serves a specific purpose:
-
mediaType: Normalized media type used for MIME mapping. -
quality: Client-provided preference weight. -
specificity: Specificity classification. -
order: Original position in the request header.
Keeping the original position is important because sorting must remain deterministic when multiple candidates have identical quality and specificity.
3.2 Parsing quality values safely
Go's strconv.ParseFloat is useful for converting numeric strings into floating-point values.
However, successful parsing alone does not guarantee a valid HTTP quality value.
For example, floating-point parsing can accept special values such as:
NaN
+Inf
-Inf
These values must not enter a sorting algorithm that expects ordinary comparable quality values.
In particular, NaN has unusual comparison behavior:
math.NaN() == math.NaN() // false
A sorting comparator operating on such values can violate the assumptions expected by the sorting implementation.
The parser therefore validates the result explicitly:
func parseQValue(qStr string) (float64, bool) {
qStr = strings.TrimSpace(qStr)
q, err := strconv.ParseFloat(qStr, 64)
if err != nil ||
math.IsNaN(q) ||
math.IsInf(q, 0) ||
q < 0.0 ||
q > 1.0 {
return 0, false
}
return q, true
}
This establishes several useful invariants:
- Parsing errors are rejected.
-
NaNis rejected. - Positive and negative infinity are rejected.
- Negative values are rejected.
- Values greater than one are rejected.
- Only finite values within the expected range are accepted.
One standards detail is worth distinguishing: RFC 9110's qvalue grammar permits at most three decimal digits. The range validation above enforces the numeric bounds but is not, by itself, a complete lexical validator for that grammar.
The key engineering principle is to validate untrusted numeric input before it participates in ordering decisions.
3.3 Handling malformed media-type parameters
The implementation uses Go's standard library:
mime.ParseMediaType(part)
This provides structured parsing of media types and parameters.
However, malformed input creates an important edge case.
Consider:
Accept: text/html; malformed; q=0
If parsing fails and the implementation blindly assigns the default quality of 1, it could accidentally reintroduce a representation the client explicitly excluded.
To avoid that behavior, the implementation has a fallback path that extracts the q parameter from the raw entry when structured parsing fails.
The relevant logic is:
func parseQuality(
params map[string]string,
rawPart string,
) (float64, bool) {
if params != nil {
if qStr, ok := params["q"]; ok {
return parseQValue(qStr)
}
return 1.0, true
}
if idx := strings.Index(rawPart, ";"); idx != -1 {
for _, param := range strings.Split(
rawPart[idx+1:],
";",
) {
param = strings.TrimSpace(param)
if strings.HasPrefix(
strings.ToLower(param),
"q=",
) {
return parseQValue(param[2:])
}
}
}
return 1.0, true
}
This is a defensive parsing decision: malformed syntax should not cause an explicitly excluded media type to be treated as fully acceptable.
It also demonstrates why error handling should consider the semantics of the input, rather than simply returning a default value whenever parsing fails.
3.4 Bounding parser work
Another important concern is resource consumption.
An attacker could send an Accept header containing a very large number of comma-separated entries.
Even if each individual entry is inexpensive to parse, processing an unbounded number of entries creates unnecessary CPU and allocation pressure.
The implementation introduces:
const maxAcceptParts = 16
The parsing loop is bounded:
for parsedParts := 0;
len(header) > 0 && parsedParts < maxAcceptParts;
parsedParts++ {
var part string
if idx := strings.IndexByte(header, ','); idx != -1 {
part = header[:idx]
header = header[idx+1:]
} else {
part = header
header = ""
}
// Parse and validate the candidate.
}
An important detail is that the counter tracks entries inspected, not just valid candidates appended.
Why does this matter?
Consider:
Accept: text/html,
text/html,
text/html,
text/html,
...
If the limit only applied to successfully appended candidates, repeated duplicate entries could still force the parser to inspect a large amount of input.
By counting every inspected entry, the implementation establishes a predictable upper bound on parsing iterations.
This is a small implementation detail with meaningful operational consequences.
It also keeps the candidate slice bounded, which limits subsequent sorting and allocation work.
3.5 Deduplicating media types
The parser maintains a set:
seenTypes := make(map[string]struct{})
After parsing and validation:
if _, seen := seenTypes[candidate.mediaType]; seen {
continue
}
seenTypes[candidate.mediaType] = struct{}{}
candidates = append(candidates, candidate)
This avoids retaining repeated entries for the same normalized media type.
Without deduplication, duplicate entries could trigger redundant resolution attempts and complicate deterministic behavior.
The implementation uses a map for efficient membership checks.
3.6 Deterministic candidate ordering
The candidates are sorted using:
func sortCandidates(candidates []mimeCandidate) {
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].quality != candidates[j].quality {
return candidates[i].quality > candidates[j].quality
}
if candidates[i].specificity != candidates[j].specificity {
return candidates[i].specificity >
candidates[j].specificity
}
return candidates[i].order < candidates[j].order
})
}
The ordering criteria are:
- Higher quality first.
- Higher specificity first.
- Earlier position in the original header first.
Stable sorting provides an additional guarantee that equal candidates do not acquire arbitrary ordering.
This makes the parser's output deterministic and easier to test.
4. MIME-to-Extension Resolution
Once the request preferences have been parsed, the next problem is translating media types into filesystem candidates.
HTTP clients communicate using MIME types.
Static hosting systems store resources using filenames and extensions.
The implementation needs a mapping between these two representations.
4.1 Supported media types
The implementation defines explicit mappings for the supported Semantic Web formats:
| Media type | File extension |
|---|---|
application/rdf+xml |
.rdf |
text/turtle |
.ttl |
application/x-turtle |
.ttl |
text/n3 |
.n3 |
text/rdf+n3 |
.n3 |
application/n-triples |
.nt |
application/ld+json |
.jsonld |
application/owl+xml |
.owx, .owl
|
text/html |
.html |
application/xhtml+xml |
.html |
The mapping is maintained centrally:
var knownMimeExtensions = map[string][]string{
mimeRdfXML: {extRDF},
mimeTurtle: {extTTL},
mimeXTurtle: {extTTL},
mimeN3: {extN3},
mimeRdfN3: {extN3},
mimeNTriples: {extNT},
mimeLdJSON: {extJSONLD},
mimeOwlXML: {extOWX, extOwl},
mimeTextHTML: {extHTML},
mimeXhtmlXML: {extHTML},
}
The important design decision is that MIME support is explicit.
The resolver does not attempt to derive arbitrary file extensions from arbitrary client-supplied media types.
That keeps the supported surface predictable and avoids turning request headers into unrestricted filesystem path construction.
4.2 Multiple extensions for one representation
Some media types can correspond to more than one established filename convention.
For example:
mimeOwlXML: {extOWX, extOwl}
The resolver checks the supported extensions in the configured order.
This provides compatibility with existing naming conventions without duplicating the negotiation logic.
4.3 MIME type detection must match the selected file
Selecting the correct file is only half of the problem.
The HTTP response must also communicate the correct media type.
GitLab Pages already contains extension-based content-type detection.
The implementation extends that mapping to include Semantic Web extensions such as:
.rdf
.ttl
.n3
.nt
.jsonld
.owl
.owx
This keeps file resolution and response metadata aligned.
A server should not return a Turtle file with an unrelated HTML content type simply because the original URL did not contain an extension.
5. Integrating Negotiation with the Virtual Filesystem
The resolver should not bypass the existing storage abstraction.
GitLab Pages uses a virtual filesystem layer, which allows the serving implementation to work with its supported storage backends without embedding backend-specific file access logic into content negotiation.
The negotiation implementation therefore operates through the existing VFS interfaces.
The central function is:
func (reader *Reader) negotiatePath(
ctx context.Context,
root vfs.Root,
basePath string,
r *http.Request,
) (string, bool, error)
Its responsibilities are:
- Read the request's
Acceptheader. - Parse the supported media-type candidates.
- Translate candidates into file extensions.
- Probe the VFS for existing representations.
- Return the first matching path.
- Fall back to HTML when no preferred representation exists.
5.1 Resolution pipeline
The high-level flow looks like this:
HTTP Request
|
v
Read Accept Header
|
v
Parse Media Ranges
|
v
Validate Quality and Specificity
|
v
Sort Candidates
|
v
Map MIME Types to Extensions
|
v
Probe Virtual Filesystem
|
+--------+--------+
| |
Found Not Found
| |
v v
Return Path Try Next Candidate
|
v
HTML Fallback
|
+-------+-------+
| |
Found Missing
| |
v v
Return Path Error
This architecture separates negotiation policy from the actual storage implementation.
5.2 Probing candidate extensions
The resolver uses a dedicated helper:
func (reader *Reader) tryExtensions(
ctx context.Context,
root vfs.Root,
basePath string,
exts []string,
seenExts map[string]struct{},
) (string, error)
The helper iterates through candidate extensions and resolves each path using the existing path resolution mechanism.
Conceptually:
for _, ext := range exts {
if _, seen := seenExts[ext]; seen {
continue
}
seenExts[ext] = struct{}{}
fullPath, err := reader.resolvePath(
ctx,
root,
basePath+ext,
)
if err == nil {
return fullPath, nil
}
}
There are two important decisions here.
First: reuse the existing resolver.
The implementation does not introduce a separate filesystem access path just for semantic resources.
This preserves existing VFS behavior and keeps storage-specific concerns out of the negotiation code.
Second: deduplicate extensions across candidates.
Different MIME types can map to the same extension.
For example, both text/turtle and application/x-turtle map to .ttl.
Without deduplication, the same path could be probed more than once.
The seenExts map prevents redundant resolution attempts.
5.3 Complexity and operational cost
Let:
- (n) be the maximum number of inspected Accept entries.
- (e) be the maximum number of extensions associated with a supported media type.
- (p) be the number of unique candidate paths actually probed.
With the parser bound set to 16, the sorting cost is:
[
O(n \log n)
]
Candidate deduplication and extension lookup are expected constant-time map operations.
The filesystem resolution cost depends on the number of unique candidates and the underlying VFS backend.
The important operational property is that parsing and candidate generation are bounded, and duplicate path probes are avoided.
This does not imply a universal latency guarantee: actual lookup performance remains dependent on the storage implementation.
6. Backward Compatibility and HTML Fallback
Introducing content negotiation into a mature static hosting platform creates a compatibility requirement.
Existing projects should not need to rename files or change their URLs.
The original behavior must remain available.
The resolver therefore uses HTML as its final fallback.
Consider:
public/
├── alice.html
└── alice.ttl
A client requests:
GET /alice
Accept: application/rdf+xml
There is no alice.rdf.
The resolver checks the preferred representation, finds that it does not exist, and falls back to:
alice.html
If the requested Turtle representation exists, it can be selected instead.
This behavior is important because it allows semantic resources to be introduced incrementally.
A project can publish HTML today and add RDF, Turtle, or JSON-LD representations without redesigning its URL structure.
6.1 Requests without a usable Accept header
When the header is absent or produces no usable candidates, the implementation resolves the HTML representation.
This preserves the traditional behavior of extensionless static URLs.
It also has an important caching implication: the URL is still negotiable even when the current request does not provide a usable preference.
That distinction becomes relevant in the next section.
7. HTTP Caching and the Vary: Accept Header
Caching is one of the easiest parts of content negotiation to overlook.
Suppose a client requests:
GET /alice
Accept: text/html
The server returns:
Content-Type: text/html
A shared cache stores the response under /alice.
Now another client requests:
GET /alice
Accept: text/turtle
If the cache does not know that the response depends on the Accept header, it might reuse the previously cached HTML representation.
The result is incorrect content delivery.
7.1 Why Vary matters
HTTP defines the Vary response header to communicate which request headers influenced representation selection.
For content-negotiated responses:
Vary: Accept
This tells compliant caches that the Accept request header is part of the cache key for the response variant.
Conceptually:
/alice + Accept: text/html
|
v
alice.html
/alice + Accept: text/turtle
|
v
alice.ttl
/alice + Accept: application/rdf+xml
|
v
alice.rdf
The cache must distinguish these variants.
7.2 The important case: missing Accept headers
A subtle but important implementation detail is that Vary: Accept is relevant even when the current request has no usable Accept header.
For example:
- A client requests
/alicewithout anAcceptheader. - GitLab Pages serves
alice.html. - Another client requests
/alicewithAccept: text/turtle.
If the first response is cached without accounting for Accept, the cache could incorrectly reuse HTML for the second request.
The implementation therefore retains the negotiation state even when it falls back to HTML because there are no usable candidates.
7.3 Direct file requests
There is also an important distinction between negotiated and explicit file requests.
For example:
GET /alice.ttl
This request already identifies a specific representation through its URL.
It does not need content negotiation to determine the extension.
The implementation keeps direct extension requests outside the negotiation path, avoiding unnecessary Vary: Accept behavior.
This is a useful example of applying HTTP caching semantics only where the representation actually depends on request negotiation.
8. Feature-Flagged Rollout
A production infrastructure change should not unexpectedly alter the behavior of existing deployments.
The implementation introduces a dedicated feature flag:
var ContentNegotiation = Feature{
EnvVariable: "FF_ENABLE_CONTENT_NEGOTIATION",
defaultEnabled: false,
}
The flag provides an explicit control over the new functionality.
When disabled:
- Existing extensionless URL resolution remains unchanged.
- Traditional HTML resolution continues to work.
- Existing static sites do not automatically enter the new negotiation flow.
When enabled:
- The request's
Acceptheader is considered. - Supported Semantic Web media types can be resolved.
- Existing HTML fallback behavior is retained.
The default-disabled configuration provides a controlled introduction of the feature.
This is particularly important in infrastructure software, where even a seemingly small change to path resolution can affect a large number of unrelated applications.
9. Testing Strategy: Validate Behavior, Not Just Parsing
A parser that passes a few unit tests is not sufficient for a feature that changes request resolution.
The implementation includes both parser-level and integration-level tests.
9.1 Parser unit tests
The dedicated parser tests cover cases such as:
| Test case | Expected behavior |
|---|---|
| Empty Accept header | No candidates |
| Single media type | Default quality of 1 |
| Multiple media types | Quality-based ordering |
| Equal quality values | Specificity-based ordering |
| Equal quality and specificity | Original order preserved |
q=0 |
Candidate excluded |
q=NaN |
Candidate rejected |
q=Inf |
Candidate rejected |
| Quality above 1 | Candidate rejected |
| Negative quality | Candidate rejected |
Malformed entry containing q=0
|
Explicit exclusion preserved |
| Duplicate media types | Duplicate candidates removed |
| Excessive header entries | Parsing remains bounded |
These tests are particularly valuable because they validate invariants rather than only testing ordinary inputs.
9.2 Integration tests
The integration tests exercise the actual content-resolution path.
Important scenarios include:
Semantic file resolution
/alice + application/rdf+xml -> alice.rdf
/alice + text/turtle -> alice.ttl
/alice + application/ld+json -> alice.jsonld
Directory index resolution
/ + application/rdf+xml -> index.rdf
Quality-weighted selection
Accept: text/turtle;q=0.9,
application/rdf+xml;q=0.8
The resolver should prefer the Turtle representation when both corresponding files are available.
Fallback behavior
If the requested semantic representation does not exist, the resolver should attempt the HTML file.
Default behavior
A request without a usable Accept header should retain HTML resolution.
Explicit extension requests
Direct requests for files such as /alice.ttl should bypass negotiation.
9.3 Why both layers matter
Unit tests answer:
Does the parser correctly interpret and order supported media-type preferences?
Integration tests answer:
Does the complete serving pipeline select the correct file and preserve the expected HTTP behavior?
Both are necessary.
A parser can be correct while the resolver is wrong. Likewise, filesystem resolution can work while response metadata or cache headers are incorrect.
Testing the complete behavior is what makes this type of infrastructure change reliable.
10. Engineering Trade-offs and Lessons
Several decisions in this implementation are worth highlighting.
10.1 Explicit MIME mappings over arbitrary extension derivation
Mapping supported MIME types explicitly is more predictable than deriving filesystem extensions from arbitrary request input.
It limits the resolution surface and makes supported behavior easier to review.
10.2 Bounded parsing over unbounded flexibility
HTTP headers are client-controlled.
A fixed maximum number of inspected entries gives the parser a predictable processing budget.
This is especially useful in high-throughput request-serving systems.
10.3 Reusing the VFS abstraction
Filesystem resolution should remain the responsibility of the existing storage layer.
Integrating negotiation through the VFS avoids introducing storage-specific assumptions into HTTP parsing logic.
10.4 Cache correctness is part of protocol correctness
Serving the correct file is not sufficient if an intermediary cache can deliver the wrong representation to another client.
Vary: Accept is part of the feature's correctness, not an optional performance optimization.
10.5 Backward compatibility must be intentional
A new resolution strategy should not silently redefine existing URLs.
Feature flags and HTML fallback behavior provide a controlled path toward introducing the new capability.
11. Final Architecture
The complete implementation can be understood as five cooperating layers:
HTTP Request
|
v
Accept Header Parser
|
v
Preference Ordering
|
v
MIME Extension Map
|
v
VFS Path Resolution
|
v
Content-Type Detection
|
v
HTTP Response
|
v
Vary: Accept
Each layer has a clear responsibility:
| Layer | Responsibility |
|---|---|
| HTTP parsing | Interpret client media-type preferences |
| Candidate ordering | Establish deterministic preference order |
| MIME mapping | Translate supported media types to extensions |
| VFS resolution | Locate the first available representation |
| HTTP response | Return the selected resource with correct metadata and cache variation |
The result is an extension to GitLab Pages' existing static serving architecture rather than a separate content-serving subsystem.
12. Conclusion
Implementing HTTP content negotiation in GitLab Pages was an interesting exercise in connecting protocol semantics with an existing production serving architecture.
The challenge was not simply recognizing Accept: text/turtle.
The implementation needed to account for:
- HTTP quality values and media-range specificity.
- Malformed and non-finite numeric inputs.
- Bounded parsing of untrusted request headers.
- Duplicate candidate and filesystem lookup elimination.
- MIME-to-extension mapping.
- Virtual filesystem integration.
- HTML fallback and backward compatibility.
- Correct cache variation through
Vary: Accept. - Controlled rollout through feature flags.
- Unit and integration-level validation.
These details illustrate an important engineering principle:
A production-ready protocol feature is not just about implementing the happy path. It is about preserving correctness across parsing, resolution, storage, caching, and compatibility boundaries.
This contribution gave me an opportunity to work deeper into Go-based infrastructure, HTTP semantics, and the design considerations involved in extending an established open-source platform.
The work is part of my contribution to GitLab Pages through the community merge request below.
References and Implementation
- Merge Request: GitLab Pages !1384 – Support content negotiation for semantic web and media types
- Original Issue: GitLab Pages #1135 – Content Negotiation
- HTTP Standard: RFC 9110 – HTTP Semantics
- Relevant Section: RFC 9110 – Section 12.5.1: Accept
- Go Standard Library: mime package
- Go Standard Library: strconv package
Author: Manish Kumar
GitLab: @manishpatel953249
Project: GitLab Pages
Building, understanding, and contributing to production-grade open-source infrastructure.
Top comments (0)