chore: bootstrap uber eats wrapper scaffold with docs and openapi

This commit is contained in:
MOHAN 2026-03-29 17:31:58 +05:30
commit 7de04ab4e0
51 changed files with 4204 additions and 0 deletions

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
NODE_ENV=development
PORT=8080
# Uber app credentials
UBER_CLIENT_ID=your_client_id
UBER_CLIENT_SECRET=your_client_secret
UBER_REDIRECT_URI=http://localhost:8080/api/v1/auth/uber/callback
UBER_OAUTH_BASE_URL=https://login.uber.com
UBER_API_BASE_URL=https://api.uber.com
# SQLite database path
SQLITE_PATH=./data/uber_wrapper.db
# Shared API key for wrapper clients (optional but recommended)
WRAPPER_API_KEY=change-me

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
.env
data/
npm-debug.log*

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# Uber Wrapper (Node.js + Express + SQLite)
Generic multi-merchant Uber Eats API wrapper for POS integrations.
## What this gives you
- Multi-merchant model (each restaurant connects its own Uber account)
- OAuth flow support and manual token storage support
- Generic passthrough endpoint to cover all Uber APIs
- Business shortcuts for common menu/order/store flows
- Webhook ingestion endpoint
- SQLite persistence with adapter boundary for future MySQL/Postgres migration
- Swagger UI docs + Postman collection
## Quick Start
1. Install dependencies
```bash
npm install
```
2. Configure env
```bash
copy .env.example .env
```
3. Start server
```bash
npm run dev
```
4. Open docs
- Swagger UI: `http://localhost:8080/docs`
- Health: `http://localhost:8080/health`
## Folder Structure
- `src/` application source
- `src/db/adapter.js` database abstraction boundary
- `docs/developer-portal/` human-friendly docs
- `docs/openapi/` exported OpenAPI artifacts
- `postman/` Postman collection
## Database Migration Strategy
All controllers/services consume repositories through `src/db/adapter.js`.
To switch from SQLite to MySQL/Postgres later:
1. Implement repository methods for new DB backend.
2. Replace exports in `src/db/adapter.js`.
3. Keep controllers/routes unchanged.
## Scope (Current Phase)
Current implementation focus is **Uber Eats Marketplace APIs only**:
- Authentication (OAuth)
- Stores
- Menus
- Orders
- Marketplace webhooks
Uber Direct is intentionally deferred to a later phase.
## Important Note About Endpoint Coverage
This wrapper includes:
- A generic endpoint (`POST /api/v1/uber/request`) for all current/future **Uber Eats** endpoints.
- Pre-built shortcuts for high-frequency flows (menu/orders/store hours).
When you finalize your Uber docs copy, update `src/config/uberEndpoints.js` with exact endpoint paths from your approved Uber docs.

View File

@ -0,0 +1,20 @@
# Uber Wrapper Developer Portal
## Goal
Create one generic POS-facing wrapper that can integrate with Uber for many restaurants, each with its own Uber OAuth connection.
## Architecture Summary
1. Merchant is created in wrapper.
2. Merchant connects Uber account via OAuth.
3. Wrapper stores tokens and proxies requests to Uber APIs.
4. POS uses wrapper APIs only.
5. Uber webhooks are ingested by wrapper and routed to internal logic.
## Key Benefits
- Your POS is isolated from Uber API complexity.
- You can add custom business logic centrally.
- You can support many merchants with one standard integration path.

View File

@ -0,0 +1,31 @@
# API Groups
This file intentionally separates high-priority vs extended APIs for easier team onboarding.
## Group A (Important for POS Core)
- Merchant management
- OAuth connection flow
- Menu upsert/get
- Order pull/list
- Order actions (accept/deny/ready/cancel)
- Store hours updates
- Uber webhook ingestion
## Group B (Extended / Optional / Can Be Added Incrementally)
- Promotions
- Ads / sponsored listings
- Payout and financial reconciliation
- Store holiday/special schedules
- Catalog media sync
- Audit/event feed replay
## Group C (Generic Catch-All)
Use:
- `POST /api/v1/uber/request`
This endpoint is the fallback for any Uber endpoint that is not yet added as a typed shortcut route.

View File

@ -0,0 +1,9 @@
# 02 Auth OAuth
Focus: Uber Eats OAuth 2.0 per merchant (multi-client onboarding).
- Generate authorize URL by merchant
- Handle callback and store tokens
- Refresh token flow
- Token expiry handling policy

View File

@ -0,0 +1,52 @@
# Integration Flow
## 1. Create Merchant
`POST /api/v1/merchants`
```json
{
"name": "My Restaurant",
"externalRef": "pos_store_101"
}
```
## 2. Get OAuth URL
`GET /api/v1/auth/uber/authorize-url?merchantId=<merchant_id>`
Redirect merchant to returned URL.
## 3. OAuth Callback
Uber redirects to:
`GET /api/v1/auth/uber/callback?code=...&state=...`
Wrapper stores merchant token.
## 4. Push Menu
`POST /api/v1/uber/menu/upsert`
## 5. Pull Orders
`GET /api/v1/uber/orders?merchantId=...&storeId=...`
## 6. Receive Webhooks
`POST /api/v1/webhooks/uber`
## 7. Use Generic API for Any Missing Endpoint
`POST /api/v1/uber/request`
```json
{
"merchantId": "<merchant_id>",
"method": "POST",
"path": "/v1/eats/some/new/uber/endpoint",
"body": {}
}
```

View File

@ -0,0 +1,11 @@
# 03 Merchant Onboarding
Flow:
1. Create merchant in wrapper
2. Generate Uber OAuth URL
3. Merchant authorizes Uber account
4. Callback stores tokens + store identifiers
Multi-client principle: each merchant has isolated credentials and mappings.

