Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add language detector middleware and helpers #3787

Merged
merged 10 commits into from
Feb 6, 2025
Prev Previous commit
Next Next commit
feat: new parse-accept helper, add edge case tests
lord007tn committed Jan 23, 2025
commit 05007db367f955f75ed2d136508256ba00a23864
5 changes: 3 additions & 2 deletions src/helper/accepts/accepts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Hono } from '../..'
import { parseAccept } from '../../utils/parse-accept'
import type { Accept, acceptsConfig, acceptsOptions } from './accepts'
import { accepts, defaultMatch, parseAccept } from './accepts'
import { accepts, defaultMatch } from './accepts'

describe('parseAccept', () => {
test('should parse accept header', () => {
@@ -10,8 +11,8 @@ describe('parseAccept', () => {
expect(accepts).toEqual([
{ type: 'text/html', params: {}, q: 1 },
{ type: 'application/xhtml+xml', params: {}, q: 1 },
{ type: 'application/xml', params: { q: '0.9' }, q: 0.9 },
{ type: 'image/webp', params: {}, q: 1 },
{ type: 'application/xml', params: { q: '0.9' }, q: 0.9 },
{ type: '*/*', params: { q: '0.8', level: '1', foo: 'bar' }, q: 0.8 },
])
})
26 changes: 1 addition & 25 deletions src/helper/accepts/accepts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Context } from '../../context'
import type { AcceptHeader } from '../../utils/headers'
import { parseAccept } from '../../utils/parse-accept'

export interface Accept {
type: string
@@ -17,31 +18,6 @@ export interface acceptsOptions extends acceptsConfig {
match?: (accepts: Accept[], config: acceptsConfig) => string
}

export const parseAccept = (acceptHeader: string): Accept[] => {
// Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
const accepts = acceptHeader.split(',') // ['text/html', 'application/xhtml+xml', 'application/xml;q=0.9', 'image/webp', '*/*;q=0.8']
return accepts.map((accept) => {
const parts = accept.trim().split(';') // ['text/html', 'q=0.9', 'image/webp']
const type = parts[0] // text/html
const params = parts.slice(1) // ['q=0.9', 'image/webp']
const q = params.find((param) => param.startsWith('q='))

const paramsObject = params.reduce((acc, param) => {
const keyValue = param.split('=')
const key = keyValue[0].trim()
const value = keyValue[1].trim()
acc[key] = value
return acc
}, {} as { [key: string]: string })

return {
type: type,
params: paramsObject,
q: q ? parseFloat(q.split('=')[1]) : 1,
}
})
}

export const defaultMatch = (accepts: Accept[], config: acceptsConfig): string => {
const { supports, default: defaultSupport } = config
const accept = accepts.sort((a, b) => b.q - a.q).find((accept) => supports.includes(accept.type))
1 change: 1 addition & 0 deletions src/middleware/language/index.test.ts
Original file line number Diff line number Diff line change
@@ -272,4 +272,5 @@ describe('languageDetector', () => {
consoleSpy.mockRestore()
})
})

})
38 changes: 11 additions & 27 deletions src/middleware/language/language.ts
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
import type { Context } from '../../context'
import { setCookie, getCookie } from '../../helper/cookie'
import type { MiddlewareHandler } from '../../types'
import { parseAccept } from '../../utils/parse-accept'

export type DetectorType = 'path' | 'querystring' | 'cookie' | 'header'
export type CacheType = 'cookie'
@@ -13,7 +14,7 @@ export interface DetectorOptions {
/** Order of language detection strategies */
order: DetectorType[]
/** Query parameter name for language */
lookupQuerystring: string
lookupQueryString: string
/** Cookie name for language */
lookupCookie: string
/** Index in URL path where language code appears */
@@ -49,7 +50,7 @@ export interface LanguageVariables {

const DEFAULT_OPTIONS: DetectorOptions = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to export this.

order: ['querystring', 'cookie', 'header'],
lookupQuerystring: 'lang',
lookupQueryString: 'lang',
lookupCookie: 'language',
lookupFromHeaderKey: 'accept-language',
lookupFromPathIndex: 0,
@@ -71,24 +72,7 @@ const DEFAULT_OPTIONS: DetectorOptions = {
* @returns Array of parsed languages with quality scores
*/
export function parseAcceptLanguage(header: string): Array<{ lang: string; q: number }> {
try {
return header
.split(',')
.map((lang) => {
const [language, quality = 'q=1.0'] = lang
.trim()
.split(';')
.map((s) => s.trim())
const q = parseFloat(quality.replace('q=', ''))
return {
lang: language,
q: isNaN(q) ? 1.0 : Math.max(0, Math.min(1, q)),
}
})
.sort((a, b) => b.q - a.q)
} catch {
return []
}
return parseAccept(header).map(({ type, q }) => ({ lang: type, q }))
}

/**
@@ -97,10 +81,10 @@ export function parseAcceptLanguage(header: string): Array<{ lang: string; q: nu
* @param options Detector options
* @returns Normalized language code or undefined
*/
export function normalizeLanguage(
export const normalizeLanguage = (
lang: string | null | undefined,
options: DetectorOptions
): string | undefined {
): string | undefined => {
if (!lang) {
return undefined
}
@@ -126,9 +110,9 @@ export function normalizeLanguage(
/**
* Detects language from query parameter
*/
export function detectFromQuery(c: Context, options: DetectorOptions): string | undefined {
export const detectFromQuery = (c: Context, options: DetectorOptions): string | undefined => {
try {
const query = c.req.query(options.lookupQuerystring)
const query = c.req.query(options.lookupQueryString)
return normalizeLanguage(query, options)
} catch {
return undefined
@@ -138,7 +122,7 @@ export function detectFromQuery(c: Context, options: DetectorOptions): string |
/**
* Detects language from cookie
*/
export function detectFromCookie(c: Context, options: DetectorOptions): string | undefined {
export const detectFromCookie = (c: Context, options: DetectorOptions): string | undefined => {
try {
const cookie = getCookie(c, options.lookupCookie)
return normalizeLanguage(cookie, options)
@@ -238,7 +222,7 @@ function cacheLanguage(c: Context, language: string, options: DetectorOptions):
/**
* Detect language from request
*/
function detectLanguage(c: Context, options: DetectorOptions): string {
const detectLanguage = (c: Context, options: DetectorOptions): string => {
let detectedLang: string | undefined

for (const detectorName of options.order) {
@@ -277,7 +261,7 @@ function detectLanguage(c: Context, options: DetectorOptions): string {
* @param userOptions Configuration options for the language detector
* @returns Hono middleware function
*/
export function languageDetector(userOptions: Partial<DetectorOptions> = {}): MiddlewareHandler {
export const languageDetector = (userOptions: Partial<DetectorOptions>): MiddlewareHandler => {
const options: DetectorOptions = {
...DEFAULT_OPTIONS,
...userOptions,
156 changes: 156 additions & 0 deletions src/utils/parse-accept.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { parseAccept } from './parse-accept'

describe('parseAccept Comprehensive Tests', () => {
describe('Basic Functionality', () => {
test('parses simple accept header', () => {
const header = 'text/html,application/json;q=0.9'
expect(parseAccept(header)).toEqual([
{ type: 'text/html', params: {}, q: 1 },
{ type: 'application/json', params: { q: '0.9' }, q: 0.9 },
])
})

test('handles missing header', () => {
expect(parseAccept('')).toEqual([])
expect(parseAccept(undefined as any)).toEqual([])
expect(parseAccept(null as any)).toEqual([])
})
})

describe('Quality Values', () => {
test('handles extreme q values', () => {
const header = 'a;q=999999,b;q=-99999,c;q=Infinity,d;q=-Infinity,e;q=NaN'
const result = parseAccept(header)
expect(result.map((x) => x.q)).toEqual([1, 1, 1, 0, 0])
})

test('handles malformed q values', () => {
const header = 'a;q=,b;q=invalid,c;q=1.2.3,d;q=true,e;q="0.5"'
const result = parseAccept(header)
expect(result.every((x) => x.q >= 0 && x.q <= 1)).toBe(true)
})

test('preserves original q string in params', () => {
const header = 'type;q=invalid'
const result = parseAccept(header)
expect(result[0].params.q).toBe('invalid')
expect(result[0].q).toBe(1) // Normalized q value
})
})

describe('Parameter Handling', () => {
test('handles complex parameters', () => {
const header = 'type;a=1;b="2";c=\'3\';d="semi;colon";e="nested"quoted""'
const result = parseAccept(header)
expect(result[0].params).toEqual({
a: '1',
b: '"2"',
// eslint-disable-next-line quotes
c: "'3'",
d: '"semi;colon"',
e: '"nested"quoted""'
})
})

test('handles malformed parameters', () => {
const header = 'type;=value;;key=;=;====;key====value'
const result = parseAccept(header)
expect(result[0].type).toBe('type')
expect(Object.keys(result[0].params).length).toBe(0)
})

test('handles duplicate parameters', () => {
const header = 'type;key=1;key=2;KEY=3'
const result = parseAccept(header)
expect(result[0].params.key).toBe('2')
expect(result[0].params.KEY).toBe('3')
})
})

describe('Media Type Edge Cases', () => {
test('handles malformed media types', () => {
const headers = [
'*/html',
'text/*mal/formed',
'/partial',
'missing/',
'inv@lid/type',
'text/(html)',
'text/html?invalid',
]
headers.forEach((header) => {
const result = parseAccept(header)
expect(result[0].type).toBe(header)
})
})

test('handles extremely long types', () => {
const longType = 'a'.repeat(10000) + '/' + 'b'.repeat(10000)
const result = parseAccept(longType)
expect(result[0].type).toBe(longType)
})
})

describe('Delimiter Edge Cases', () => {
test('handles multiple consecutive delimiters', () => {
const header = 'a,,,,b;q=0.9,,,,c;q=0.8,,,,'
const result = parseAccept(header)
expect(result.map((x) => x.type)).toEqual(['a', 'b', 'c'])
})

test('handles unusual whitespace', () => {
const header = '\n\t a \t\n ; \n\t q=0.9 \t\n , \n\t b \t\n'
const result = parseAccept(header)
expect(result.map((x) => x.type)).toEqual(['b', 'a'])
})
})

describe('Security Cases', () => {
test('handles potential injection patterns', () => {
const headers = [
'type;q=0.9--',
'type;q=0.9;drop table users',
'type;__|;q=0.9',
'text/html"><script>alert(1)</script>',
'application/json${process.env}',
]
headers.forEach((header) => {
expect(() => parseAccept(header)).not.toThrow()
})
})

test('handles extremely large input', () => {
const header = 'a;q=0.9,'.repeat(100000)
expect(() => parseAccept(header)).not.toThrow()
})
})

describe('Unicode and Special Characters', () => {
test('handles unicode in types and parameters', () => {
const header = '🌐/😊;param=🔥;q=0.9'
const result = parseAccept(header)
expect(result[0].type).toBe('🌐/😊')
expect(result[0].params.param).toBe('🔥')
})

test('handles special characters', () => {
const header = 'type;param=\x00\x01\x02\x03'
const result = parseAccept(header)
expect(result[0].params.param).toBe('\x00\x01\x02\x03')
})
})

describe('Sort Stability', () => {
test('maintains stable sort for equal q values', () => {
const header = 'a;q=0.9,b;q=0.9,c;q=0.9,d;q=0.9'
const result = parseAccept(header)
expect(result.map((x) => x.type)).toEqual(['a', 'b', 'c', 'd'])
})

test('handles mixed priorities correctly', () => {
const header = 'd;q=0.8,b;q=0.9,c;q=0.8,a;q=0.9'
const result = parseAccept(header)
expect(result.map((x) => x.type)).toEqual(['b', 'a', 'd', 'c'])
})
})
})
Loading