Skip to content

Commit

Permalink
Add SDK and API
Browse files Browse the repository at this point in the history
  • Loading branch information
pontusab committed Feb 8, 2025
1 parent 7145005 commit 5273371
Show file tree
Hide file tree
Showing 24 changed files with 965 additions and 10 deletions.
Binary file added apps/web/public/updates/api-and-sdk.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions apps/web/src/app/[locale]/(marketing)/updates/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { PackageManagerProvider } from "@/components/package-manager-context";
import { getUpdates } from "@/lib/markdown";
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Updates",
description: "Latest updates and announcements from Languine",
};

interface Update {
slug: string;
frontmatter: {
title: string;
description: string;
date: string;
};
}

export default async function Page() {
const updates = await getUpdates();

return (
<PackageManagerProvider>
<div className="container mx-auto max-w-screen-md px-4 py-16 space-y-24">
{updates}
</div>
</PackageManagerProvider>
);
}
87 changes: 87 additions & 0 deletions apps/web/src/app/api/translate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";
import {
generateKey,
getCacheKey,
getFromCache,
setInCache,
} from "./utils/cache";
import { verifyApiKeyAndLimits } from "./utils/db";
import { handleError } from "./utils/errors";
import { performTranslation, persistTranslation } from "./utils/translation";
import { translateRequestSchema } from "./utils/validation";

export async function POST(request: Request) {
try {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) {
return NextResponse.json(
{
success: false,
error: "API key is required. Provide it via x-api-key header",
},
{ status: 401 },
);
}

const body = await request.json();
const { projectId, sourceLocale, targetLocale, format, sourceText, cache } =
translateRequestSchema.parse(body);

const org = await verifyApiKeyAndLimits(apiKey, projectId, format);
const key = generateKey(sourceText);
const cacheKey = getCacheKey(projectId, sourceLocale, targetLocale, key);

// Check cache
if (cache) {
const cachedResult = await getFromCache(cacheKey);
if (cachedResult) {
return NextResponse.json({
success: true,
translatedText: cachedResult,
cached: true,
});
}
}

// Perform translation
const translatedText = await performTranslation(key, sourceText, {
sourceLocale,
targetLocale,
format,
});

// Handle persistence in background
const isDocument = format === "md" || format === "mdx";

waitUntil(
(async () => {
await persistTranslation(
{
projectId,
organizationId: org.id,
format,
key,
sourceLocale,
targetLocale,
sourceText,
translatedText,
},
isDocument,
);

if (cache) {
await setInCache(cacheKey, translatedText);
}
})(),
);

return NextResponse.json({
success: true,
translatedText,
cached: false,
});
} catch (error) {
return handleError(error);
}
}
32 changes: 32 additions & 0 deletions apps/web/src/app/api/translate/utils/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createHash } from "node:crypto";
import { kv } from "@/lib/kv";

// Cache TTL in seconds (8 hours)
export const CACHE_TTL = 60 * 60 * 8;

export const generateKey = (content: string): string => {
return `api_${createHash("sha256").update(content).digest("hex").slice(0, 8)}`;
};

export const getCacheKey = (
projectId: string,
sourceLocale: string,
targetLocale: string,
key: string,
): string => {
return `translate:${projectId}:${sourceLocale}:${targetLocale}:${key}`;
};

export const getFromCache = async (
cacheKey: string,
): Promise<string | null> => {
return kv.get<string>(cacheKey);
};

export const setInCache = async (
cacheKey: string,
value: string,
ttl: number = CACHE_TTL,
): Promise<void> => {
await kv.set(cacheKey, value, { ex: ttl });
};
55 changes: 55 additions & 0 deletions apps/web/src/app/api/translate/utils/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { connectDb } from "@/db";
import { getOrganizationLimits } from "@/db/queries/organization";
import { organizations, projects } from "@/db/schema";
import { TIERS_MAX_DOCUMENTS, TIERS_MAX_KEYS } from "@/lib/tiers";
import { eq } from "drizzle-orm";

export const verifyApiKeyAndLimits = async (
apiKey: string,
projectId: string,
format?: string,
) => {
if (!apiKey.startsWith("org_")) {
throw new Error("Invalid API key format");
}

const db = await connectDb();

// Optimize: Single query to get both organization and project
const [org, project] = await Promise.all([
db
.select()
.from(organizations)
.where(eq(organizations.apiKey, apiKey))
.limit(1)
.then((rows) => rows[0]),
db
.select()
.from(projects)
.where(eq(projects.id, projectId))
.limit(1)
.then((rows) => rows[0]),
]);

if (!org) throw new Error("Invalid API key");
if (!project || project.organizationId !== org.id)
throw new Error("Project not found or access denied");

// Check translation limits
const { totalKeys, totalDocuments } = await getOrganizationLimits(org.id);
const currentKeysLimit =
TIERS_MAX_KEYS[org.tier as keyof typeof TIERS_MAX_KEYS];
const currentDocsLimit =
TIERS_MAX_DOCUMENTS[org.tier as keyof typeof TIERS_MAX_DOCUMENTS];

const isDocument = format === "md" || format === "mdx";
if (isDocument && totalDocuments >= currentDocsLimit) {
throw new Error("Document limit reached");
}

if (!isDocument && totalKeys >= currentKeysLimit) {
throw new Error("Translation key limit reached");
}

return org;
};
65 changes: 65 additions & 0 deletions apps/web/src/app/api/translate/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getValidationErrorMessage } from "./validation";