View File

@ -0,0 +1,25 @@
# Extended API Catalog Template
Use this file to paste the full Uber documentation mappings that are not in core POS flows.
## How to document each endpoint
1. Wrapper route
2. Uber upstream route
3. HTTP method
4. Required merchant/store identifiers
5. Request payload schema
6. Response schema
7. Retry/idempotency behavior
8. Error mapping
## Suggested Sections
- Catalog and item metadata
- Option groups and modifier sets
- Promotions and pricing campaigns
- Financial, payouts, settlements
- Delivery fulfillment edge cases
- Store operational settings
- Reporting and analytics data pulls

View File

@ -0,0 +1,9 @@
# 04 Stores
Uber Eats store-focused integrations:
- Store details retrieval
- Store status
- Holiday/special hours
- Operational metadata sync

View File

@ -0,0 +1,9 @@
# 05 Menus
Menu sync between POS and Uber Eats:
- Upload/replace menu
- Fetch menu from Uber
- Item and modifier mapping strategy
- Validation and publish error handling

View File

@ -0,0 +1,10 @@
# 06 Orders
Order flow for POS:
- Receive order event
- Fetch full order payload
- Accept/deny
- Ready/handoff updates
- Completion/cancellation reconciliation

View File

@ -0,0 +1,11 @@
# 07 Webhooks
Webhook design goals:
- Multi-merchant event routing
- Idempotent processing
- Retry-safe handlers
- Event logging and replay support
Current ingestion endpoint: `POST /api/v1/webhooks/uber`

View File

@ -0,0 +1,8 @@
# 08 Delivery Status
Delivery-related updates consumed from Uber Eats order/webhook lifecycle:
- Courier assignment milestones
- Pickup and dropoff transitions
- Final completion state mapping

View File

@ -0,0 +1,9 @@
# 09 Errors Retries
Standards:
- Normalize Uber error responses
- Define retryable vs non-retryable errors
- Enforce idempotent writes where possible
- Capture request/response logs for debugging

View File

@ -0,0 +1,10 @@
# 10 Sandbox Testing
Checklist:
- Test OAuth connect flow
- Test menu upload/get
- Test order lifecycle actions
- Test webhook receipt and persistence
- Validate retry and duplicate event handling

View File

@ -0,0 +1,9 @@
# 11 Production Go Live
Go-live readiness:
- Production OAuth credentials configured
- Webhook URL reachable over HTTPS
- Alerting and log monitoring enabled
- Token refresh and failure runbooks ready

View File

@ -0,0 +1,9 @@
# 12 SDK Wrapper
Internal SDK goals:
- Stable wrapper methods for POS services
- Central auth/token handling
- Typed request/response contracts
- Consistent retries and error translation

View File

@ -0,0 +1,9 @@
# 13 POS Mapping Spec
Define canonical mappings:
- POS store ID <-> Uber store ID
- POS item/modifier IDs <-> Uber external IDs
- POS order status <-> Uber order state
- POS tax/fees fields <-> Uber charge lines

View File

@ -0,0 +1,9 @@
# 14 OpenAPI Swagger
Docs sources:
- Live docs: `/docs`
- Static export: `docs/openapi/openapi.json`
Update route annotations whenever endpoints are added/changed.

View File

@ -0,0 +1,12 @@
# 15 Postman
Collection file:
- `postman/Uber_Wrapper.postman_collection.json`
Keep environments for:
- local
- sandbox
- production

8
docs/openapi/README.md Normal file
View File

@ -0,0 +1,8 @@
# OpenAPI Export
Live OpenAPI spec is served from route annotations at runtime:
- `GET /docs`
If you need a static JSON export for CI or portal import, generate it from `src/docs/swagger.js` in your build script.

251
docs/openapi/openapi.json Normal file
View File

@ -0,0 +1,251 @@
{
"openapi": "3.0.3",
"info": {
"title": "Uber Wrapper API",
"version": "1.0.0",
"description": "Generic multi-merchant Uber API wrapper for POS platforms"
},
"servers": [
{
"url": "http://localhost:8080"
}
],
"components": {
"securitySchemes": {
"ApiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "x-api-key"
}
}
},
"security": [
{
"ApiKeyAuth": []
}
],
"paths": {
"/api/v1/auth/uber/authorize-url": {
"get": {
"summary": "Generate Uber OAuth authorize URL for merchant",
"tags": [
"Auth"
],
"parameters": [
{
"in": "query",
"name": "merchantId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OAuth URL generated"
}
}
}
},
"/api/v1/auth/uber/callback": {
"get": {
"summary": "Uber OAuth callback endpoint",
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Uber account connected"
}
}
}
},
"/api/v1/auth/uber/{merchantId}/refresh-token": {
"post": {
"summary": "Refresh Uber token for merchant",
"tags": [
"Auth"
],
"parameters": [
{
"in": "path",
"name": "merchantId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Token refreshed"
}
}
}
},
"/api/v1/merchants": {
"post": {
"summary": "Create or update merchant",
"tags": [
"Merchants"
],
"responses": {
"201": {
"description": "Merchant upserted"
}
}
},
"get": {
"summary": "List merchants",
"tags": [
"Merchants"
],
"responses": {
"200": {
"description": "Merchant list"
}
}
}
},
"/api/v1/connections/uber": {
"post": {
"summary": "Save manual Uber tokens for merchant",
"tags": [
"Connections"
],
"responses": {
"201": {
"description": "Connection stored"
}
}
},
"get": {
"summary": "List Uber connections",
"tags": [
"Connections"
],
"responses": {
"200": {
"description": "Connection list"
}
}
}
},
"/health": {
"get": {
"summary": "Health check",
"tags": [
"Health"
],
"responses": {
"200": {
"description": "Service is healthy"
}
}
}
},
"/api/v1/uber/request": {
"post": {
"summary": "Generic Uber passthrough for any Uber endpoint",
"tags": [
"Uber Generic"
],
"responses": {
"200": {
"description": "Uber response"
}
}
}
},
"/api/v1/uber/menu/upsert": {
"post": {
"summary": "Upsert store menu",
"tags": [
"Uber Menu"
],
"responses": {
"200": {
"description": "Menu upserted"
}
}
}
},
"/api/v1/uber/menu": {
"get": {
"summary": "Fetch store menu",
"tags": [
"Uber Menu"
],
"responses": {
"200": {
"description": "Menu fetched"
}
}
}
},
"/api/v1/uber/orders": {
"get": {
"summary": "List store orders",
"tags": [
"Uber Orders"
],
"responses": {
"200": {
"description": "Orders fetched"
}
}
}
},
"/api/v1/uber/orders/{orderId}/action": {
"post": {
"summary": "Trigger order action (accept, deny, ready, cancel)",
"tags": [
"Uber Orders"
],
"parameters": [
{
"in": "path",
"name": "orderId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Order action sent"
}
}
}
},
"/api/v1/uber/stores/hours": {
"put": {
"summary": "Update store hours",
"tags": [
"Uber Stores"
],
"responses": {
"200": {
"description": "Store hours updated"
}
}
}
},
"/api/v1/webhooks/uber": {
"post": {
"summary": "Ingest Uber webhook events",
"tags": [
"Webhooks"
],
"responses": {
"202": {
"description": "Webhook accepted"
}
}
}
}
},
"tags": []
}

