定时发布目录(ops_scheduled_publications),并加固 catalog 生成器。 Co-authored-by: Cursor <cursoragent@cursor.com>
542 lines
16 KiB
Python
542 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate a standard Ops read-catalog slice (migration+repo+svc+handler+test+openapi+client stub)."""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
OPENAPI_MARKER = " /api/v1/admin/crisis/policies:" # insert before crisis if cms; else append before end - configurable
|
|
|
|
|
|
def run(cmd: list[str]):
|
|
print("+", " ".join(cmd))
|
|
subprocess.check_call(cmd, cwd=ROOT)
|
|
|
|
|
|
def write(path: Path, text: str):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
|
print("wrote", path.relative_to(ROOT))
|
|
|
|
|
|
def ensure_perm(code: str, const: str):
|
|
rbac = ROOT / "apps/api/internal/service/admin/rbac.go"
|
|
t = rbac.read_text()
|
|
if f'{const} ' in t or f'{const}\t' in t or f'{const}=' in t.replace(' ', ''):
|
|
if code in t and const in t:
|
|
# still ensure knownPermissions
|
|
pass
|
|
if f'{const} ' not in t and f'{const}\t' not in t:
|
|
# insert before closing paren of const block
|
|
t = t.replace(
|
|
')\n\nvar knownPermissions',
|
|
f'\t{const} = "{code}"\n)\n\nvar knownPermissions',
|
|
1,
|
|
)
|
|
if f'{const}:' not in t:
|
|
t = t.replace(
|
|
'PermCMSRead: {},\n}',
|
|
f'PermCMSRead: {{}}, {const}: {{}},\n}}',
|
|
1,
|
|
)
|
|
if f'{const}:' not in t:
|
|
# append before knownPermissions closing
|
|
t = t.replace(
|
|
'PermCrisisRead: {}, PermCMSRead: {},',
|
|
f'PermCrisisRead: {{}}, PermCMSRead: {{}}, {const}: {{}},',
|
|
1,
|
|
)
|
|
if code not in t:
|
|
raise SystemExit(f'failed to inject perm {code}')
|
|
rbac.write_text(t)
|
|
print("updated rbac", code, const)
|
|
|
|
|
|
def append_register(fn: str):
|
|
admin = ROOT / "apps/api/internal/handler/admin.go"
|
|
t = admin.read_text()
|
|
if f"h.{fn}(" in t:
|
|
return
|
|
needle = "\th.registerCMS(authed)\n"
|
|
if needle in t and f"h.{fn}(" not in t:
|
|
# insert after last registerXXX line inside Register
|
|
import re
|
|
m = list(re.finditer(r"\th\.register\w+\(authed\)\n", t))
|
|
if m:
|
|
last = m[-1]
|
|
t = t[: last.end()] + f"\th.{fn}(authed)\n" + t[last.end() :]
|
|
admin.write_text(t)
|
|
return
|
|
admin.write_text(t)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--spec", required=True, help="path to json slice spec")
|
|
args = ap.parse_args()
|
|
cfg = json.loads(Path(args.spec).read_text())
|
|
|
|
ecr = cfg["ecr"] # "026"
|
|
slug = cfg["slug"]
|
|
concept = cfg["concept"]
|
|
list_method = cfg["list_method"]
|
|
get_method = cfg["get_method"]
|
|
table = cfg["table"]
|
|
mig = cfg["migration"]
|
|
route_group = cfg["route_group"] # /cms
|
|
route_res = cfg["route_resource"] # publications
|
|
perm_const = cfg["perm_const"] # PermCMSRead
|
|
perm_code = cfg["perm_code"]
|
|
seed_code = cfg["seed_code"]
|
|
columns = cfg["columns"] # list of {name, sql_type, go_type, json, seed?}
|
|
register_fn = cfg["register_fn"]
|
|
test_name = cfg["test_name"]
|
|
row_type = cfg["row_type"]
|
|
err_name = cfg["err_name"]
|
|
new_perm = cfg.get("new_perm", False)
|
|
capability = cfg["capability"]
|
|
bc = cfg["bc"]
|
|
predecessor = cfg["predecessor"]
|
|
title = cfg["title"]
|
|
non_goals = cfg["non_goals"]
|
|
openapi_before = cfg.get("openapi_before", OPENAPI_MARKER)
|
|
|
|
# 1) ESS scaffold
|
|
apis = f"GET /admin{route_group}/{route_res}|GET /admin{route_group}/{route_res}/{{id}}"
|
|
cmd = [
|
|
"python3", "scripts/ess-slice-scaffold.py",
|
|
"--ecr", ecr, "--slug", slug, "--title", title,
|
|
"--capability", capability, "--bc", bc, "--concept", concept,
|
|
"--predecessor", predecessor, "--migration", mig, "--perm", perm_code,
|
|
"--apis", apis, "--non-goals", non_goals,
|
|
]
|
|
if new_perm:
|
|
cmd.append("--new-perm")
|
|
run(cmd)
|
|
|
|
# 2) migration
|
|
col_sql = [f" {c['name']} {c['sql']}" for c in columns]
|
|
seed_cols = [c["name"] for c in columns if c.get("seed") is not None]
|
|
seed_vals = []
|
|
for c in columns:
|
|
if c.get("seed") is None:
|
|
continue
|
|
v = c["seed"]
|
|
if isinstance(v, bool):
|
|
seed_vals.append("true" if v else "false")
|
|
elif isinstance(v, int):
|
|
seed_vals.append(str(v))
|
|
elif v is None:
|
|
seed_vals.append("NULL")
|
|
else:
|
|
seed_vals.append("'" + str(v).replace("'", "''") + "'")
|
|
up = f"""-- ECR-{ecr} {concept} (read catalog)
|
|
|
|
CREATE TABLE IF NOT EXISTS {table} (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
{chr(10).join(c + "," for c in col_sql)}
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_{table}_active ON {table}(active);
|
|
|
|
INSERT INTO {table}({', '.join(seed_cols)})
|
|
VALUES ({', '.join(seed_vals)})
|
|
ON CONFLICT (code) DO NOTHING;
|
|
"""
|
|
if new_perm:
|
|
up += f"""
|
|
INSERT INTO admin_role_permissions(role_id, code)
|
|
SELECT r.id, '{perm_code}'
|
|
FROM admin_roles r
|
|
WHERE r.name = 'super_admin'
|
|
ON CONFLICT DO NOTHING;
|
|
"""
|
|
write(ROOT / f"apps/api/migrations/{mig}_{table}.up.sql", up)
|
|
down = f"DROP TABLE IF EXISTS {table};\n"
|
|
if new_perm:
|
|
down = f"DELETE FROM admin_role_permissions WHERE code = '{perm_code}';\n" + down
|
|
write(ROOT / f"apps/api/migrations/{mig}_{table}.down.sql", down)
|
|
|
|
if new_perm:
|
|
ensure_perm(perm_code, perm_const)
|
|
|
|
# 3) repository file
|
|
go_fields = []
|
|
scan_vars = []
|
|
for c in columns:
|
|
go_fields.append(f"\t{c['go_name']} {c['go_type']} `json:\"{c['json']}\"`")
|
|
scan_vars.append(f"&row.{c['go_name']}")
|
|
go_fields.append('\tUpdatedAt time.Time `json:"updated_at"`')
|
|
scan_vars.append("&row.UpdatedAt")
|
|
select_cols = ", ".join(["id"] + [c["name"] for c in columns] + ["updated_at"])
|
|
repo = f"""package repository
|
|
|
|
import (
|
|
\t"context"
|
|
\t"errors"
|
|
\t"time"
|
|
|
|
\t"github.com/google/uuid"
|
|
\t"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// {row_type} is {concept} catalog row.
|
|
type {row_type} struct {{
|
|
\tID uuid.UUID `json:"id"`
|
|
{chr(10).join(go_fields)}
|
|
}}
|
|
|
|
// List{list_method} returns {concept} catalog.
|
|
func (r *AdminRepo) List{list_method}(ctx context.Context) ([]{row_type}, error) {{
|
|
\trows, err := r.Pool.Query(ctx, `
|
|
\t\tSELECT {select_cols}
|
|
\t\tFROM {table}
|
|
\t\tORDER BY active DESC, code ASC`)
|
|
\tif err != nil {{
|
|
\t\treturn nil, err
|
|
\t}}
|
|
\tdefer rows.Close()
|
|
\tvar out []{row_type}
|
|
\tfor rows.Next() {{
|
|
\t\tvar row {row_type}
|
|
\t\tif err := rows.Scan(&row.ID, {', '.join(scan_vars)}); err != nil {{
|
|
\t\t\treturn nil, err
|
|
\t\t}}
|
|
\t\tout = append(out, row)
|
|
\t}}
|
|
\treturn out, rows.Err()
|
|
}}
|
|
|
|
// Get{get_method} loads one by id.
|
|
func (r *AdminRepo) Get{get_method}(ctx context.Context, id uuid.UUID) (*{row_type}, error) {{
|
|
\tvar row {row_type}
|
|
\terr := r.Pool.QueryRow(ctx, `
|
|
\t\tSELECT {select_cols}
|
|
\t\tFROM {table} WHERE id=$1`, id,
|
|
\t).Scan(&row.ID, {', '.join(scan_vars)})
|
|
\tif errors.Is(err, pgx.ErrNoRows) {{
|
|
\t\treturn nil, err
|
|
\t}}
|
|
\tif err != nil {{
|
|
\t\treturn nil, err
|
|
\t}}
|
|
\treturn &row, nil
|
|
}}
|
|
"""
|
|
# Fix Scan - I duplicated &row incorrectly. scan_vars already have &row.X
|
|
# List scan should be: rows.Scan(&row.ID, &row.Code, ...)
|
|
scan_list = ", ".join(["&row.ID"] + [f"&row.{c['go_name']}" for c in columns] + ["&row.UpdatedAt"])
|
|
repo = f"""package repository
|
|
|
|
import (
|
|
\t"context"
|
|
\t"errors"
|
|
\t"time"
|
|
|
|
\t"github.com/google/uuid"
|
|
\t"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// {row_type} is {concept} catalog row.
|
|
type {row_type} struct {{
|
|
\tID uuid.UUID `json:"id"`
|
|
{chr(10).join(go_fields)}
|
|
}}
|
|
|
|
// List{list_method} returns {concept} catalog.
|
|
func (r *AdminRepo) List{list_method}(ctx context.Context) ([]{row_type}, error) {{
|
|
\trows, err := r.Pool.Query(ctx, `
|
|
\t\tSELECT {select_cols}
|
|
\t\tFROM {table}
|
|
\t\tORDER BY active DESC, code ASC`)
|
|
\tif err != nil {{
|
|
\t\treturn nil, err
|
|
\t}}
|
|
\tdefer rows.Close()
|
|
\tvar out []{row_type}
|
|
\tfor rows.Next() {{
|
|
\t\tvar row {row_type}
|
|
\t\tif err := rows.Scan({scan_list}); err != nil {{
|
|
\t\t\treturn nil, err
|
|
\t\t}}
|
|
\t\tout = append(out, row)
|
|
\t}}
|
|
\treturn out, rows.Err()
|
|
}}
|
|
|
|
// Get{get_method} loads one by id.
|
|
func (r *AdminRepo) Get{get_method}(ctx context.Context, id uuid.UUID) (*{row_type}, error) {{
|
|
\tvar row {row_type}
|
|
\terr := r.Pool.QueryRow(ctx, `
|
|
\t\tSELECT {select_cols}
|
|
\t\tFROM {table} WHERE id=$1`, id,
|
|
\t).Scan({scan_list})
|
|
\tif errors.Is(err, pgx.ErrNoRows) {{
|
|
\t\treturn nil, err
|
|
\t}}
|
|
\tif err != nil {{
|
|
\t\treturn nil, err
|
|
\t}}
|
|
\treturn &row, nil
|
|
}}
|
|
"""
|
|
write(ROOT / f"apps/api/internal/repository/{slug.replace('-', '_')}_repo.go", repo)
|
|
|
|
svc = f"""package admin
|
|
|
|
import (
|
|
\t"context"
|
|
\t"errors"
|
|
|
|
\t"github.com/google/uuid"
|
|
\t"github.com/jackc/pgx/v5"
|
|
|
|
\t"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
|
)
|
|
|
|
var {err_name} = errString("{slug.replace('-', ' ')} not found")
|
|
|
|
// List{list_method} returns catalog.
|
|
func (s *Service) List{list_method}(ctx context.Context) ([]repository.{row_type}, error) {{
|
|
\titems, err := s.Repo.List{list_method}(ctx)
|
|
\tif err != nil {{
|
|
\t\treturn nil, err
|
|
\t}}
|
|
\tif items == nil {{
|
|
\t\titems = []repository.{row_type}{{}}
|
|
\t}}
|
|
\treturn items, nil
|
|
}}
|
|
|
|
// Get{get_method} loads one.
|
|
func (s *Service) Get{get_method}(ctx context.Context, id uuid.UUID) (*repository.{row_type}, error) {{
|
|
\trow, err := s.Repo.Get{get_method}(ctx, id)
|
|
\tif errors.Is(err, pgx.ErrNoRows) {{
|
|
\t\treturn nil, {err_name}
|
|
\t}}
|
|
\treturn row, err
|
|
}}
|
|
"""
|
|
write(ROOT / f"apps/api/internal/service/admin/{slug.replace('-', '_')}.go", svc)
|
|
|
|
handler = f"""package handler
|
|
|
|
import (
|
|
\t"errors"
|
|
\t"net/http"
|
|
|
|
\t"github.com/gin-gonic/gin"
|
|
\t"github.com/google/uuid"
|
|
|
|
\t"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
|
\t"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
|
\t"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
|
)
|
|
|
|
func (h *AdminHandler) {register_fn}(authed *gin.RouterGroup) {{
|
|
\tg := authed.Group("{route_group}")
|
|
\tg.GET("/{route_res}", middleware.RequireAdminPermission(h.Svc, admin.{perm_const}), h.List{list_method})
|
|
\tg.GET("/{route_res}/:id", middleware.RequireAdminPermission(h.Svc, admin.{perm_const}), h.Get{get_method})
|
|
}}
|
|
|
|
func (h *AdminHandler) List{list_method}(c *gin.Context) {{
|
|
\titems, err := h.Svc.List{list_method}(c.Request.Context())
|
|
\tif err != nil {{
|
|
\t\tresponse.Fail(c, http.StatusInternalServerError, 50050, "list {slug} failed")
|
|
\t\treturn
|
|
\t}}
|
|
\tresponse.OK(c, gin.H{{"items": items}})
|
|
}}
|
|
|
|
func (h *AdminHandler) Get{get_method}(c *gin.Context) {{
|
|
\tid, err := uuid.Parse(c.Param("id"))
|
|
\tif err != nil {{
|
|
\t\tresponse.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
|
\t\treturn
|
|
\t}}
|
|
\trow, err := h.Svc.Get{get_method}(c.Request.Context(), id)
|
|
\tif errors.Is(err, admin.{err_name}) {{
|
|
\t\tresponse.Fail(c, http.StatusNotFound, 40420, "{slug} not found")
|
|
\t\treturn
|
|
\t}}
|
|
\tif err != nil {{
|
|
\t\tresponse.Fail(c, http.StatusInternalServerError, 50051, "get {slug} failed")
|
|
\t\treturn
|
|
\t}}
|
|
\tresponse.OK(c, row)
|
|
}}
|
|
"""
|
|
write(ROOT / f"apps/api/internal/handler/admin_{slug.replace('-', '_')}.go", handler)
|
|
append_register(register_fn)
|
|
|
|
# 4) integration test
|
|
path_list = f"/api/v1/admin{route_group}/{route_res}"
|
|
test = f"""package integration_test
|
|
|
|
import (
|
|
\t"context"
|
|
\t"encoding/json"
|
|
\t"fmt"
|
|
\t"net/http"
|
|
\t"testing"
|
|
\t"time"
|
|
|
|
\t"github.com/google/uuid"
|
|
\t"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func {test_name}(t *testing.T) {{
|
|
\tr, pool := setupAPIPool(t)
|
|
\tctx := context.Background()
|
|
\ttok := adminLogin(t, r, "admin", "change-me")
|
|
|
|
\t_, code := doAdminJSON(t, r, http.MethodGet, "{path_list}", nil, "")
|
|
\tif code != http.StatusUnauthorized {{
|
|
\t\tt.Fatalf("expected 401, got %d", code)
|
|
\t}}
|
|
|
|
\tlimitedRoleID := uuid.New()
|
|
\t_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
|
\t\tlimitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
|
\tif err != nil {{
|
|
\t\tt.Fatal(err)
|
|
\t}}
|
|
\t_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
|
\tif err != nil {{
|
|
\t\tt.Fatal(err)
|
|
\t}}
|
|
\thash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
|
\tif err != nil {{
|
|
\t\tt.Fatal(err)
|
|
\t}}
|
|
\tlimUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
|
\t_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
|
\tlimUser, string(hash), limitedRoleID)
|
|
\tif err != nil {{
|
|
\t\tt.Fatal(err)
|
|
\t}}
|
|
\tt.Cleanup(func() {{
|
|
\t\t_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
|
\t\t_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
|
\t}})
|
|
\tlimTok := adminLogin(t, r, limUser, "limited-pass")
|
|
\t_, code = doAdminJSON(t, r, http.MethodGet, "{path_list}", nil, limTok)
|
|
\tif code != http.StatusForbidden {{
|
|
\t\tt.Fatalf("expected 403, got %d", code)
|
|
\t}}
|
|
|
|
\tstart := time.Now()
|
|
\tenv, code := doAdminJSON(t, r, http.MethodGet, "{path_list}", nil, tok)
|
|
\tif code != 200 || env.Code != 0 {{
|
|
\t\tt.Fatalf("list http=%d msg=%s", code, env.Message)
|
|
\t}}
|
|
\tif time.Since(start) > 500*time.Millisecond {{
|
|
\t\tt.Fatalf("list too slow %v", time.Since(start))
|
|
\t}}
|
|
\tvar list struct {{
|
|
\t\tItems []struct {{
|
|
\t\t\tID string `json:"id"`
|
|
\t\t\tCode string `json:"code"`
|
|
\t\t}} `json:"items"`
|
|
\t}}
|
|
\t_ = json.Unmarshal(env.Data, &list)
|
|
\tvar id string
|
|
\tfor _, it := range list.Items {{
|
|
\t\tif it.Code == "{seed_code}" {{
|
|
\t\t\tid = it.ID
|
|
\t\t\tbreak
|
|
\t\t}}
|
|
\t}}
|
|
\tif id == "" {{
|
|
\t\tt.Fatalf("missing {seed_code}: %#v", list.Items)
|
|
\t}}
|
|
\tenv, code = doAdminJSON(t, r, http.MethodGet, "{path_list}/"+id, nil, tok)
|
|
\tif code != 200 {{
|
|
\t\tt.Fatalf("get %d", code)
|
|
\t}}
|
|
\t_, code = doAdminJSON(t, r, http.MethodGet, "{path_list}/"+fakeUUID(), nil, tok)
|
|
\tif code != http.StatusNotFound {{
|
|
\t\tt.Fatalf("expected 404, got %d", code)
|
|
\t}}
|
|
}}
|
|
"""
|
|
write(ROOT / f"apps/api/internal/integration/{slug.replace('-', '_')}_test.go", test)
|
|
|
|
# 5) openapi
|
|
oa = ROOT / "proto/openapi.yaml"
|
|
ot = oa.read_text()
|
|
block = f""" /api/v1/admin{route_group}/{route_res}:
|
|
get:
|
|
tags: [admin]
|
|
summary: List {concept} catalog
|
|
description: Requires {perm_code}
|
|
responses:
|
|
'200':
|
|
description: OK
|
|
'401':
|
|
description: Unauthorized
|
|
'403':
|
|
description: Forbidden
|
|
|
|
/api/v1/admin{route_group}/{route_res}/{{id}}:
|
|
get:
|
|
tags: [admin]
|
|
summary: Get {concept}
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: {{ type: string, format: uuid }}
|
|
responses:
|
|
'200':
|
|
description: OK
|
|
'404':
|
|
description: Not found
|
|
|
|
"""
|
|
if f"/admin{route_group}/{route_res}:" in ot:
|
|
print("openapi already has route")
|
|
else:
|
|
if openapi_before in ot:
|
|
ot = ot.replace(openapi_before, block + openapi_before)
|
|
else:
|
|
ot = ot + "\n" + block
|
|
oa.write_text(ot)
|
|
print("updated openapi")
|
|
|
|
# 6) client.ts append before orders:
|
|
client = ROOT / "apps/admin-h5/src/api/client.ts"
|
|
ct = client.read_text()
|
|
method = cfg.get("client_list", route_res.replace("-", "_"))
|
|
# camelCase
|
|
def camel(s: str) -> str:
|
|
parts = s.replace("_", "-").split("-")
|
|
return parts[0] + "".join(p.title() for p in parts[1:])
|
|
|
|
list_fn = camel(route_res)
|
|
get_fn = camel(route_res.rstrip("s") if route_res.endswith("s") else route_res + "Item")
|
|
if route_res.endswith("s"):
|
|
get_fn = camel(route_res[:-1])
|
|
stub = f""" {list_fn}: () =>
|
|
request<{{ items: Array<Record<string, unknown>> }}>('GET', '{route_group}/{route_res}'),
|
|
{get_fn}: (id: string) =>
|
|
request<Record<string, unknown>>('GET', `{route_group}/{route_res}/${{id}}`),
|
|
"""
|
|
if f"{list_fn}:" not in ct:
|
|
ct = ct.replace(" orders: () =>", stub + " orders: () =>")
|
|
client.write_text(ct)
|
|
print("updated client")
|
|
|
|
print("GENERATED", ecr, concept)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|