diff --git a/.dockerignore b/.dockerignore index 6f20247..321d874 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,8 @@ coverage .git .github test -README.md \ No newline at end of file +README.md +.env +.env.local +docker-compose.override.yml +.dockerignore \ No newline at end of file diff --git a/.env.example b/.env.example index 961c454..d8ad42b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,14 @@ +# Server +PORT=4000 + +# Redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_URL=redis://redis:6379 + +# Database +DATABASE_URL=postgres://smartdrop:smartdrop@postgres:5432/smartdrop # Runtime environment # NODE_ENV: string enum (development, test, production). Default: development. NODE_ENV=development @@ -36,13 +47,10 @@ PRICE_STALE_THRESHOLD_MINUTES=5 PRICE_ANOMALY_THRESHOLD_PCT=20 # API key auth -# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ADMIN_API_KEY= # LOG_LEVEL: string enum (debug, info, warn, error). Default: info. LOG_LEVEL=info # CORS -# Comma-separated list of allowed origins (no trailing slashes) -# Production: CORS_ALLOWED_ORIGINS=https://app.smartdrop.io,https://staging.smartdrop.io -CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 +CORS_ALLOWED_ORIGINS=http://localhost:4000,http://localhost:3001 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index bc5e6e8..9ff0706 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,19 @@ -FROM node:20-alpine +# --- Base & Development Stage --- +FROM node:20-alpine AS development +WORKDIR /app + +# Instalar dependencias completas (incluye devDependencies para nodemon/hot-reload) +COPY package*.json ./ +RUN npm install --legacy-peer-deps + +# Copiar el código fuente +COPY . . +EXPOSE 4000 +CMD ["npm", "run", "dev"] + +# --- Builder Stage para Producción --- +FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ @@ -7,6 +21,14 @@ RUN npm ci --omit=dev COPY src ./src -EXPOSE 3000 +# --- Production Stage --- +FROM node:20-alpine AS production +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/src ./src +COPY package*.json ./ +EXPOSE 4000 CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/README.md b/README.md index d23fff9..a9b1751 100644 --- a/README.md +++ b/README.md @@ -49,56 +49,65 @@ Registers subscriber endpoints for SmartDrop lifecycle events and delivers signe - Delivery logs with response code, error, duration, and attempt count - Dead-letter storage after retry exhaustion -## Setup +--- -### Prerequisites +## 🚀 Quick Start (Docker Development) -- Node.js >= 20.9.0 -- Redis server (local or remote) +You can spin up the entire local development stack—including the API, PostgreSQL database, and Redis instance—using a single command. -### Installation +### Prerequisites +* Ensure you have [Docker and Docker Compose](https://docs.docker.com/get-docker/) installed. -```bash -npm install -``` +### Spin Up the Stack -### Redis Setup +1. **Clone and Navigate** to the project root directory. +2. **Set up Environment Variables**: + ```bash + cp .env.example .env -**macOS (Homebrew):** -```bash -brew install redis -brew services start redis ``` -**Linux (Ubuntu/Debian):** +3. **Launch the Infrastructure**: ```bash -sudo apt-get install redis-server -sudo systemctl start redis -sudo systemctl enable redis -``` +docker compose up --build -**Docker:** -```bash -docker run -d -p 6379:6379 redis:alpine ``` -**Verify Redis is running:** -```bash -redis-cli ping -# Should return: PONG -``` -### Configuration -Copy `.env.example` to `.env` and configure: +The API will stand up on [http://localhost:4000](https://www.google.com/search?q=http://localhost:4000). -```bash -cp .env.example .env -``` +* **Hot Reloading:** Any changes made to files within the `./src` directory will instantly trigger an application restart inside the container. +* **Database & Cache:** Health checks prevent the API from booting until Postgres and Redis are fully operational. +* **Teardown:** To stop the containers and maintain volume data, run `docker compose down`. To wipe database volumes completely during stop, use `docker compose down -v`. + +--- + +## Configuration + +The application reads configurations from the `.env` file at the root. **Environment Variables:** | Variable | Description | Default | Required | +| --- | --- | --- | --- | +| `PORT` | Server port | 4000 | No | +| `REDIS_HOST` | Redis server host | redis | No | +| `REDIS_PORT` | Redis server port | 6379 | No | +| `REDIS_PASSWORD` | Redis password | undefined | No | +| `REDIS_URL` | Redis connection string | redis://redis:6379 | No | +| `DATABASE_URL` | PostgreSQL connection string | postgres://smartdrop:smartdrop@postgres:5432/smartdrop | No | +| `STELLAR_HORIZON_URL` | Horizon API URL | https://horizon.stellar.org | No | +| `USDC_ISSUER` | USDC issuer address | GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA | No | +| `COINGECKO_API_KEY` | CoinGecko API key | undefined | No | +| `COINMARKETCAP_API_KEY` | CoinMarketCap API key | undefined | No | +| `PRICE_CACHE_TTL` | Cache TTL in seconds | 60 | No | +| `PRICE_REFRESH_INTERVAL` | Refresh interval in seconds | 30 | No | +| `PRICE_STALE_THRESHOLD` | Stale threshold in minutes | 5 | No | +| `PRICE_ANOMALY_THRESHOLD` | Anomaly detection threshold % | 10 | No | +| `ADMIN_API_KEY` | Bootstrap admin bearer token for API key management | undefined | Yes, for protected endpoints | +| `LOG_LEVEL` | Logging level | info | No | +| `CORS_ALLOWED_ORIGINS` | Allowed origins split by commas | http://localhost:4000,http://localhost:3001 | No | |----------|-------------|---------|----------| | `NODE_ENV` | Runtime environment: `development`, `test`, or `production` | development | No | | `PORT` | Server port | 3000 | No | @@ -115,17 +124,7 @@ cp .env.example .env | `ADMIN_API_KEY` | Bootstrap admin bearer token for API key management | empty | Yes, for protected endpoints | | `LOG_LEVEL` | Logging level: `debug`, `info`, `warn`, or `error` | info | No | -### Running - -```bash -# Development (with auto-reload) -npm run dev - -# Production -npm start -``` - -The server will start on the configured port (default: 3000) and automatically begin the background price refresh job. +--- ## API Endpoints @@ -133,9 +132,11 @@ The server will start on the configured port (default: 3000) and automatically b ``` GET /api/v1/prices/:asset_code?issuer= + ``` **Response:** + ```json { "asset_code": "XLM", @@ -147,31 +148,30 @@ GET /api/v1/prices/:asset_code?issuer= "stale_warning": null, "sources_attempted": ["stellar_dex", "coingecko"] } + ``` ### Force Price Refresh ``` GET /api/v1/prices/:asset_code/refresh?issuer= + ``` Requires `Authorization: Bearer `. ### API Keys -Protected endpoints use `Authorization: Bearer `. Set `ADMIN_API_KEY` -to a 32-byte hex token for bootstrap access, then create scoped API keys with -the key-management endpoints. +Protected endpoints use `Authorization: Bearer `. Set `ADMIN_API_KEY` to a 32-byte hex token for bootstrap access, then create scoped API keys with the key-management endpoints. ``` GET /api/v1/keys POST /api/v1/keys DELETE /api/v1/keys/:id + ``` -`POST /api/v1/keys` returns the raw `api_key` only once. Stored keys are hashed -with SHA-256 and listed with metadata only (`label`, `created_at`, -`last_used_at`, `scopes`, and `key_prefix`). +`POST /api/v1/keys` returns the raw `api_key` only once. Stored keys are hashed with SHA-256 and listed with metadata only (`label`, `created_at`, `last_used_at`, `scopes`, and `key_prefix`). ### Webhook Endpoints @@ -181,70 +181,92 @@ GET /api/v1/webhooks DELETE /api/v1/webhooks/:id POST /api/v1/webhooks/:id/test GET /api/v1/webhooks/:id/deliveries + ``` ### Health Check ``` GET /health + ``` **Response:** + ```json { "status": "ok", "timestamp": "2024-01-15T10:30:00.000Z" } + ``` +--- + ## Usage Examples ### Fetch XLM Price + ```bash -curl http://localhost:3000/api/v1/prices/XLM +curl http://localhost:4000/api/v1/prices/XLM + ``` ### Fetch Custom Asset Price + ```bash -curl "http://localhost:3000/api/v1/prices/USDC?issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA" +curl "http://localhost:4000/api/v1/prices/USDC?issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA" + ``` ### Force Price Refresh + ```bash -curl http://localhost:3000/api/v1/prices/XLM/refresh \ +curl http://localhost:4000/api/v1/prices/XLM/refresh \ -H "Authorization: Bearer $API_KEY" + ``` ### Create API Key + ```bash -curl -X POST http://localhost:3000/api/v1/keys \ +curl -X POST http://localhost:4000/api/v1/keys \ -H "Authorization: Bearer $ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"label":"alerts worker","scopes":["alerts"]}' + ``` ### Check Service Health + ```bash -curl http://localhost:3000/health +curl http://localhost:4000/health + ``` +--- + ## Error Handling The API returns appropriate HTTP status codes: -- `200` - Success -- `400` - Invalid request parameters -- `404` - Price not available -- `500` - Internal server error +* `200` - Success +* `400` - Invalid request parameters +* `404` - Price not available +* `500` - Internal server error **Error Response Format:** + ```json { "error": "Error type", "message": "Detailed error message" } + ``` +--- + ## Development ### Project Structure @@ -265,6 +287,7 @@ src/ │ └── coinmarketcap.js # CoinMarketCap API source └── jobs/ └── priceRefresh.js # Background price refresh job + ``` ### Adding New Price Sources @@ -276,6 +299,7 @@ To add a new price source: 3. Add the source to the `SOURCES` array in `src/services/priceOracle.js` Example: + ```javascript // src/services/sources/customSource.js const axios = require('axios'); @@ -283,8 +307,7 @@ const logger = require('../../logger'); async function fetchPrice(assetCode, issuer) { try { - // Fetch price from your source - const response = await axios.get('https://api.example.com/price', { + const response = await axios.get('[https://api.example.com/price](https://api.example.com/price)', { params: { asset: assetCode } }); return response.data.price; @@ -295,13 +318,20 @@ async function fetchPrice(assetCode, issuer) { } module.exports = { fetchPrice }; + ``` +--- + ## Troubleshooting ### Redis Connection Issues If you see "Redis connection error" in logs: + +* Verify containers are running: `docker compose ps` +* Check Redis logs: `docker compose logs redis` +* Ensure environmental parameters (`REDIS_HOST=redis`) reference the compose network alias rather than `localhost`. - Verify Redis is running: `redis-cli ping` - Check `REDIS_URL` in `.env` - If Redis requires a password, include it in the connection URL @@ -309,21 +339,31 @@ If you see "Redis connection error" in logs: ### Price Not Available If prices return `null`: -- Check that at least one price source is configured -- Verify API keys for CoinGecko/CoinMarketCap if using those sources -- Check logs for specific source errors -- Stellar DEX may have no liquidity for the asset + +* Check that at least one price source is configured +* Verify API keys for CoinGecko/CoinMarketCap if using those sources +* Check logs for specific source errors +* Stellar DEX may have no liquidity for the asset ### Rate Limiting External APIs may rate limit requests: -- CoinGecko: Free tier has rate limits -- CoinMarketCap: Requires API key for production use -- The service handles rate limits gracefully and falls back to other sources + +* CoinGecko: Free tier has rate limits +* CoinMarketCap: Requires API key for production use +* The service handles rate limits gracefully and falls back to other sources + +--- ## Monitoring The service logs important events: + +* Price fetches from each source +* Price anomalies (>10% changes) +* Stale price warnings +* Cache refresh cycles +* API errors - Price fetches from each source - Price anomalies (>20% changes) - Stale price warnings @@ -331,9 +371,10 @@ The service logs important events: - API errors Monitor logs for: -- Frequent source failures -- Price anomalies (may indicate market volatility or data issues) -- Stale prices (may indicate cache or source issues) + +* Frequent source failures +* Price anomalies (may indicate market volatility or data issues) +* Stale prices (may indicate cache or source issues) ## License diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..96d3886 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + api: + build: + context: . + target: development + ports: + - "4000:4000" + environment: + - PORT=4000 + - REDIS_URL=redis://redis:6379 + - REDIS_HOST=redis + - REDIS_PORT=6379 + - DATABASE_URL=postgres://smartdrop:smartdrop@postgres:5432/smartdrop + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy + volumes: + - ./src:/app/src # Hot reload en desarrollo + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: smartdrop + POSTGRES_PASSWORD: smartdrop + POSTGRES_DB: smartdrop + ports: + - "5432:5432" + healthcheck: + test: ["CMD", "pg_isready", "-U", "smartdrop"] + interval: 5s + timeout: 3s + retries: 5 \ No newline at end of file diff --git a/src/index.js b/src/index.js index 092cff0..fbbf102 100644 --- a/src/index.js +++ b/src/index.js @@ -44,6 +44,11 @@ app.use((err, req, res, _next) => { res.status(status).json({ error: err.message || 'Internal server error' }); }); +// 1. Declaramos la variable server aquí afuera usando let (para que tenga alcance global en el archivo) +let server; + +if (require.main === module) { + // 2. Aquí adentro solo la asignamos (quitamos el 'const') let server; if (require.main === module) { @@ -55,7 +60,7 @@ if (require.main === module) { process.on('SIGTERM', async () => { logger.info('SIGTERM received, shutting down'); priceRefreshJob.stop(); - server.close(); + if (server) server.close(); await cache.disconnect(); process.exit(0); }); @@ -63,12 +68,14 @@ if (require.main === module) { process.on('SIGINT', async () => { logger.info('SIGINT received, shutting down'); priceRefreshJob.stop(); - server.close(); + if (server) server.close(); await cache.disconnect(); process.exit(0); }); } +// 3. Ahora el export funcionará perfectamente, tanto si corre directo como en modo test +module.exports = { app, server }; module.exports = app; module.exports.app = app; module.exports.server = server || {