View File

@ -0,0 +1,7 @@
# Optional Direct Delivery (Later)
This folder is intentionally separate from Uber Eats Marketplace scope.
Do not implement Uber Direct in current phase.
Add Uber Direct documentation and endpoints here only when Uber Eats scope is complete.

2209
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "uber_wrapper",
"version": "1.0.0",
"description": "Generic multi-merchant Uber API wrapper for POS integrations",
"main": "src/server.js",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js",
"openapi:export": "node scripts/export-openapi.js",
"test": "echo \"No tests configured yet\""
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"axios": "^1.14.0",
"better-sqlite3": "^12.8.0",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"helmet": "^8.1.0",
"morgan": "^1.10.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^13.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
}

View File

@ -0,0 +1,138 @@
{
"info": {
"_postman_id": "3f2bc407-ec9d-4eef-b77d-74359eb281fe",
"name": "Uber Wrapper",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Health",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/health",
"host": [
"{{baseUrl}}"
],
"path": [
"health"
]
}
}
},
{
"name": "Create Merchant",
"request": {
"method": "POST",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Demo Restaurant\",\n \"externalRef\": \"pos_001\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/merchants",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"merchants"
]
}
}
},
{
"name": "Get OAuth URL",
"request": {
"method": "GET",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/auth/uber/authorize-url?merchantId={{merchantId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"auth",
"uber",
"authorize-url"
],
"query": [
{
"key": "merchantId",
"value": "{{merchantId}}"
}
]
}
}
},
{
"name": "Generic Uber Request",
"request": {
"method": "POST",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"method\": \"GET\",\n \"path\": \"/v1/eats/stores/{{storeId}}/orders\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/uber/request",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"uber",
"request"
]
}
}
}
],
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:8080"
},
{
"key": "apiKey",
"value": "change-me"
},
{
"key": "merchantId",
"value": ""
},
{
"key": "storeId",
"value": ""
}
]
}

16
scripts/export-openapi.js Normal file
View File

@ -0,0 +1,16 @@
require("dotenv").config();
const fs = require("fs");
const path = require("path");
const spec = require("../src/docs/swagger");
const outputDir = path.resolve(process.cwd(), "docs", "openapi");
const outputFile = path.join(outputDir, "openapi.json");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputFile, JSON.stringify(spec, null, 2), "utf8");
// eslint-disable-next-line no-console
console.log(`OpenAPI exported to ${outputFile}`);

34
src/app.js Normal file
View File

@ -0,0 +1,34 @@
require("dotenv").config();
const express = require("express");
const helmet = require("helmet");
const cors = require("cors");
const morgan = require("morgan");
const swaggerUi = require("swagger-ui-express");
const spec = require("./docs/swagger");
const { requestContext, requireWrapperApiKey } = require("./middleware/requestContext");
const errorHandler = require("./middleware/errorHandler");
const healthRoutes = require("./routes/health.routes");
const authRoutes = require("./routes/auth.routes");
const connectionRoutes = require("./routes/connections.routes");
const proxyRoutes = require("./routes/proxy.routes");
const webhookRoutes = require("./routes/webhooks.routes");
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: "5mb" }));
app.use(morgan("combined"));
app.use(requestContext);
app.use("/health", healthRoutes);
app.use("/api/v1/auth", requireWrapperApiKey, authRoutes);
app.use("/api/v1", requireWrapperApiKey, connectionRoutes);
app.use("/api/v1", requireWrapperApiKey, proxyRoutes);
app.use("/api/v1", webhookRoutes);
app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec));
app.use(errorHandler);
module.exports = app;

27
src/config/env.js Normal file
View File

