#!/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> }}>('GET', '{route_group}/{route_res}'), {get_fn}: (id: string) => request>('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()