diff --git a/src/index.ts b/src/index.ts index d7d984d..2c64bd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { handleCORSPreflight, validateLanguageCode, } from "./lib/security"; +import { translateWithGoogle } from "./lib/services/googleTranslate"; import { createStandardResponse } from "./lib/types"; /** @@ -63,6 +64,137 @@ const worker = { export default worker; +/** + * Common translation handler function + * Processes translation requests for both DeepL and Google Translate + * @param c - Hono context + * @param provider - Translation provider ('deepl' or 'google') + * @returns Translation response + */ +async function handleTranslation(c: any, provider: "deepl" | "google") { + const env = c.env; + const clientIP = getSecureClientIP(c.req.raw) || "unknown"; + + try { + // Parse request parameters with better error handling + let params; + try { + params = await c.req.json(); + } catch (parseError) { + return c.json(createStandardResponse(400, null), 400); + } + + // Enhanced parameter validation with input sanitization + if (!params || typeof params !== "object") { + return c.json(createStandardResponse(400, null), 400); + } + + if (!params.text || typeof params.text !== "string") { + return c.json(createStandardResponse(400, null), 400); + } + + // Basic text validation + let sanitizedText; + try { + sanitizedText = params.text; + if (sanitizedText.length > PAYLOAD_LIMITS.MAX_TEXT_LENGTH) { + sanitizedText = sanitizedText.slice(0, PAYLOAD_LIMITS.MAX_TEXT_LENGTH); + } + } catch (sanitizeError) { + return c.json(createStandardResponse(400, null), 400); + } + + // Validate text length + if (!sanitizedText) { + return c.json(createStandardResponse(400, null), 400); + } + + // Validate and sanitize language parameters + const sourceLang = params.source_lang + ? validateLanguageCode(params.source_lang) + : "auto"; + const targetLang = params.target_lang + ? validateLanguageCode(params.target_lang) + : "en"; + + if (!sourceLang || !targetLang) { + return c.json(createStandardResponse(400, null), 400); + } + + // Check cache first for faster response + const normalizedSourceLang = normalizeLanguageCode(sourceLang); + const normalizedTargetLang = normalizeLanguageCode(targetLang); + const cacheKey = generateCacheKey( + sanitizedText, + normalizedSourceLang, + normalizedTargetLang, + provider + ); + const cached = await getCachedTranslation(cacheKey, env); + + if (cached) { + return c.json( + createStandardResponse( + 200, + cached.data, + cached.id || Math.floor(Math.random() * 10000000000), + cached.source_lang, + cached.target_lang + ) + ); + } + + // Prepare validated parameters for translation + const validatedParams = { + text: sanitizedText, + source_lang: normalizedSourceLang, + target_lang: normalizedTargetLang, + }; + + let result; + + // Choose translation provider + if (provider === "google") { + result = await translateWithGoogle(validatedParams, { + env, + clientIP, + }); + } else { + // Use DeepL as default + result = await query(validatedParams, { + env, + clientIP, + }); + } + + // Cache successful translations + if (result.code === 200 && result.data) { + await setCachedTranslation( + cacheKey, + { + data: result.data, + timestamp: Date.now(), + source_lang: + result.source_lang || validatedParams.source_lang.toUpperCase(), + target_lang: + result.target_lang || validatedParams.target_lang.toUpperCase(), + id: result.id, + }, + env + ); + } + + return c.json(result, result.code as any); + } catch (error) { + const errorResponse = createErrorResponse(error, { + endpoint: `/${provider}`, + clientIP, + }); + + return c.json(errorResponse.response, errorResponse.httpStatus as any); + } +} + /** * API Route Definitions * Defines all available endpoints and their handlers @@ -72,6 +204,8 @@ app .options("*", (c) => handleCORSPreflight(c)) .get("/translate", (c) => c.text("Please use POST method :)")) + .get("/deepl", (c) => c.text("Please use POST method :)")) + .get("/google", (c) => c.text("Please use POST method :)")) /** * Debug endpoint for request format validation and troubleshooting @@ -175,122 +309,26 @@ app /** * Main translation endpoint with comprehensive features * Handles single text translation with rate limiting, caching, and error handling - * POST /translate + * POST /translate - Uses DeepL (legacy endpoint) */ .post("/translate", async (c) => { - const env = c.env; - const clientIP = getSecureClientIP(c.req.raw) || "unknown"; + return handleTranslation(c, "deepl"); + }) - try { - // Parse request parameters with better error handling - let params; - try { - params = await c.req.json(); - } catch (parseError) { - return c.json(createStandardResponse(400, null), 400); - } + /** + * DeepL translation endpoint + * POST /deepl - Uses DeepL translation service + */ + .post("/deepl", async (c) => { + return handleTranslation(c, "deepl"); + }) - // Enhanced parameter validation with input sanitization - if (!params || typeof params !== "object") { - return c.json(createStandardResponse(400, null), 400); - } - - if (!params.text || typeof params.text !== "string") { - return c.json(createStandardResponse(400, null), 400); - } - - // Basic text validation - let sanitizedText; - try { - sanitizedText = params.text; - if (sanitizedText.length > PAYLOAD_LIMITS.MAX_TEXT_LENGTH) { - sanitizedText = sanitizedText.slice( - 0, - PAYLOAD_LIMITS.MAX_TEXT_LENGTH - ); - } - } catch (sanitizeError) { - return c.json(createStandardResponse(400, null), 400); - } - - // Validate text length - if (!sanitizedText) { - return c.json(createStandardResponse(400, null), 400); - } - - // Validate and sanitize language parameters - const sourceLang = params.source_lang - ? validateLanguageCode(params.source_lang) - : "auto"; - const targetLang = params.target_lang - ? validateLanguageCode(params.target_lang) - : "en"; - - if (!sourceLang || !targetLang) { - return c.json(createStandardResponse(400, null), 400); - } - - // Check cache first for faster response - const normalizedSourceLang = normalizeLanguageCode(sourceLang); - const normalizedTargetLang = normalizeLanguageCode(targetLang); - const cacheKey = generateCacheKey( - sanitizedText, - normalizedSourceLang, - normalizedTargetLang - ); - const cached = await getCachedTranslation(cacheKey, env); - - if (cached) { - return c.json( - createStandardResponse( - 200, - cached.data, - cached.id || Math.floor(Math.random() * 10000000000), - cached.source_lang, - cached.target_lang - ) - ); - } - - // Prepare validated parameters for translation - const validatedParams = { - text: sanitizedText, - source_lang: normalizedSourceLang, - target_lang: normalizedTargetLang, - }; - - // Process translation with comprehensive rate limiting built-in - const result = await query(validatedParams, { - env, - clientIP, // Pass client IP to query function - }); - - // Cache successful translations - if (result.code === 200 && result.data) { - await setCachedTranslation( - cacheKey, - { - data: result.data, - timestamp: Date.now(), - source_lang: - result.source_lang || validatedParams.source_lang.toUpperCase(), - target_lang: - result.target_lang || validatedParams.target_lang.toUpperCase(), - id: result.id, - }, - env - ); - } - - return c.json(result, result.code as any); - } catch (error) { - const errorResponse = createErrorResponse(error, { - endpoint: "/translate", - clientIP, - }); - - return c.json(errorResponse.response, errorResponse.httpStatus as any); - } + /** + * Google Translate endpoint + * POST /google - Uses Google Translate service + */ + .post("/google", async (c) => { + return handleTranslation(c, "google"); }) /** diff --git a/src/lib/cache.ts b/src/lib/cache.ts index d3add95..80cf743 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -25,13 +25,15 @@ const CACHE_TTL = 3600; // 1 hour in seconds export function generateCacheKey( text: string, sourceLang: string, - targetLang: string + targetLang: string, + provider?: string ): string { // Normalize language codes to uppercase for consistent caching const normalizedSourceLang = sourceLang === "auto" ? "auto" : sourceLang.toUpperCase(); const normalizedTargetLang = targetLang.toUpperCase(); - const content = `${text}:${normalizedSourceLang}:${normalizedTargetLang}`; + const providerSuffix = provider ? `:${provider}` : ""; + const content = `${text}:${normalizedSourceLang}:${normalizedTargetLang}${providerSuffix}`; // Use crypto.subtle to generate a hash instead of btoa for Unicode safety try { diff --git a/src/lib/services/googleTranslate.ts b/src/lib/services/googleTranslate.ts new file mode 100644 index 0000000..ef00b53 --- /dev/null +++ b/src/lib/services/googleTranslate.ts @@ -0,0 +1,96 @@ +/** + * Google Translate integration service + * Provides Google Translate functionality with DeepLX-compatible API format + */ + +import { createErrorResponse } from "../errorHandler"; +import { + Config, + createStandardResponse, + RequestParams, + ResponseParams, +} from "../types"; + +/** + * Translate text using Google Translate API + * @param params - Translation parameters (text, source_lang, target_lang) + * @param config - Configuration options + * @returns Translation response in DeepLX format + */ +export async function translateWithGoogle( + params: RequestParams, + config: Config & { env: any; clientIP: string } +): Promise { + try { + const { text, source_lang, target_lang } = params; + + // Construct the request to Google Translate's internal API + const googleApiUrl = new URL( + "https://translate.google.com/translate_a/single" + ); + googleApiUrl.searchParams.append("client", "gtx"); // Google Translate web client + googleApiUrl.searchParams.append( + "sl", + source_lang === "auto" ? "auto" : source_lang.toLowerCase() + ); // Source language + googleApiUrl.searchParams.append("tl", target_lang.toLowerCase()); // Target language + googleApiUrl.searchParams.append("dt", "t"); // 't' for translation of text + googleApiUrl.searchParams.append("q", text); // The text to translate + + // Make the fetch call to Google Translate + const googleResponse = await fetch(googleApiUrl.toString(), { + method: "GET", + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + Referer: "https://translate.google.com/", + }, + }); + + if (!googleResponse.ok) { + throw new Error( + `Google Translate API responded with status ${googleResponse.status}` + ); + } + + const googleResponseBody = await googleResponse.json(); + + // Parse the complex Google Translate response + // The response is a deeply nested array. The translated text is + // typically in the first element. We concatenate the pieces. + let translatedText = ""; + if (googleResponseBody && googleResponseBody[0]) { + googleResponseBody[0].forEach((segment: any) => { + if (segment[0]) { + translatedText += segment[0]; + } + }); + } + + if (!translatedText) { + throw new Error("No translation result received from Google Translate"); + } + + // Format the response to match the DeepLX API + const detectedSourceLang = googleResponseBody[2] || source_lang; + + return createStandardResponse( + 200, + translatedText, + Math.floor(Math.random() * 10000000000), + detectedSourceLang.toUpperCase(), + target_lang.toUpperCase() + ); + } catch (error) { + console.error("Error in Google Translate:", error); + + const errorResponse = createErrorResponse(error, { + endpoint: "/google", + clientIP: config.clientIP, + }); + + return errorResponse.response; + } +} diff --git a/tests/integration/multiProvider.test.ts b/tests/integration/multiProvider.test.ts new file mode 100644 index 0000000..eedeb69 --- /dev/null +++ b/tests/integration/multiProvider.test.ts @@ -0,0 +1,46 @@ +/** + * Integration tests for multiple translation providers + */ + +import { describe, expect, it } from "@jest/globals"; + +describe("Translation Endpoints Integration", () => { + const testText = "Hello, world!"; + const mockRequest = { + text: testText, + source_lang: "en", + target_lang: "es", + }; + + describe("DeepL endpoints", () => { + it("should handle /translate endpoint (legacy DeepL)", async () => { + // This is just a placeholder test since we don't have actual server setup + // In a real scenario, you would make HTTP requests to your endpoints + expect(true).toBe(true); + }); + + it("should handle /deepl endpoint", async () => { + // Placeholder test for DeepL endpoint + expect(true).toBe(true); + }); + }); + + describe("Google Translate endpoint", () => { + it("should handle /google endpoint", async () => { + // Placeholder test for Google Translate endpoint + expect(true).toBe(true); + }); + }); + + describe("Error handling", () => { + it("should return 400 for invalid requests", async () => { + // Test error handling + expect(true).toBe(true); + }); + + it("should return 405 for GET requests", async () => { + // Test method not allowed + expect(true).toBe(true); + }); + }); +}); diff --git a/tests/lib/services/googleTranslate.test.ts b/tests/lib/services/googleTranslate.test.ts new file mode 100644 index 0000000..0e9a14c --- /dev/null +++ b/tests/lib/services/googleTranslate.test.ts @@ -0,0 +1,123 @@ +/** + * Google Translate service integration tests + */ + +import { beforeEach, describe, expect, it } from "@jest/globals"; +import { translateWithGoogle } from "../../src/lib/services/googleTranslate"; + +describe("Google Translate Service", () => { + let mockEnv: any; + + beforeEach(() => { + mockEnv = { + // Mock environment for testing + }; + }); + + describe("translateWithGoogle", () => { + it("should handle successful translation request", async () => { + // Mock fetch for successful response + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve([[["Hello", "Hola", null, null, 10]], null, "en"]), + }) + ) as jest.Mock; + + const params = { + text: "Hello", + source_lang: "en", + target_lang: "es", + }; + + const result = await translateWithGoogle(params, { + env: mockEnv, + clientIP: "127.0.0.1", + }); + + expect(result.code).toBe(200); + expect(result.data).toBe("Hello"); + expect(result.source_lang).toBe("EN"); + expect(result.target_lang).toBe("ES"); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("translate.google.com"), + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "User-Agent": expect.stringContaining("Mozilla"), + }), + }) + ); + }); + + it("should handle API errors", async () => { + // Mock fetch for error response + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 500, + }) + ) as jest.Mock; + + const params = { + text: "Hello", + source_lang: "en", + target_lang: "es", + }; + + const result = await translateWithGoogle(params, { + env: mockEnv, + clientIP: "127.0.0.1", + }); + + expect(result.code).toBe(500); + expect(result.data).toBeNull(); + }); + + it("should handle network errors", async () => { + // Mock fetch for network error + global.fetch = jest.fn(() => + Promise.reject(new Error("Network error")) + ) as jest.Mock; + + const params = { + text: "Hello", + source_lang: "en", + target_lang: "es", + }; + + const result = await translateWithGoogle(params, { + env: mockEnv, + clientIP: "127.0.0.1", + }); + + expect(result.code).toBe(500); + expect(result.data).toBeNull(); + }); + + it("should handle empty translation response", async () => { + // Mock fetch for empty response + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve([]), + }) + ) as jest.Mock; + + const params = { + text: "Hello", + source_lang: "en", + target_lang: "es", + }; + + const result = await translateWithGoogle(params, { + env: mockEnv, + clientIP: "127.0.0.1", + }); + + expect(result.code).toBe(500); + expect(result.data).toBeNull(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cb64777 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "lib": ["ES2020", "WebWorker"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2020", + "types": ["jest", "@cloudflare/workers-types"] + }, + "exclude": ["node_modules", "dist"], + "include": ["src/**/*", "tests/**/*", "worker-configuration.d.ts"] +}