@ -0,0 +1,27 @@
const path = require("path");
const { z } = require("zod");
const envSchema = z.object({
NODE_ENV: z.string().default("development"),
PORT: z.coerce.number().default(8080),
UBER_CLIENT_ID: z.string().min(1),
UBER_CLIENT_SECRET: z.string().min(1),
UBER_REDIRECT_URI: z.string().url(),
UBER_OAUTH_BASE_URL: z.string().url().default("https://login.uber.com"),
UBER_API_BASE_URL: z.string().url().default("https://api.uber.com"),
SQLITE_PATH: z.string().default("./data/uber_wrapper.db"),
WRAPPER_API_KEY: z.string().optional()
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
throw new Error(`Invalid environment variables:\n${issues.join("\n")}`);
}
const env = parsed.data;
env.SQLITE_PATH = path.resolve(process.cwd(), env.SQLITE_PATH);
module.exports = env;

View File

@ -0,0 +1,23 @@
module.exports = {
menu: {
upsert: "/v1/eats/stores/{storeId}/menus",
get: "/v1/eats/stores/{storeId}/menus"
},
orders: {
list: "/v1/eats/stores/{storeId}/orders",
getById: "/v1/eats/orders/{orderId}",
accept: "/v1/eats/orders/{orderId}/accept_pos_order",
deny: "/v1/eats/orders/{orderId}/deny_pos_order",
readyForPickup: "/v1/eats/orders/{orderId}/pos_order_ready_for_pickup",
cancel: "/v1/eats/orders/{orderId}/cancel"
},
stores: {
getById: "/v1/eats/stores/{storeId}",
updateHours: "/v1/eats/stores/{storeId}/hours",
inventory: "/v1/eats/stores/{storeId}/inventory"
},
webhooks: {
events: "/v1/eats/stores/{storeId}/event_feed"
}
};

14
src/db/adapter.js Normal file
View File

@ -0,0 +1,14 @@
const repositories = require("./repositories");
/**
* Database adapter boundary.
* Replace exports here later with MySQL/Postgres implementations
* without changing route/controller code.
*/
module.exports = {
merchantRepository: repositories.merchantRepository,
uberConnectionRepository: repositories.uberConnectionRepository,
webhookRepository: repositories.webhookRepository,
apiLogRepository: repositories.apiLogRepository
};

160
src/db/repositories.js Normal file
View File

@ -0,0 +1,160 @@
const { v4: uuidv4 } = require("uuid");
const { db } = require("./sqlite");
function nowIso() {
return new Date().toISOString();
}
const merchantRepository = {
upsert({ id, name, externalRef }) {
const timestamp = nowIso();
const merchantId = id || uuidv4();
const stmt = db.prepare(`
INSERT INTO merchants (id, name, external_ref, created_at, updated_at)
VALUES (@id, @name, @external_ref, @created_at, @updated_at)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
external_ref = excluded.external_ref,
updated_at = excluded.updated_at
`);
stmt.run({
id: merchantId,
name,
external_ref: externalRef || null,
created_at: timestamp,
updated_at: timestamp
});
return this.findById(merchantId);
},
findById(id) {
return db.prepare("SELECT * FROM merchants WHERE id = ?").get(id);
},
list() {
return db.prepare("SELECT * FROM merchants ORDER BY created_at DESC").all();
}
};
const uberConnectionRepository = {
upsertByMerchantId(merchantId, payload) {
const existing = this.findByMerchantId(merchantId);
const timestamp = nowIso();
const row = {
id: existing?.id || uuidv4(),
merchant_id: merchantId,
uber_user_id: payload.uberUserId || existing?.uber_user_id || null,
uber_store_id: payload.uberStoreId || existing?.uber_store_id || null,
access_token: payload.accessToken || existing?.access_token,
refresh_token: payload.refreshToken ?? existing?.refresh_token ?? null,
token_type: payload.tokenType ?? existing?.token_type ?? "Bearer",
scope: payload.scope ?? existing?.scope ?? null,
expires_at: payload.expiresAt ?? existing?.expires_at ?? null,
status: payload.status ?? existing?.status ?? "active",
created_at: existing?.created_at || timestamp,
updated_at: timestamp
};
const stmt = db.prepare(`
INSERT INTO uber_connections (
id, merchant_id, uber_user_id, uber_store_id, access_token, refresh_token,
token_type, scope, expires_at, status, created_at, updated_at
)
VALUES (
@id, @merchant_id, @uber_user_id, @uber_store_id, @access_token, @refresh_token,
@token_type, @scope, @expires_at, @status, @created_at, @updated_at
)
ON CONFLICT(merchant_id) DO UPDATE SET
uber_user_id = excluded.uber_user_id,
uber_store_id = excluded.uber_store_id,
access_token = excluded.access_token,
refresh_token = excluded.refresh_token,
token_type = excluded.token_type,
scope = excluded.scope,
expires_at = excluded.expires_at,
status = excluded.status,
updated_at = excluded.updated_at
`);
stmt.run(row);
return this.findByMerchantId(merchantId);
},
findByMerchantId(merchantId) {
return db.prepare("SELECT * FROM uber_connections WHERE merchant_id = ?").get(merchantId);
},
list() {
return db
.prepare(`
SELECT uc.*, m.name AS merchant_name
FROM uber_connections uc
JOIN merchants m ON m.id = uc.merchant_id
ORDER BY uc.created_at DESC
`)
.all();
}
};
const webhookRepository = {
insert({ provider, merchantId, eventType, payloadJson, headersJson }) {
const row = {
id: uuidv4(),
provider,
merchant_id: merchantId || null,
event_type: eventType || null,
payload_json: JSON.stringify(payloadJson || {}),
headers_json: JSON.stringify(headersJson || {}),
received_at: nowIso(),
processing_status: "received"
};
const stmt = db.prepare(`
INSERT INTO webhook_events (
id, provider, merchant_id, event_type, payload_json, headers_json,
received_at, processing_status
)
VALUES (
@id, @provider, @merchant_id, @event_type, @payload_json, @headers_json,
@received_at, @processing_status
)
`);
stmt.run(row);
return row;
}
};
const apiLogRepository = {
insert({ merchantId, method, wrapperRoute, uberPath, responseStatus, requestBody, responseBody }) {
const row = {
id: uuidv4(),
merchant_id: merchantId || null,
http_method: method,
wrapper_route: wrapperRoute,
uber_path: uberPath,
response_status: responseStatus,
request_json: JSON.stringify(requestBody || {}),
response_json: JSON.stringify(responseBody || {}),
created_at: nowIso()
};
const stmt = db.prepare(`
INSERT INTO api_logs (
id, merchant_id, http_method, wrapper_route, uber_path,
response_status, request_json, response_json, created_at
)
VALUES (
@id, @merchant_id, @http_method, @wrapper_route, @uber_path,
@response_status, @request_json, @response_json, @created_at
)
`);
stmt.run(row);
return row;
}
};
module.exports = {
merchantRepository,
uberConnectionRepository,
webhookRepository,
apiLogRepository
};

75
src/db/sqlite.js Normal file
View File

@ -0,0 +1,75 @@
const fs = require("fs");
const path = require("path");
const Database = require("better-sqlite3");
const env = require("../config/env");
const dbDir = path.dirname(env.SQLITE_PATH);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new Database(env.SQLITE_PATH);
db.pragma("journal_mode = WAL");
function initSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS merchants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
external_ref TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS uber_connections (
id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL,
uber_user_id TEXT,
uber_store_id TEXT,
access_token TEXT NOT NULL,
refresh_token TEXT,
token_type TEXT,
scope TEXT,
expires_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_uber_connections_merchant
ON uber_connections(merchant_id);
CREATE TABLE IF NOT EXISTS webhook_events (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
merchant_id TEXT,
event_type TEXT,
payload_json TEXT NOT NULL,
headers_json TEXT,
received_at TEXT NOT NULL,
processed_at TEXT,
processing_status TEXT NOT NULL DEFAULT 'received',
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
);
CREATE TABLE IF NOT EXISTS api_logs (
id TEXT PRIMARY KEY,
merchant_id TEXT,
http_method TEXT NOT NULL,
wrapper_route TEXT NOT NULL,
uber_path TEXT NOT NULL,
response_status INTEGER,
request_json TEXT,
response_json TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(merchant_id) REFERENCES merchants(id)
);
`);
}
module.exports = {
db,
initSchema
};

32
src/docs/swagger.js Normal file
View File

@ -0,0 +1,32 @@
const swaggerJsdoc = require("swagger-jsdoc");
const env = require("../config/env");
const spec = swaggerJsdoc({
definition: {
openapi: "3.0.3",
info: {
title: "Uber Wrapper API",
version: "1.0.0",
description: "Generic multi-merchant Uber API wrapper for POS platforms"
},
servers: [
{
url: `http://localhost:${env.PORT}`
}
],
components: {
securitySchemes: {
ApiKeyAuth: {
type: "apiKey",
in: "header",
name: "x-api-key"
}
}
},
security: [{ ApiKeyAuth: [] }]
},
apis: ["./src/routes/*.js"]
});
module.exports = spec;

