diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 0000000..969a527 --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,42 @@ +name: OpenAPI Validation + +on: + push: + branches: [ main ] + pull_request: + +jobs: + validate-openapi: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm --filter "./packages/**" run build + + - name: Generate Prisma Client + run: pnpm --filter access-api run prisma:generate + + - name: Generate OpenAPI spec + run: pnpm --filter access-api run openapi:generate + + - name: Verify no changes + run: | + if [[ -n $(git status --porcelain docs/openapi.json) ]]; then + echo "Error: docs/openapi.json is out of date. Run 'pnpm --filter access-api run openapi:generate' (or npm run openapi:generate in apps/access-api) and commit the changes." + git diff docs/openapi.json + exit 1 + fi diff --git a/README.md b/README.md index 248047f..f701c2f 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,23 @@ Responses include `allowed`/`denied` plus human-readable and machine-readable re --- +## OpenAPI Specification + +A stable, machine-readable OpenAPI specification is generated for all public API routes to support SDKs and integrations. + +- **Specification File:** [docs/openapi.json](./docs/openapi.json) + +**For Contributors:** +When adding or modifying routes in the Access API, you must update the checked-in specification. Run the following command from the root of the repository: + +```bash +npm run -w access-api openapi:generate +``` + +CI will automatically verify that the OpenAPI specification is up-to-date with your code changes. + +--- + ## Data Model Prisma schema includes: `communities`, `wallets`, `members`, `memberships`, `roles`, `access policies`, `profiles`, `badges` (placeholder). diff --git a/apps/access-api/package.json b/apps/access-api/package.json index b016f32..aabc770 100644 --- a/apps/access-api/package.json +++ b/apps/access-api/package.json @@ -10,6 +10,7 @@ "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint 'src/**/*.ts'", + "openapi:generate": "ts-node scripts/generate-openapi.ts", "prisma:generate": "prisma generate", "prisma:validate": "prisma validate", "prisma:migrate": "prisma migrate dev", diff --git a/apps/access-api/prisma/migrations/20260615000000_init/migration.sql b/apps/access-api/prisma/migrations/20260615000000_init/migration.sql new file mode 100644 index 0000000..a0b6351 --- /dev/null +++ b/apps/access-api/prisma/migrations/20260615000000_init/migration.sql @@ -0,0 +1,122 @@ +-- CreateEnum +CREATE TYPE "MembershipState" AS ENUM ('invited', 'active', 'expired', 'suspended'); + +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('admin', 'member', 'contributor'); + +-- CreateEnum +CREATE TYPE "RoleSource" AS ENUM ('manual', 'auto'); + +-- CreateTable +CREATE TABLE "Community" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Community_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Wallet" ( + "id" TEXT NOT NULL, + "address" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Wallet_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Profile" ( + "id" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "bio" TEXT, + + CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Member" ( + "id" TEXT NOT NULL, + "communityId" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "profileId" TEXT, + + CONSTRAINT "Member_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Membership" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "state" "MembershipState" NOT NULL, + "expiresAt" TIMESTAMP(3), + "renewedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Membership_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RoleAssignment" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "role" "Role" NOT NULL, + "source" "RoleSource" NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RoleAssignment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AccessPolicy" ( + "id" TEXT NOT NULL, + "communityId" TEXT NOT NULL, + "resource" TEXT NOT NULL, + "rule" TEXT NOT NULL, + + CONSTRAINT "AccessPolicy_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Badge" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Badge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Wallet_address_key" ON "Wallet"("address"); + +-- CreateIndex +CREATE UNIQUE INDEX "Member_communityId_walletId_key" ON "Member"("communityId", "walletId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Membership_memberId_key" ON "Membership"("memberId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccessPolicy_communityId_resource_key" ON "AccessPolicy"("communityId", "resource"); + +-- AddForeignKey +ALTER TABLE "Member" ADD CONSTRAINT "Member_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Member" ADD CONSTRAINT "Member_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Member" ADD CONSTRAINT "Member_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoleAssignment" ADD CONSTRAINT "RoleAssignment_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccessPolicy" ADD CONSTRAINT "AccessPolicy_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Badge" ADD CONSTRAINT "Badge_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/apps/access-api/scripts/generate-openapi.ts b/apps/access-api/scripts/generate-openapi.ts new file mode 100644 index 0000000..93495d4 --- /dev/null +++ b/apps/access-api/scripts/generate-openapi.ts @@ -0,0 +1,28 @@ +import fs from 'fs'; +import path from 'path'; +import { buildApp } from '../src/app'; + +async function main() { + const app = await buildApp(); + await app.ready(); + + const openapiSpec = app.swagger(); + + // Write to repository root docs folder + const outPath = path.join(__dirname, '../../../docs/openapi.json'); + const outDir = path.dirname(outPath); + + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + fs.writeFileSync(outPath, JSON.stringify(openapiSpec, null, 2)); + console.log(`OpenAPI specification successfully written to ${outPath}`); + + await app.close(); +} + +main().catch((err) => { + console.error('Failed to generate OpenAPI specification:', err); + process.exit(1); +}); diff --git a/apps/access-api/src/routes.ts b/apps/access-api/src/routes.ts index bf9bc79..d471565 100644 --- a/apps/access-api/src/routes.ts +++ b/apps/access-api/src/routes.ts @@ -31,7 +31,7 @@ export async function registerRoutes(app: FastifyInstance): Promise { // POST /v1/access/check — check access for wallet/resource app.post('/v1/access/check', async (request, reply) => { const body = request.body as { - wallet: string; + wallet: `0x${string}`; communityId: string; resource: string; }; diff --git a/apps/access-api/test/routes.integration.test.ts b/apps/access-api/test/routes.integration.test.ts index 092211d..943d555 100644 --- a/apps/access-api/test/routes.integration.test.ts +++ b/apps/access-api/test/routes.integration.test.ts @@ -30,13 +30,13 @@ async function buildTestApp(mockService: ReturnType { - const { wallet } = request.params as { wallet: string }; + app.get('/v1/communities/:communityId/memberships/:wallet', async (request) => { + const { wallet } = request.params as { communityId: string; wallet: string }; return mockService.getMembershipsByWallet(wallet); }); - app.get('/v1/members/:wallet', async (request, reply) => { - const { wallet } = request.params as { wallet: string }; + app.get('/v1/communities/:communityId/members/:wallet', async (request, reply) => { + const { wallet } = request.params as { communityId: string; wallet: string }; const result = await mockService.getProfileByWallet(wallet); if (!result) { return reply.status(404).send({ error: 'Member not found' }); @@ -122,7 +122,7 @@ describe('GET /v1/memberships/:wallet', () => { const response = await app.inject({ method: 'GET', - url: '/v1/memberships/0x0000000000000000000000000000000000000000', + url: '/v1/communities/community-1/memberships/0x0000000000000000000000000000000000000000', }); expect(response.statusCode).toBe(200); @@ -161,7 +161,7 @@ describe('GET /v1/members/:wallet', () => { const response = await app.inject({ method: 'GET', - url: '/v1/members/0x0000000000000000000000000000000000000000', + url: '/v1/communities/community-1/members/0x0000000000000000000000000000000000000000', }); expect(response.statusCode).toBe(404); diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 0000000..f191e19 --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,190 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "GuildPass Access API", + "description": "MVP API for wallet membership and access checks", + "version": "0.1.0" + }, + "components": { + "schemas": {} + }, + "paths": { + "/metrics": { + "get": { + "summary": "Prometheus metrics scrape endpoint", + "tags": [ + "Observability" + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/health/live": { + "get": { + "summary": "Liveness probe – is the process alive?", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "version": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/health/ready": { + "get": { + "summary": "Readiness probe – can we serve traffic?", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "db": { + "type": "string" + } + } + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "db": { + "type": "string" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/communities/{communityId}/memberships/{wallet}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "communityId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "wallet", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/v1/communities/{communityId}/members/{wallet}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "communityId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "wallet", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/v1/access/check": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/v1/communities/{communityId}/members": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "communityId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + } + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ] +} \ No newline at end of file diff --git a/packages/shared-types/src/apiContract.ts b/packages/shared-types/src/apiContract.ts index 8e3c326..0b4b43d 100644 --- a/packages/shared-types/src/apiContract.ts +++ b/packages/shared-types/src/apiContract.ts @@ -1,8 +1,8 @@ export const API_CONTRACT = { membershipsByWallet: { method: 'GET', - pathTemplate: '/v1/memberships/:wallet', - samplePath: '/v1/memberships/0x1234567890abcdef1234567890abcdef12345678', + pathTemplate: '/v1/communities/:communityId/memberships/:wallet', + samplePath: '/v1/communities/community-1/memberships/0x1234567890abcdef1234567890abcdef12345678', successStatus: 200, successResponse: { wallet: '0x1234567890abcdef1234567890abcdef12345678', @@ -13,8 +13,8 @@ export const API_CONTRACT = { }, memberProfileByWallet: { method: 'GET', - pathTemplate: '/v1/members/:wallet', - samplePath: '/v1/members/0x1234567890abcdef1234567890abcdef12345678', + pathTemplate: '/v1/communities/:communityId/members/:wallet', + samplePath: '/v1/communities/community-1/members/0x1234567890abcdef1234567890abcdef12345678', successStatus: 200, successResponse: { communityId: 'community-1',