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
74 changes: 43 additions & 31 deletions src/handlers/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,17 +149,18 @@ export async function handleGet(request, reply) {
const content = await storage.read(indexPath);
const indexStats = await storage.stat(indexPath);

// Check if RDF format requested via content negotiation
// Pick the negotiated RDF type using q-aware Accept parsing. The
// naive `acceptHeader.includes('text/turtle')` we used to do here
// ignored q-weights — `Accept: application/ld+json, text/turtle;q=0.1`
// would still pick Turtle even though JSON-LD was preferred (#325).
const acceptHeader = request.headers.accept || '';
const wantsTurtle = connegEnabled && (
acceptHeader.includes('text/turtle') ||
acceptHeader.includes('text/n3') ||
acceptHeader.includes('application/n-triples')
);
const wantsJsonLd = connegEnabled && (
acceptHeader.includes('application/ld+json') ||
acceptHeader.includes('application/json')
);
const negotiated = connegEnabled
? selectContentType(acceptHeader, true)
: null;
const wantsTurtle = negotiated === RDF_TYPES.TURTLE
|| negotiated === RDF_TYPES.N3
|| negotiated === 'application/n-triples';
const wantsJsonLd = negotiated === RDF_TYPES.JSON_LD;

if (wantsTurtle || wantsJsonLd) {
// Extract JSON-LD from HTML data island
Expand Down Expand Up @@ -257,13 +258,14 @@ export async function handleGet(request, reply) {
return reply.type('text/html').send(html);
}

// Check if Turtle/N3 format is requested via content negotiation
// Pick the negotiated RDF type using q-aware Accept parsing (#325).
const acceptHeader = request.headers.accept || '';
const wantsTurtle = connegEnabled && (
acceptHeader.includes('text/turtle') ||
acceptHeader.includes('text/n3') ||
acceptHeader.includes('application/n-triples')
);
const negotiated = connegEnabled
? selectContentType(acceptHeader, true)
: null;
const wantsTurtle = negotiated === RDF_TYPES.TURTLE
|| negotiated === RDF_TYPES.N3
|| negotiated === 'application/n-triples';

if (wantsTurtle) {
// Convert container JSON-LD to Turtle
Expand Down Expand Up @@ -383,11 +385,14 @@ export async function handleGet(request, reply) {
if (connegEnabled) {
const contentStr = content.toString();
const acceptHeader = request.headers.accept || '';
// Serve Turtle if: URL ends with .ttl OR Accept header requests it
const wantsTurtle = urlPath.endsWith('.ttl') ||
acceptHeader.includes('text/turtle') ||
acceptHeader.includes('text/n3') ||
acceptHeader.includes('application/n-triples');
// Serve Turtle if: URL ends with .ttl OR Accept's q-weighted top
// RDF type is Turtle/N3 (#325 — naive substring matching ignored
// q-weights and would pick Turtle whenever it appeared in Accept).
const negotiated = selectContentType(acceptHeader, true);
const wantsTurtle = urlPath.endsWith('.ttl')
|| negotiated === RDF_TYPES.TURTLE
|| negotiated === RDF_TYPES.N3
|| negotiated === 'application/n-triples';

// Check if this is HTML with JSON-LD data island
const isHtmlWithDataIsland = contentStr.trimStart().startsWith('<!DOCTYPE') ||
Expand Down Expand Up @@ -505,24 +510,31 @@ export async function handleHead(request, reply) {
let contentType;

if (stats.isDirectory) {
// For directories with index.html, determine content type based on Accept header
const indexPath = storagePath.endsWith('/') ? `${storagePath}index.html` : `${storagePath}/index.html`;
const indexExists = await storage.exists(indexPath);
const acceptHeader = request.headers.accept || '';

if (indexExists && connegEnabled) {
const acceptHeader = request.headers.accept || '';
const wantsTurtle = acceptHeader.includes('text/turtle') ||
acceptHeader.includes('text/n3') ||
acceptHeader.includes('application/n-triples');
const wantsJsonLd = acceptHeader.includes('application/ld+json') ||
acceptHeader.includes('application/json');
if (connegEnabled) {
// HEAD must mirror what GET would emit; otherwise client caches and
// RDF-aware tooling key off a content-type that doesn't match the
// body they'll see on the next GET (#325). Use q-aware Accept
// parsing for both the index.html and listing branches.
const negotiated = selectContentType(acceptHeader, true);
const wantsTurtle = negotiated === RDF_TYPES.TURTLE
|| negotiated === RDF_TYPES.N3
|| negotiated === 'application/n-triples';
const wantsJsonLd = negotiated === RDF_TYPES.JSON_LD;

if (wantsTurtle) {
contentType = 'text/turtle';
} else if (wantsJsonLd) {
contentType = 'application/ld+json';
// For an index.html container, only override to JSON-LD if the
// Accept header explicitly asked for JSON; otherwise fall back
// to text/html so HEAD matches the index.html that GET serves.
const explicitJson = /\b(application\/ld\+json|application\/json)\b/i.test(acceptHeader);
contentType = (indexExists && !explicitJson) ? 'text/html' : 'application/ld+json';
} else {
contentType = 'text/html';
contentType = indexExists ? 'text/html' : 'application/ld+json';
}
} else if (indexExists) {
contentType = 'text/html';
Expand Down
92 changes: 92 additions & 0 deletions test/conneg.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,95 @@ describe('Content Negotiation (conneg disabled - default)', () => {
});
});
});

// Regression coverage for #325 — q-weighted Accept and HEAD/GET parity.
// Previously the conneg dispatcher used naive substring matching on the
// Accept header, so any Accept that mentioned text/turtle (even at q=0.1
// alongside q=1.0 application/ld+json) returned Turtle. Separately, HEAD
// on a container without an index.html hard-coded application/ld+json,
// so HEAD and GET disagreed on content-type for the same URL.
describe('Content Negotiation — q-weights and HEAD/GET parity (#325)', () => {
before(async () => {
await startTestServer({ conneg: true });
await createTestPod('qwtest');
});
after(async () => { await stopTestServer(); });

function ct(res) {
return (res.headers.get('content-type') || '').split(';')[0].trim();
}

describe('container — q-weight respected', () => {
it('Accept: jsonld q=1.0, turtle q=0.1 → JSON-LD', async () => {
const res = await request('/qwtest/', {
headers: { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' }
});
assertStatus(res, 200);
assert.strictEqual(ct(res), 'application/ld+json');
const body = await res.text();
assert.ok(body.trimStart().startsWith('{'),
`body should be JSON, got: ${body.slice(0, 80)}`);
});

it('Accept: jsonld, turtle;q=0.5 → JSON-LD wins (downstream repro)', async () => {
const res = await request('/qwtest/', {
headers: { Accept: 'application/ld+json, text/turtle;q=0.5' }
});
assert.strictEqual(ct(res), 'application/ld+json');
const body = await res.text();
assert.ok(body.trimStart().startsWith('{'),
`body should be JSON, got: ${body.slice(0, 80)}`);
});

it('Accept: turtle (explicit) → Turtle', async () => {
const res = await request('/qwtest/', { headers: { Accept: 'text/turtle' } });
assert.strictEqual(ct(res), 'text/turtle');
const body = await res.text();
assert.ok(body.trimStart().startsWith('@prefix'),
`body should be Turtle, got: ${body.slice(0, 80)}`);
});

it('no Accept → JSON-LD (native default)', async () => {
const res = await request('/qwtest/');
assert.strictEqual(ct(res), 'application/ld+json');
});
});

describe('container — HEAD content-type matches GET', () => {
const cases = [
['no Accept', {}],
['jsonld preferred', { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' }],
['turtle preferred', { Accept: 'text/turtle' }],
['mixed (q=0.5)', { Accept: 'application/ld+json, text/turtle;q=0.5' }]
];
for (const [label, headers] of cases) {
it(`HEAD === GET content-type — ${label}`, async () => {
const get = await request('/qwtest/', { headers });
const head = await request('/qwtest/', { method: 'HEAD', headers });
assert.strictEqual(get.status, 200);
assert.strictEqual(head.status, 200);
assert.strictEqual(ct(head), ct(get),
`HEAD ct (${ct(head)}) must equal GET ct (${ct(get)}) for ${label}`);
});
}
});

describe('container — auth path matches anonymous', () => {
it('GET with auth returns same content-type as without auth (turtle case)', async () => {
const headers = { Accept: 'text/turtle' };
const anon = await request('/qwtest/', { headers });
const authed = await request('/qwtest/', { headers, auth: 'qwtest' });
assert.strictEqual(ct(anon), 'text/turtle');
assert.strictEqual(ct(authed), ct(anon),
'authenticated GET must report the same content-type as anonymous');
});

it('GET with auth returns same content-type as without auth (jsonld case)', async () => {
const headers = { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' };
const anon = await request('/qwtest/', { headers });
const authed = await request('/qwtest/', { headers, auth: 'qwtest' });
assert.strictEqual(ct(anon), 'application/ld+json');
assert.strictEqual(ct(authed), ct(anon));
});
});
});