View File

@ -0,0 +1,10 @@
module.exports = function asyncHandler(fn) {
return async function wrapped(req, res, next) {
try {
await fn(req, res, next);
} catch (error) {
next(error);
}
};
};

View File

@ -0,0 +1,20 @@
module.exports = function errorHandler(err, req, res, next) {
const status = err.status || 500;
const payload = {
success: false,
message: err.message || "Internal server error",
requestId: req.requestId
};
if (process.env.NODE_ENV !== "production") {
payload.stack = err.stack;
}
if (status >= 500) {
// eslint-disable-next-line no-console
console.error(err);
}
res.status(status).json(payload);
};

View File

@ -0,0 +1,30 @@
const { v4: uuidv4 } = require("uuid");
const env = require("../config/env");
function requestContext(req, res, next) {
req.requestId = req.headers["x-request-id"] || uuidv4();
res.setHeader("x-request-id", req.requestId);
next();
}
function requireWrapperApiKey(req, res, next) {
if (!env.WRAPPER_API_KEY) {
return next();
}
const apiKey = req.headers["x-api-key"];
if (apiKey !== env.WRAPPER_API_KEY) {
return res.status(401).json({
success: false,
message: "Invalid API key"
});
}
return next();
}
module.exports = {
requestContext,
requireWrapperApiKey
};

View File