const errorMessages: Record<string, { status: number; message: string }> = {
"Invalid API key": {
status: 401,
message: "The provided API key is invalid. Please check your credentials.",
},
"Invalid API key format": {
status: 401,
message: "API key must start with 'org_'. Please check your credentials.",
},
"Project not found or access denied": {
status: 403,
message: "You don't have access to this project or it doesn't exist.",
},
"Translation key limit reached": {
status: 403,
message:
"You've reached your translation key limit. Please upgrade your plan to continue.",
},
"Document limit reached": {
status: 403,
message:
"You've reached your document limit. Please upgrade your plan to continue.",
},
};

export const handleError = (error: unknown) => {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
error: getValidationErrorMessage(error),
},
{ status: 400 },
);
}

if (error instanceof Error) {
const errorInfo = errorMessages[error.message] || {
status: 500,
message: "An unexpected error occurred while processing your request.",
};

return NextResponse.json(
{
success: false,
error: errorInfo.message,
},
{ status: errorInfo.status },
);
}

console.error("Translation error:", error);
return NextResponse.json(
{
success: false,
error:
"Something went wrong while processing your translation request. Please try again later.",
},
{ status: 500 },
);
};
55 changes: 55 additions & 0 deletions apps/web/src/app/api/translate/utils/translation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createTranslations } from "@/db/queries/translate";
import { translateKeys } from "@/jobs/utils/translate";

export const performTranslation = async (
key: string,
sourceText: string,
params: {
sourceLocale: string;
targetLocale: string;
format: string;
},
): Promise<string> => {
const translatedContent = await translateKeys(
[{ key, sourceText }],
{
sourceLocale: params.sourceLocale,
targetLocale: params.targetLocale,
sourceFormat: params.format,
},
1,
);

return translatedContent[key] || "";
};

export const persistTranslation = async (
params: {
projectId: string;
organizationId: string;
format: string;
key: string;
sourceLocale: string;
targetLocale: string;
sourceText: string;
translatedText: string;
},
isDocument: boolean,
): Promise<void> => {
await createTranslations({
projectId: params.projectId,
organizationId: params.organizationId,
sourceFormat: params.format,
translations: [
{
translationKey: params.key,
sourceLanguage: params.sourceLocale,
targetLanguage: params.targetLocale,
sourceText: params.sourceText,
translatedText: params.translatedText,
sourceFile: "api",
sourceType: isDocument ? "document" : "key",
},
],
});
};
53 changes: 53 additions & 0 deletions apps/web/src/app/api/translate/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { z } from "zod";

// Supported formats from the codebase
export const FORMAT_ENUM = [
"string",
"json",
"yaml",
"properties",
"android",
"xcode-strings",
"xcode-stringsdict",
"xcode-xcstrings",
"md",
"mdx",
"html",
"js",
"ts",
"po",
"xliff",
"csv",
"xml",
"arb",
] as const;

export const translateRequestSchema = z.object({
projectId: z.string(),
sourceLocale: z.string(),
targetLocale: z.string(),
format: z.enum(FORMAT_ENUM).optional().default("string"),
sourceText: z.string(),
cache: z.boolean().optional().default(true),
});

export type TranslateRequest = z.infer<typeof translateRequestSchema>;

export const getValidationErrorMessage = (error: z.ZodError): string => {
const errors = error.errors.map((err) => {
const field = err.path.join(".");
switch (err.code) {
case "invalid_type":
return `${field} must be a ${err.expected}`;
case "invalid_enum_value":
return `${field} must be one of: ${err.options?.join(", ")}`;
case "invalid_string":
return `${field} is not valid`;
case "too_small":
return `${field} is required`;
default:
return `${field} is invalid`;
}
});
return errors[0] || "Invalid request format";
};
2 changes: 0 additions & 2 deletions apps/web/src/components/docs-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ export function DocsSidebar() {
const router = useRouter();
const { sections, currentPage } = useDocs();

console.log(pathname);

return (
<>
<div className="md:hidden w-full mb-6">
Expand Down
Loading

0 comments on commit 5273371

Please sign in to comment.