commit 8eacb68a7e0a41ae4b1026b99bf4531539f100ea Author: GarandPLG Date: Thu May 15 21:50:11 2025 +0200 Dodaj mikroserwis Redis API z obsługą Docker i Bun (Migracja api do oddzielnego repozytorium) - Utworzono API w Bun i TypeScript do obsługi Redis (GET/POST/DELETE) z różnymi typami danych (string, list, set, hash). - Dodano `docker-compose.yaml` i `dockerfile` do uruchamiania Redis i API jako kontenery. - Skonfigurowano `.dockerignore` i `.gitignore` dla czystości repozytorium. - Użyto `bun.lockb` oraz `package.json` do zależności i blokady wersji. - Skonfigurowano `tsconfig.json` dla kompilatora TypeScript. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..17aa17a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +docker-compose.yaml +data +tsconfig.json \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f1800a --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +# Editor directories and files +.vscode/* +"$(pwd)" +slim.report.json +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo + + +data + +commit.txt +git-diff.txt \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..f41bd17 Binary files /dev/null and b/bun.lockb differ diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..3924269 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,32 @@ +name: redis-dev +services: + redis-stack: + image: redis/redis-stack:latest + container_name: redis-stack + restart: unless-stopped + ports: + - "6379:6379" + - "8001:8001" + networks: + - redis-net + volumes: + - redis-data:/data + redis-api: + image: gitea.garandplg.com/garands-world-game/gwg-redis-api:latest + container_name: redis-api + restart: unless-stopped + ports: + - "5001:5001" + networks: + - redis-net + depends_on: + - redis-stack + + +networks: + redis-net: + driver: bridge + + +volumes: + redis-data: diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..3757a6a --- /dev/null +++ b/dockerfile @@ -0,0 +1,27 @@ +FROM oven/bun:alpine + +LABEL maintainer="garandplg@garandplg.com" +LABEL version="0.0.10" +LABEL description="Mikroserwis API do obsługi Redis" + +WORKDIR /api + +COPY package.json bun.lockb ./ + +RUN bun install --production --frozen-lockfile \ + && bun pm cache rm \ + && rm -rf /root/.bun + +COPY . . + +RUN addgroup --system redis_api \ + && adduser --system --no-create-home --ingroup redis_api redis_api \ + && chown -R redis_api:redis_api /api + +USER redis_api + +ENV IS_DOCKER=true + +EXPOSE 5001 + +CMD ["bun", "run", "index.ts"] diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..eade0fe --- /dev/null +++ b/index.ts @@ -0,0 +1,195 @@ +import { serve } from 'bun'; +import { createClient } from 'redis'; + +const client = createClient({ + url: Bun.env.IS_DOCKER ? 'redis://redis-stack:6379' : 'redis://localhost:6379' +}); + +client.on('error', (err: Error) => console.error('Redis Client Error', err)); +await client.connect(); + +const healthCheck = async () => { + try { + await client.ping(); + return { status: 'OK' }; + } catch (error: unknown) { + return { + status: 'ERROR', + message: error instanceof Error ? error.message : 'Nieznany błąd' + }; + } +}; + +const handleRedisAction = async ( + method: string, + key?: string, + value?: any, + ttl?: number, + type: string = 'string' +) => { + switch (method) { + case 'GET': + if (!key) return { error: 'Brak klucza' }; + + const keyExists = await client.exists(key); + if (!keyExists) return { value: null, type: null }; + + const valueType = await client.type(key); + switch (valueType) { + case 'string': + const getValue = await client.get(key); + return { value: getValue ? JSON.parse(getValue) : null, type: valueType }; + + case 'list': + const getList = await client.lRange(key, 0, -1); + return { + value: getList ? getList.map(item => JSON.parse(item)) : [], + type: valueType + }; + + case 'hash': + const getHash = await client.hGetAll(key); + const parsedHash = Object.fromEntries( + Object.entries(getHash).map(([k, v]) => [k, JSON.parse(v)]) + ); + return { value: parsedHash || {}, type: valueType }; + + case 'set': + const getSet = await client.sMembers(key); + return { + value: getSet ? getSet.map(item => JSON.parse(item)) : [], + type: valueType + }; + + default: + return { error: 'Nieobsługiwany typ danych' }; + } + + case 'POST': + if (!key) return { error: 'Brak klucza' }; + + switch (type) { + case 'string': + if (!value) return { error: 'Brak wartości' }; + + await client.set(key, JSON.stringify(value)); + if (ttl && ttl > 0) await client.expire(key, ttl); + + return { message: 'Klucz string ustawiony' }; + + case 'hash': + if (!value || typeof value !== 'object') + return { error: 'Brak wartości dla hash lub niepoprawny typ' }; + + const stringifiedHash = Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, JSON.stringify(v)]) + ); + await client.hSet(key, stringifiedHash); + if (ttl && ttl > 0) await client.expire(key, ttl); + + return { message: 'Hash ustawiony' }; + + case 'list': + if (!Array.isArray(value)) return { error: 'Wartość musi być tablicą' }; + + const stringifiedList = value.map(item => JSON.stringify(item)); + await client.rPush(key, stringifiedList); + if (ttl && ttl > 0) await client.expire(key, ttl); + + return { message: 'Lista ustawiona' }; + + case 'set': + if (!Array.isArray(value)) return { error: 'Wartość musi być tablicą' }; + + const stringifiedSet = value.map(item => JSON.stringify(item)); + await client.sAdd(key, stringifiedSet); + if (ttl && ttl > 0) await client.expire(key, ttl); + + return { message: 'Set ustawiony' }; + + // case 'zset': + // if ( + // !Array.isArray(value) || + // !value.every((item: any) => item.score !== undefined && item.value !== undefined) + // ) + // return { error: 'Wartość musi być tablicą obiektów z polem "score" i "value"' }; + // for (const item of value) { + // await client.zAdd(key, { score: item.score, value: item.value }); + // } + // console.log('SET (zset)', key, value); + // return { message: 'Sorted Set ustawiony' }; + + default: + return { error: 'Nieobsługiwany typ danych' }; + } + + case 'DELETE': + if (!key) return { error: 'Brak klucza' }; + + if (key === 'FLUSHALL') { + await client.flushAll(); + return { message: 'Wszystkie klucze zostały usunięte' }; + } + + await client.del(key); + return { message: 'Klucz usunięty' }; + + default: + return { error: 'Nieobsługiwana metoda' }; + } +}; + +serve({ + port: 5001, + async fetch(req: Request) { + const url = new URL(req.url); + const method = req.method; + + const headers: { [key: string]: string } = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + }; + + if (method === 'OPTIONS') { + return new Response('', { status: 200, headers }); + } + + if (url.pathname === '/api/redis') { + try { + const body = method !== 'GET' && method !== 'DELETE' ? await req.json() : null; + const key = + method === 'GET' || method === 'DELETE' ? url.searchParams.get('key') : body?.key; + const value = body?.value; + const ttl = body?.ttl ? parseInt(body.ttl, 10) : 0; + const type = body?.type; + + const result = await handleRedisAction(method, key, value, ttl, type); + + if (result?.error) + return new Response(JSON.stringify({ error: result.error }), { status: 400, headers }); + + return new Response(JSON.stringify(result), { status: 200, headers }); + } catch (error) { + return new Response( + JSON.stringify({ error: error instanceof Error ? error.message : 'Nieznany błąd' }), + { status: 400, headers } + ); + } + } else if (url.pathname === '/api/health') { + const health = await healthCheck(); + return new Response(JSON.stringify(health), { + status: health.status === 'OK' ? 200 : 500, + headers + }); + } + + return new Response(JSON.stringify({ error: 'Nieobsługiwany adres URL' }), { + status: 404, + headers + }); + } +}); + +console.log('Redis API uruchomiony na porcie 5001'); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d5641dc --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "redis-api", + "version": "0.0.10", + "private": true, + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "redis": "^4.7.0" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}