@ -0,0 +1,92 @@
const { merchantRepository, uberConnectionRepository } = require("../../db/adapter");
const { buildAuthorizeUrl, exchangeCodeForToken, refreshToken } = require("./auth.service");
function parseExpiresAt(expiresInSeconds) {
if (!expiresInSeconds) {
return null;
}
return new Date(Date.now() + Number(expiresInSeconds) * 1000).toISOString();
}
async function getAuthorizeUrl(req, res) {
const { merchantId, scope } = req.query;
if (!merchantId) {
return res.status(400).json({ success: false, message: "merchantId query param is required" });
}
const merchant = merchantRepository.findById(merchantId);
if (!merchant) {
return res.status(404).json({ success: false, message: "Merchant not found" });
}
const state = Buffer.from(JSON.stringify({ merchantId })).toString("base64url");
const url = buildAuthorizeUrl({ state, scope });
return res.json({ success: true, data: { url } });
}
async function oauthCallback(req, res) {
const { code, state } = req.query;
if (!code || !state) {
return res.status(400).json({ success: false, message: "code and state are required" });
}
const decodedState = JSON.parse(Buffer.from(state, "base64url").toString("utf8"));
const merchant = merchantRepository.findById(decodedState.merchantId);
if (!merchant) {
return res.status(404).json({ success: false, message: "Merchant not found from state" });
}
const tokenData = await exchangeCodeForToken(code);
const connection = uberConnectionRepository.upsertByMerchantId(merchant.id, {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
tokenType: tokenData.token_type || "Bearer",
scope: tokenData.scope || null,
expiresAt: parseExpiresAt(tokenData.expires_in),
status: "active"
});
return res.json({
success: true,
message: "Uber account connected successfully",
data: {
merchantId: merchant.id,
connectionId: connection.id
}
});
}
async function refreshMerchantToken(req, res) {
const { merchantId } = req.params;
const connection = uberConnectionRepository.findByMerchantId(merchantId);
if (!connection) {
return res.status(404).json({ success: false, message: "Uber connection not found for merchant" });
}
if (!connection.refresh_token) {
return res.status(400).json({ success: false, message: "Refresh token not available for merchant" });
}
const tokenData = await refreshToken(connection.refresh_token);
const updated = uberConnectionRepository.upsertByMerchantId(merchantId, {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || connection.refresh_token,
tokenType: tokenData.token_type || "Bearer",
scope: tokenData.scope || connection.scope,
expiresAt: parseExpiresAt(tokenData.expires_in),
status: "active"
});
return res.json({
success: true,
message: "Token refreshed",
data: updated
});
}
module.exports = {
getAuthorizeUrl,
oauthCallback,
refreshMerchantToken
};

View File

@ -0,0 +1,61 @@
const axios = require("axios");
const env = require("../../config/env");
const uberAuthClient = axios.create({
baseURL: env.UBER_OAUTH_BASE_URL,
timeout: 20000
});
function buildAuthorizeUrl({ state, scope = "eats.store eats.order" }) {
const params = new URLSearchParams({
client_id: env.UBER_CLIENT_ID,
response_type: "code",
redirect_uri: env.UBER_REDIRECT_URI,
scope,
state
});
return `${env.UBER_OAUTH_BASE_URL}/oauth/v2/authorize?${params.toString()}`;
}
async function exchangeCodeForToken(code) {
const payload = new URLSearchParams({
client_id: env.UBER_CLIENT_ID,
client_secret: env.UBER_CLIENT_SECRET,
grant_type: "authorization_code",
redirect_uri: env.UBER_REDIRECT_URI,
code
});
const { data } = await uberAuthClient.post("/oauth/v2/token", payload.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
});
return data;
}
async function refreshToken(refreshToken) {
const payload = new URLSearchParams({
client_id: env.UBER_CLIENT_ID,
client_secret: env.UBER_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: refreshToken
});
const { data } = await uberAuthClient.post("/oauth/v2/token", payload.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
});
return data;
}
module.exports = {
buildAuthorizeUrl,
exchangeCodeForToken,
refreshToken
};

View File

@ -0,0 +1,54 @@
const { z } = require("zod");
const { merchantRepository, uberConnectionRepository } = require("../../db/adapter");
const merchantSchema = z.object({
id: z.string().optional(),
name: z.string().min(1),
externalRef: z.string().optional()
});
const manualConnectionSchema = z.object({
merchantId: z.string().min(1),
uberUserId: z.string().optional(),
uberStoreId: z.string().optional(),
accessToken: z.string().min(1),
refreshToken: z.string().optional(),
tokenType: z.string().optional(),
scope: z.string().optional(),
expiresAt: z.string().datetime().optional()
});
async function upsertMerchant(req, res) {
const payload = merchantSchema.parse(req.body);
const merchant = merchantRepository.upsert(payload);
return res.status(201).json({ success: true, data: merchant });
}
async function listMerchants(req, res) {
const merchants = merchantRepository.list();
return res.json({ success: true, data: merchants });
}
async function upsertManualConnection(req, res) {
const payload = manualConnectionSchema.parse(req.body);
const merchant = merchantRepository.findById(payload.merchantId);
if (!merchant) {
return res.status(404).json({ success: false, message: "Merchant not found" });
}
const connection = uberConnectionRepository.upsertByMerchantId(payload.merchantId, payload);
return res.status(201).json({ success: true, data: connection });
}
async function listConnections(req, res) {
const rows = uberConnectionRepository.list();
return res.json({ success: true, data: rows });
}
module.exports = {
upsertMerchant,
listMerchants,
upsertManualConnection,
listConnections
};

View File

@ -0,0 +1,15 @@
async function healthCheck(req, res) {
return res.json({
success: true,
data: {
service: "uber-wrapper",
status: "ok",
timestamp: new Date().toISOString()
}
});
}
module.exports = {
healthCheck
};

View File

