可是我使用repo api. 仍然還要加入open. Aip的設計嗎?下面提供一套可直接放入 myapp/repo 的「完整範例檔」與對應的最小可執行數據集,以及前端與 Gateway 的單元測試樣板。目標:clone 後不需後端即可用 /mock 與 /repo 跑起來,並在 CI 驗證 artifact 正確性。
# 目錄結構
```
repo/
├─ api/
│ ├─ index.json
│ ├─ users.get.json
│ ├─ users.id.get.json
│ └─ users.post.json
├─ contracts/
│ ├─ common.yaml
│ └─ users.yaml
├─ data/
│ ├─ users.list.v1.json
│ ├─ users.detail.u_1.v1.json
│ └─ seed.users.v1.json
├─ flows/
│ └─ users.flow.yaml
└─ validators/
├─ zod.ts
└─ ajv.ts
```
---
## repo/api/index.json
```json
{
"version": "1.0.0",
"endpoints": {
"GET /v1/users": "users.get.json",
"GET /v1/users/{id}": "users.id.get.json",
"POST /v1/users": "users.post.json"
}
}
```
## repo/api/users.get.json
```json
{
"method": "GET",
"path": "/v1/users",
"request": {
"query": {
"page": { "type": "integer", "default": 1, "minimum": 1 },
"size": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
"q": { "type": "string", "optional": true }
}
},
"response": {
"status": 200,
"body": { "$ref": "../data/users.list.v1.json" }
},
"errors": {
"429": { "message": "too many requests" }
},
"meta": {
"contract": "../contracts/users.yaml#list",
"rbac": "read"
}
}
```
## repo/api/users.id.get.json
```json
{
"method": "GET",
"path": "/v1/users/{id}",
"request": {
"params": {
"id": { "type": "string", "pattern": "^u_\\w+" }
}
},
"response": {
"status": 200,
"body": { "$ref": "../data/users.detail.u_1.v1.json" }
},
"errors": {
"404": { "message": "user not found" }
},
"meta": {
"contract": "../contracts/users.yaml#getById",
"rbac": "read"
}
}
```
## repo/api/users.post.json
```json
{
"method": "POST",
"path": "/v1/users",
"request": {
"headers": {
"content-type": { "const": "application/json" }
},
"body": {
"type": "object",
"required": ["email", "name"],
"properties": {
"email": { "type": "string", "format": "email" },
"name": { "type": "string", "minLength": 1 }
}
}
},
"response": {
"status": 201,
"body": {
"id": "u_seed_new",
"email": "new@example.com",
"name": "New User"
}
},
"errors": {
"400": { "message": "invalid body" },
"409": { "message": "email already exists" }
},
"meta": {
"contract": "../contracts/users.yaml#create",
"rbac": "write",
"idStrategy": "uuid"
}
}
```
---
## repo/contracts/common.yaml
```yaml
version: 1
name: common
components:
schemas:
ErrorResponse:
type: object
required: [error]
properties:
error:
type: string
PageMeta:
type: object
required: [page, size, total]
properties:
page: { type: integer, minimum: 1 }
size: { type: integer, minimum: 1, maximum: 100 }
total: { type: integer, minimum: 0 }
security:
rbac:
roles:
- admin
- user
rules:
read:
anyOf:
- role: admin
- role: user
write:
anyOf:
- role: admin
```
## repo/contracts/users.yaml
```yaml
version: 1
name: users
components:
schemas:
User:
type: object
required: [id, email, name]
properties:
id: { type: string }
email: { type: string, format: email }
name: { type: string }
CreateUserRequest:
type: object
required: [email, name]
properties:
email: { type: string, format: email }
name: { type: string, minLength: 1 }
UsersListResponse:
type: object
required: [items, total]
properties:
items:
type: array
items:
$ref: '#/components/schemas/User'
total: { type: integer, minimum: 0 }
endpoints:
list:
method: GET
path: /v1/users
query:
page: { type: integer, minimum: 1 }
size: { type: integer, minimum: 1, maximum: 100 }
q: { type: string }
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/UsersListResponse'
rbac: read
getById:
method: GET
path: /v1/users/{id}
params:
id: { type: string }
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/User'
"404":
content:
application/json:
schema:
$ref: '../common.yaml#/components/schemas/ErrorResponse'
rbac: read
create:
method: POST
path: /v1/users
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/User'
"400":
content:
application/json:
schema:
$ref: '../common.yaml#/components/schemas/ErrorResponse'
"409":
content:
application/json:
schema:
$ref: '../common.yaml#/components/schemas/ErrorResponse'
rbac: write
deprecations:
- note: "v1 list will move to cursor pagination in v2"
```
---
## repo/data/users.list.v1.json
```json
{
"items": [
{ "id": "u_1", "email": "a@example.com", "name": "Alice" },
{ "id": "u_2", "email": "b@example.com", "name": "Bob" }
],
"total": 2
}
```
## repo/data/users.detail.u_1.v1.json
```json
{
"id": "u_1",
"email": "a@example.com",
"name": "Alice"
}
```
## repo/data/seed.users.v1.json
```json
[
{ "id": "u_1", "email": "a@example.com", "name": "Alice" },
{ "id": "u_2", "email": "b@example.com", "name": "Bob" }
]
```
---
## repo/flows/users.flow.yaml(完整流程示例)
```yaml
usecase: "users module"
shared:
datasets:
users: "../data/seed.users.v1.json"
validators:
listReq: "contracts.users.endpoints.list.query"
listRes: "contracts.users.components.schemas.UsersListResponse"
userRes: "contracts.users.components.schemas.User"
rbac:
read: "contracts.common.security.rbac.rules.read"
write: "contracts.common.security.rbac.rules.write"
flows:
list:
route: "GET /v1/users"
steps:
- rbac:
require: read # 依 common.yaml 的 rbac 規則,檢查 x-role
- validate:
target: listReq # 驗證 query 參數
- query:
from: users # 使用 datasets.users 資料
op: filterAndPaginate
args:
page: "$request.query.page"
size: "$request.query.size"
q: "$request.query.q"
- validate:
target: listRes # 回傳前驗證 schema
- respond:
status: 200
getById:
route: "GET /v1/users/{id}"
steps:
- rbac: { require: read }
- query:
from: users
op: findById
args: { id: "$request.params.id" }
- if:
when: "$ctx.result == null"
then:
- respond: { status: 404, body: { error: "user not found" } }
else:
- validate: { target: userRes }
- respond: { status: 200 }
create:
route: "POST /v1/users"
steps:
- rbac: { require: write }
- validate:
target: "contracts.users.components.schemas.CreateUserRequest"
source: "$request.body"
- query:
from: users
op: checkEmailUnique
args: { email: "$request.body.email" }
- if:
when: "$ctx.unique == false"
then: [ { respond: { status: 409, body: { error: "email already exists" } } } ]
else:
- transform:
op: assignId
args: { strategy: "uuid" }
- respond:
status: 201
bodyFrom: "$ctx.newUser"
errors:
- match: "validation_error"
respond: { status: 400, body: { error: "invalid request" } }
- match: "rbac_denied"
respond: { status: 403, body: { error: "forbidden" } }
```
說明:
- filterAndPaginate、findById、checkEmailUnique、assignId 這些運算可由 Gateway 內建或以 JS 執行器提供(Goja)。
---
## repo/validators/zod.ts(前端 Zod 驗證器)
```ts
import { z } from 'zod';
// 共用
export const errorResponseSchema = z.object({ error: z.string() });
// Users
export const userSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string()
});
export const usersListResponseSchema = z.object({
items: z.array(userSchema),
total: z.number().nonnegative()
});
// create
export const createUserRequestSchema = z.object({
email: z.string().email(),
name: z.string().min(1)
});
export type User = z.infer<typeof userSchema>;
export type UsersListResponse = z.infer<typeof usersListResponseSchema>;
export type CreateUserRequest = z.infer<typeof createUserRequestSchema>;
```
## repo/validators/ajv.ts(若採 AJV)
```ts
import Ajv from 'ajv';
export function makeAjv() {
const ajv = new Ajv({ allErrors: true, strict: false });
// 可動態載入 contracts/*.yaml 轉為 JSON schema 後 addSchema
return ajv;
}
```
---
# 最小可執行數據集與單元測試
以下提供三種測試層級的最小樣本。
## 1) 前端單元測試(Jest/Karma,驗證 artifact 與 Zod 一致)
web/src/app/core/artifact/artifact.spec.ts
```ts
import { usersListResponseSchema, userSchema } from '../../../../repo/validators/zod';
import data from '../../../../repo/data/users.list.v1.json';
describe('Artifact data schema', () => {
it('users.list matches schema', () => {
const parsed = usersListResponseSchema.parse(data);
expect(parsed.items.length).toBeGreaterThan(0);
parsed.items.forEach(u => expect(userSchema.parse(u)).toBeTruthy());
});
});
```
說明:
- 配置 tsconfig / jest 允許 import JSON(或使用 raw-loader/vite json import)。
- Angular 預設 Karma 也可運行,按專案測試框架調整。
## 2) Node 腳本驗證(CI 用)
scripts/validate-artifact.mjs
```js
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { z } from 'zod';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const userSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string()
});
const listSchema = z.object({
items: z.array(userSchema),
total: z.number().nonnegative()
});
async function main() {
const base = path.join(__dirname, '..', 'repo', 'data');
const list = JSON.parse(await fs.readFile(path.join(base, 'users.list.v1.json'), 'utf-8'));
listSchema.parse(list);
const detail = JSON.parse(await fs.readFile(path.join(base, 'users.detail.u_1.v1.json'), 'utf-8'));
userSchema.parse(detail);
console.log('Artifact validation OK');
}
main().catch(e => { console.error(e); process.exit(1); });
```
CI 片段(.github/workflows/ci.yml)
```yaml
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: node scripts/validate-artifact.mjs
```
## 3) Artifact Gateway 的 E2E 測試(Go,使用 httptest)
cmd/artifact-gateway/main_test.go(示意)
```go
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func TestMockListUsers(t *testing.T) {
gin.SetMode(gin.TestMode)
repoBase := filepath.Join("..", "..", "repo")
os.Setenv("REPO_BASE", repoBase)
r := gin.Default()
r.Static("/repo", repoBase)
r.Any("/mock/*any", func(c *gin.Context) {
ep, err := matchEndpoint(repoBase, c.Request.Method, c.Param("any")[1:])
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
status := 200
if s, ok := ep.Response["status"].(float64); ok {
status = int(s)
}
body := ep.Response["body"]
c.JSON(status, body)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/mock/v1/users?page=1&size=10", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: %d, body: %s", w.Code, w.Body.String())
}
if got := w.Body.String(); len(got) == 0 {
t.Fatalf("empty body")
}
}
```
---
# 開發與啟動提示
- 本地開發
- 啟動 Artifact Gateway:`make gw.run`(或 docker-compose 啟動)
- 前端以環境開關使用 `/mock` 或 `/repo`:
- `/mock/v1/users` -> 根據 repo/api/ 與 flows 返回
- `/repo/data/users.list.v1.json` -> 直接讀靜態資料
- Docker 組合(在 docker-compose.yml 已示範)
- Web 靜態暴露 /repo,並代理 `/mock` 至 Gateway
---
# 後續延伸
- 在 Gateway 增加 flows 運算子實作:filterAndPaginate、findById、checkEmailUnique、assignId(可用 Goja 執行 TS/JS)。
- 以 js-yaml 解析 contracts/*.yaml,附加 request/response schema 驗證與 `x-role` RBAC 檢查。
- 提供 CLI:`gw validate` 檢查 repo 完整性(registry 對應檔案存在、$ref 可解析、schema 驗證通過)。
以上檔案可直接放入專案對應路徑,配合前文提供的 Gateway 與 Angular 設定即可起跑。