6086f7b
commit 6086f7b
2 files changed
+63 -10
Showing 2 changed files+63-10
▼opengrammar/backend/src/index.ts
+13-3
| @@ -1,6 +1,7 @@ | ||
1 | 1 | import { Hono } from 'hono'; |
2 | 2 | import { cors } from 'hono/cors'; |
3 | 3 | import { logger } from 'hono/logger'; |
4 | +import type { Context, Next } from 'hono'; | |
4 | 5 | import OpenAI from 'openai'; |
5 | 6 | import { LLMAnalyzer, RuleBasedAnalyzer } from './analyzer.js'; |
6 | 7 | import { analyzeTone } from './nlp/tone-analyzer.js'; |
| @@ -21,13 +22,22 @@ const app = new Hono(); | ||
21 | 22 | // Middleware |
22 | 23 | app.use('/*', logger()); |
23 | 24 | app.use('/*', cors()); |
24 | -app.use('/*', async (c, next) => { | |
25 | + | |
26 | +// Append charset=utf-8 to JSON responses that don't declare one. | |
27 | +// Content-Type is case-insensitive: media type and charset parameter are | |
28 | +// matched case-insensitively so an existing "Charset=iso-8859-1" is preserved | |
29 | +// instead of appending a conflicting charset. | |
30 | +export async function ensureJsonUtf8(c: Context, next: Next) { | |
25 | 31 | await next(); |
26 | 32 | const ct = c.res.headers.get('Content-Type') ?? ''; |
27 | - if (ct.startsWith('application/json') && !ct.includes('charset')) { | |
33 | + const [mediaType, ...parameters] = ct.split(';'); | |
34 | + const hasCharset = parameters.some((p) => /^\s*charset\s*=/i.test(p)); | |
35 | + if (mediaType.trim().toLowerCase() === 'application/json' && !hasCharset) { | |
28 | 36 | c.res.headers.set('Content-Type', ct + '; charset=utf-8'); |
29 | 37 | } |
30 | -}); | |
38 | +} | |
39 | + | |
40 | +app.use('/*', ensureJsonUtf8); | |
31 | 41 | |
32 | 42 | // Root Landing Page |
33 | 43 | app.get('/', (c) => { |
34 | 44 | |
▼opengrammar/backend/tests/server.test.ts
+50-7
| @@ -1,18 +1,61 @@ | ||
1 | 1 | import { describe, expect, test } from 'bun:test'; |
2 | -import app from '../src/index.js'; | |
2 | +import { Hono } from 'hono'; | |
3 | +import app, { ensureJsonUtf8 } from '../src/index.js'; | |
4 | + | |
5 | +function fixtureApp() { | |
6 | + const fixture = new Hono(); | |
7 | + fixture.use('/*', ensureJsonUtf8); | |
8 | + fixture.get('/json', (c) => | |
9 | + new Response('{"a":1}', { headers: { 'Content-Type': 'application/json' } }), | |
10 | + ); | |
11 | + fixture.get('/json-lower-charset', (c) => | |
12 | + new Response('{"a":1}', { | |
13 | + headers: { 'Content-Type': 'application/json; charset=iso-8859-1' }, | |
14 | + }), | |
15 | + ); | |
16 | + fixture.get('/json-mixed-charset', (c) => | |
17 | + new Response('{"a":1}', { headers: { 'Content-Type': 'application/json; Charset=UTF-8' } }), | |
18 | + ); | |
19 | + fixture.get('/json-patch', (c) => | |
20 | + new Response('{"a":1}', { headers: { 'Content-Type': 'application/json-patch+json' } }), | |
21 | + ); | |
22 | + fixture.get('/html', (c) => | |
23 | + new Response('<h1>hi</h1>', { headers: { 'Content-Type': 'text/html; charset=utf-8' } }), | |
24 | + ); | |
25 | + return fixture; | |
26 | +} | |
3 | 27 | |
4 | 28 | describe('response Content-Type charset', () => { |
5 | - test('appends charset=utf-8 to JSON responses', async () => { | |
29 | + test('appends charset=utf-8 to real JSON responses', async () => { | |
6 | 30 | const res = await app.request('/health'); |
7 | 31 | expect(res.status).toBe(200); |
8 | - expect(res.headers.get('Content-Type')).toMatch(/application\/json;\s*charset=utf-8/i); | |
32 | + expect(res.headers.get('Content-Type')).toBe('application/json; charset=utf-8'); | |
33 | + }); | |
34 | + | |
35 | + test('appends charset=utf-8 to charset-less JSON', async () => { | |
36 | + const res = await fixtureApp().request('/json'); | |
37 | + expect(res.headers.get('Content-Type')).toBe('application/json; charset=utf-8'); | |
38 | + }); | |
39 | + | |
40 | + test('preserves an existing lower-case charset', async () => { | |
41 | + const res = await fixtureApp().request('/json-lower-charset'); | |
42 | + expect(res.headers.get('Content-Type')).toBe('application/json; charset=iso-8859-1'); | |
43 | + }); | |
44 | + | |
45 | + test('preserves a mixed-case charset without appending a conflicting one', async () => { | |
46 | + const res = await fixtureApp().request('/json-mixed-charset'); | |
47 | + expect(res.headers.get('Content-Type')).toBe('application/json; Charset=UTF-8'); | |
48 | + }); | |
49 | + | |
50 | + test('does not touch other application/* media types', async () => { | |
51 | + const res = await fixtureApp().request('/json-patch'); | |
52 | + expect(res.headers.get('Content-Type')).toBe('application/json-patch+json'); | |
9 | 53 | }); |
10 | 54 | |
11 | 55 | test('does not modify non-JSON responses', async () => { |
12 | - const res = await app.request('/'); | |
56 | + const res = await fixtureApp().request('/html'); | |
13 | 57 | expect(res.status).toBe(200); |
14 | - expect(res.headers.get('Content-Type')).toStartWith('text/html'); | |
15 | - const body = await res.text(); | |
16 | - expect(body.length).toBeGreaterThan(0); | |
58 | + expect(res.headers.get('Content-Type')).toBe('text/html; charset=utf-8'); | |
59 | + expect(await res.text()).toBe('<h1>hi</h1>'); | |
17 | 60 | }); |
18 | 61 | }); |
19 | 62 | |