@ -0,0 +1,97 @@
const { z } = require("zod");
const proxyService = require("./proxy.service");
const genericSchema = z.object({
merchantId: z.string().min(1),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("GET"),
path: z.string().min(1),
query: z.record(z.string(), z.any()).optional(),
body: z.any().optional()
});
async function genericProxy(req, res) {
const payload = genericSchema.parse(req.body);
const data = await proxyService.genericProxy(payload);
return res.json({ success: true, data });
}
async function upsertMenu(req, res) {
const schema = z.object({
merchantId: z.string().min(1),
storeId: z.string().min(1),
menu: z.any()
});
const payload = schema.parse(req.body);
const data = await proxyService.menuUpsert({
merchantId: payload.merchantId,
storeId: payload.storeId,
payload: payload.menu
});
return res.json({ success: true, data });
}
async function getMenu(req, res) {
const schema = z.object({
merchantId: z.string().min(1),
storeId: z.string().min(1)
});
const payload = schema.parse(req.query);
const data = await proxyService.menuGet(payload);
return res.json({ success: true, data });
}
async function listOrders(req, res) {
const schema = z.object({
merchantId: z.string().min(1),
storeId: z.string().min(1)
});
const payload = schema.parse(req.query);
const data = await proxyService.ordersList({
merchantId: payload.merchantId,
storeId: payload.storeId,
query: req.query
});
return res.json({ success: true, data });
}
async function orderAction(req, res) {
const schema = z.object({
merchantId: z.string().min(1),
action: z.enum(["accept", "deny", "ready", "cancel"]),
payload: z.any().optional()
});
const parsed = schema.parse(req.body);
const data = await proxyService.orderAction({
merchantId: parsed.merchantId,
orderId: req.params.orderId,
action: parsed.action,
payload: parsed.payload
});
return res.json({ success: true, data });
}
async function updateHours(req, res) {
const schema = z.object({
merchantId: z.string().min(1),
storeId: z.string().min(1),
hours: z.any()
});
const payload = schema.parse(req.body);
const data = await proxyService.updateStoreHours({
merchantId: payload.merchantId,
storeId: payload.storeId,
payload: payload.hours
});
return res.json({ success: true, data });
}
module.exports = {
genericProxy,
upsertMenu,
getMenu,
listOrders,
orderAction,
updateHours
};

View File

@ -0,0 +1,161 @@
const axios = require("axios");
const env = require("../../config/env");
const uberEndpoints = require("../../config/uberEndpoints");
const { uberConnectionRepository, apiLogRepository } = require("../../db/adapter");
const uberApiClient = axios.create({
baseURL: env.UBER_API_BASE_URL,
timeout: 30000
});
function interpolatePath(pathTemplate, params = {}) {
let output = pathTemplate;
Object.entries(params).forEach(([key, value]) => {
output = output.replaceAll(`{${key}}`, encodeURIComponent(value));
});
return output;
}
function buildAuthHeader(connection) {
return `${connection.token_type || "Bearer"} ${connection.access_token}`;
}
async function callUberApi({ merchantId, method, uberPath, query, body, wrapperRoute }) {
const connection = uberConnectionRepository.findByMerchantId(merchantId);
if (!connection || connection.status !== "active") {
const error = new Error("Active Uber connection not found for merchant");
error.status = 404;
throw error;
}
try {
const response = await uberApiClient.request({
method,
url: uberPath,
params: query,
data: body,
headers: {
Authorization: buildAuthHeader(connection),
"Content-Type": "application/json"
}
});
apiLogRepository.insert({
merchantId,
method,
wrapperRoute,
uberPath,
responseStatus: response.status,
requestBody: body,
responseBody: response.data
});
return response.data;
} catch (error) {
const status = error.response?.status || 500;
const responseBody = error.response?.data || { message: error.message };
apiLogRepository.insert({
merchantId,
method,
wrapperRoute,
uberPath,
responseStatus: status,
requestBody: body,
responseBody
});
const wrapped = new Error(`Uber API request failed: ${status}`);
wrapped.status = status;
wrapped.details = responseBody;
throw wrapped;
}
}
async function genericProxy({ merchantId, method, path, query, body }) {
return callUberApi({
merchantId,
method,
uberPath: path,
query,
body,
wrapperRoute: "/api/v1/uber/request"
});
}
async function menuUpsert({ merchantId, storeId, payload }) {
const uberPath = interpolatePath(uberEndpoints.menu.upsert, { storeId });
return callUberApi({
merchantId,
method: "POST",
uberPath,
body: payload,
wrapperRoute: "/api/v1/uber/menu/upsert"
});
}
async function menuGet({ merchantId, storeId }) {
const uberPath = interpolatePath(uberEndpoints.menu.get, { storeId });
return callUberApi({
merchantId,
method: "GET",
uberPath,
wrapperRoute: "/api/v1/uber/menu"
});
}
async function ordersList({ merchantId, storeId, query }) {
const uberPath = interpolatePath(uberEndpoints.orders.list, { storeId });
return callUberApi({
merchantId,
method: "GET",
uberPath,
query,
wrapperRoute: "/api/v1/uber/orders"
});
}
async function orderAction({ merchantId, orderId, action, payload }) {
const routeMap = {
accept: uberEndpoints.orders.accept,
deny: uberEndpoints.orders.deny,
ready: uberEndpoints.orders.readyForPickup,
cancel: uberEndpoints.orders.cancel
};
const template = routeMap[action];
if (!template) {
const error = new Error("Unsupported order action");
error.status = 400;
throw error;
}
const uberPath = interpolatePath(template, { orderId });
return callUberApi({
merchantId,
method: "POST",
uberPath,
body: payload || {},
wrapperRoute: "/api/v1/uber/orders/:orderId/action"
});
}
async function updateStoreHours({ merchantId, storeId, payload }) {
const uberPath = interpolatePath(uberEndpoints.stores.updateHours, { storeId });
return callUberApi({
merchantId,
method: "PUT",
uberPath,
body: payload,
wrapperRoute: "/api/v1/uber/stores/hours"
});
}
module.exports = {
genericProxy,
menuUpsert,
menuGet,
ordersList,
orderAction,
updateStoreHours
};

View File

@ -0,0 +1,25 @@
const { webhookRepository } = require("../../db/adapter");
async function handleUberWebhook(req, res) {
const merchantId = req.query.merchantId || req.body?.merchant_id || null;
const eventType = req.body?.event_type || req.body?.type || "unknown";
const row = webhookRepository.insert({
provider: "uber",
merchantId,
eventType,
payloadJson: req.body,
headersJson: req.headers
});
return res.status(202).json({
success: true,
message: "Webhook received",
data: { webhookEventId: row.id }
});
}
module.exports = {
handleUberWebhook
};

59
src/routes/auth.routes.js Normal file
View File

@ -0,0 +1,59 @@
const express = require("express");
const asyncHandler = require("../middleware/asyncHandler");
const controller = require("../modules/auth/auth.controller");
const router = express.Router();
/**
* @openapi
* /api/v1/auth/uber/authorize-url:
* get:
* summary: Generate Uber OAuth authorize URL for merchant
* tags:
* - Auth
* parameters:
* - in: query
* name: merchantId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OAuth URL generated
*/
router.get("/uber/authorize-url", asyncHandler(controller.getAuthorizeUrl));
/**
* @openapi
* /api/v1/auth/uber/callback:
* get:
* summary: Uber OAuth callback endpoint
* tags:
* - Auth
* responses:
* 200:
* description: Uber account connected
*/
router.get("/uber/callback", asyncHandler(controller.oauthCallback));
/**
* @openapi
* /api/v1/auth/uber/{merchantId}/refresh-token:
* post:
* summary: Refresh Uber token for merchant
* tags:
* - Auth
* parameters:
* - in: path
* name: merchantId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Token refreshed
*/
router.post("/uber/:merchantId/refresh-token", asyncHandler(controller.refreshMerchantToken));
module.exports = router;

View File

@ -0,0 +1,50 @@
const express = require("express");
const asyncHandler = require("../middleware/asyncHandler");
const controller = require("../modules/connections/connections.controller");
const router = express.Router();
/**
* @openapi
* /api/v1/merchants:
* post:
* summary: Create or update merchant
* tags:
* - Merchants
* responses:
* 201:
* description: Merchant upserted
* get:
* summary: List merchants
* tags:
* - Merchants
* responses:
* 200:
* description: Merchant list
*/
router.post("/merchants", asyncHandler(controller.upsertMerchant));
router.get("/merchants", asyncHandler(controller.listMerchants));
/**
* @openapi
* /api/v1/connections/uber:
* post:
* summary: Save manual Uber tokens for merchant
* tags:
* - Connections
* responses:
* 201:
* description: Connection stored
* get:
* summary: List Uber connections
* tags:
* - Connections
* responses:
* 200:
* description: Connection list
*/
router.post("/connections/uber", asyncHandler(controller.upsertManualConnection));
router.get("/connections/uber", asyncHandler(controller.listConnections));
module.exports = router;

View File

@ -0,0 +1,21 @@
const express = require("express");
const asyncHandler = require("../middleware/asyncHandler");
const { healthCheck } = require("../modules/health/health.controller");
const router = express.Router();
/**
* @openapi
* /health:
* get:
* summary: Health check
* tags:
* - Health
* responses:
* 200:
* description: Service is healthy
*/
router.get("/", asyncHandler(healthCheck));
module.exports = router;

View File

@ -0,0 +1,92 @@
const express = require("express");
const asyncHandler = require("../middleware/asyncHandler");
const controller = require("../modules/proxy/proxy.controller");
const router = express.Router();
/**
* @openapi
* /api/v1/uber/request:
* post:
* summary: Generic Uber passthrough for any Uber endpoint
* tags:
* - Uber Generic
* responses:
* 200:
* description: Uber response
*/
router.post("/uber/request", asyncHandler(controller.genericProxy));
/**
* @openapi
* /api/v1/uber/menu/upsert:
* post:
* summary: Upsert store menu
* tags:
* - Uber Menu
* responses:
* 200:
* description: Menu upserted
*/
router.post("/uber/menu/upsert", asyncHandler(controller.upsertMenu));
/**
* @openapi
* /api/v1/uber/menu:
* get:
* summary: Fetch store menu
* tags:
* - Uber Menu
* responses:
* 200:
* description: Menu fetched
*/
router.get("/uber/menu", asyncHandler(controller.getMenu));
/**
* @openapi
* /api/v1/uber/orders:
* get:
* summary: List store orders
* tags:
* - Uber Orders
* responses:
* 200:
* description: Orders fetched
*/
router.get("/uber/orders", asyncHandler(controller.listOrders));
/**
* @openapi
* /api/v1/uber/orders/{orderId}/action:
* post:
* summary: Trigger order action (accept, deny, ready, cancel)
* tags:
* - Uber Orders
* parameters:
* - in: path
* name: orderId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Order action sent
*/
router.post("/uber/orders/:orderId/action", asyncHandler(controller.orderAction));
/**
* @openapi
* /api/v1/uber/stores/hours:
* put:
* summary: Update store hours
* tags:
* - Uber Stores
* responses:
* 200:
* description: Store hours updated
*/
router.put("/uber/stores/hours", asyncHandler(controller.updateHours));
module.exports = router;

View File

@ -0,0 +1,21 @@
const express = require("express");
const asyncHandler = require("../middleware/asyncHandler");
const { handleUberWebhook } = require("../modules/webhooks/webhooks.controller");
const router = express.Router();
/**
* @openapi
* /api/v1/webhooks/uber:
* post:
* summary: Ingest Uber webhook events
* tags:
* - Webhooks
* responses:
* 202:
* description: Webhook accepted
*/
router.post("/webhooks/uber", asyncHandler(handleUberWebhook));
module.exports = router;

11
src/server.js Normal file
View File

@ -0,0 +1,11 @@
const app = require("./app");
const env = require("./config/env");
const { initSchema } = require("./db/sqlite");
initSchema();
app.listen(env.PORT, () => {
// eslint-disable-next-line no-console
console.log(`Uber Wrapper listening on http://localhost:${env.PORT}`);
});