Files
zeekay cc7a65b5cb base: route convention /api/* -> /v1/*
One path: /v1/*. We do not use /api/ — that's Casdoor's path. The
BASE_API_PREFIX env knob is gone; the prefix is hard-coded in
apis/base.go and apis/serve.go banner.

Server-side mounts:
- apis: bindHealthApi, bindCollectionApi, bindRecordCrudApi, etc. all
  attach under r.Group("/v1") (was r.Group(BASE_API_PREFIX, default /api))
- platform plugin: /api/platform/* -> /v1/platform/* (including auth and
  org sub-groups)
- kms plugin: /api/kms/* -> /v1/kms/*
- functions plugin: /api/functions/* -> /v1/functions/*
- cloudsql plugin: /api/cloud-sql/*, /api/meta/* -> /v1/*
- superuser OIDC: /_/api/oidc/config -> /_/v1/oidc/config
- core settings: default RateLimitRule labels /api/batch, /api/ ->
  /v1/batch, /v1/
- apis/batch.go: ValidBatchActions regexes match /v1/collections/*;
  internal rewrite targets /v1/collections/* (was /v1/base/collections/*,
  a stale artifact)

Clients:
- sdk/base-js (BaseClient.collections, files, realtime, CRDT WS,
  signIn/refresh/health) targets /v1/*
- sdk/base-react (use-crdt WS path) targets /v1/crdt
- ui-react admin (lib/api.ts, settings.application copy) targets /v1/*
- cmd/cli (NewClient.APIPrefix) defaults to /v1; client_test.go and
  cmd/cli_test.go stubServer use /v1/*
- cmd/typegen calls /v1/collections

Upstream paths untouched: Casdoor IAM (/api/login, /api/userinfo,
/api/signup, /api/send-verification-code, /api/get-user), KMS Infisical
(/api/v1/auth/universal-auth/login, /api/v2/workspace/environments),
Kubernetes tasks (/api/v1/namespaces/...), IPFS (/api/v0/...), Gitea/
GitLab/Discord/Strava/Patreon/Box/Wakatime upstream APIs. Those are
their contracts, not ours.

Tests: all 437 prior /api/ test URLs across apis/*_test.go and
plugins/platform/auth_proxy_test.go updated to /v1/. Build + go vet
clean. ./apis ./core ./plugins/platform ./plugins/jsvm ./forms all
green. Pre-existing red probes (network/TestRedProbe_*,
migratecmd/TestAutomigrateCollection*) unchanged.
2026-05-15 14:41:26 -07:00

288 lines
6.7 KiB
Go

// Command typegen generates TypeScript type definitions from a running Hanzo Base instance.
//
// Usage:
//
// go run cmd/typegen/main.go --url http://localhost:8090 --output types.ts \
// --token "$BASE_TOKEN"
//
// It connects to the Base API, reads all collection schemas, and emits
// TypeScript interfaces with a Collections type map.
//
// The legacy --admin-email / --admin-password flags were removed in the
// IAM-native rip; pass a pre-obtained IAM JWT via --token or the
// BASE_TOKEN env var. Anonymous access (--token "") still works for
// public-readable collections.
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"unicode"
)
type field struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
System bool `json:"system"`
Hidden bool `json:"hidden"`
MaxSelect int `json:"maxSelect"`
Values []string `json:"values"` // select
CollectionID string `json:"collectionId"` // relation
}
type collection struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
System bool `json:"system"`
Fields []field `json:"fields"`
}
// --- CLI flags ---
var (
flagURL = flag.String("url", "http://localhost:8090", "Base instance URL")
flagOutput = flag.String("output", "", "Output file path (default: stdout)")
flagToken = flag.String("token", "", "Bearer token (IAM-issued JWT). Falls back to $BASE_TOKEN.")
flagExclude = flag.String("exclude", "", "Comma-separated collection names to exclude")
)
func main() {
flag.Parse()
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "typegen: %v\n", err)
os.Exit(1)
}
}
func run() error {
baseURL := strings.TrimRight(*flagURL, "/")
// 1. Resolve token (flag wins; otherwise env)
token := *flagToken
if token == "" {
token = os.Getenv("BASE_TOKEN")
}
// 2. Fetch collections
collections, err := fetchCollections(baseURL, token)
if err != nil {
return fmt.Errorf("fetch collections: %w", err)
}
// 3. Build exclusion set
excludeSet := make(map[string]bool)
if *flagExclude != "" {
for _, name := range strings.Split(*flagExclude, ",") {
excludeSet[strings.TrimSpace(name)] = true
}
}
// 4. Filter and sort
var filtered []collection
for _, c := range collections {
if excludeSet[c.Name] {
continue
}
filtered = append(filtered, c)
}
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].Name < filtered[j].Name
})
// 5. Build collection ID -> name map for relation resolution
idToName := make(map[string]string, len(collections))
for _, c := range collections {
idToName[c.ID] = c.Name
}
// 6. Generate TypeScript
ts := generate(filtered, idToName)
// 7. Write output
if *flagOutput != "" {
if err := os.WriteFile(*flagOutput, []byte(ts), 0644); err != nil {
return fmt.Errorf("write %s: %w", *flagOutput, err)
}
fmt.Fprintf(os.Stderr, "typegen: wrote %s (%d collections)\n", *flagOutput, len(filtered))
return nil
}
_, err = fmt.Print(ts)
return err
}
// fetchCollections retrieves all collections from the Base API.
func fetchCollections(baseURL, token string) ([]collection, error) {
url := baseURL + "/v1/collections?perPage=500"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("GET %s: status %d: %s", url, resp.StatusCode, string(b))
}
// The API returns a paginated response with items array.
var result struct {
Items []collection `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode collections: %w", err)
}
return result.Items, nil
}
// --- TypeScript generation ---
func generate(collections []collection, idToName map[string]string) string {
var buf strings.Builder
buf.WriteString("// Auto-generated by base typegen — do not edit.\n")
buf.WriteString("// Source: " + *flagURL + "\n\n")
for i, c := range collections {
writeInterface(&buf, c, idToName)
if i < len(collections)-1 {
buf.WriteByte('\n')
}
}
// Collections type map
buf.WriteByte('\n')
buf.WriteString("export type Collections = {\n")
for _, c := range collections {
buf.WriteString(" " + c.Name + ": " + pascalCase(c.Name) + "\n")
}
buf.WriteString("}\n")
return buf.String()
}
func writeInterface(buf *strings.Builder, c collection, idToName map[string]string) {
name := pascalCase(c.Name)
buf.WriteString("export interface " + name + " {\n")
for _, f := range c.Fields {
if f.Hidden {
continue
}
ts, comment := fieldToTS(f, idToName)
line := " " + f.Name + ": " + ts
if comment != "" {
line += " // " + comment
}
buf.WriteString(line + "\n")
}
buf.WriteString("}\n")
}
// fieldToTS maps a Base field to its TypeScript type and an optional comment.
func fieldToTS(f field, idToName map[string]string) (tsType string, comment string) {
switch f.Type {
case "text", "email", "url", "editor", "password":
return "string", ""
case "number":
return "number", ""
case "bool":
return "boolean", ""
case "date", "autodate":
return "string", ""
case "json":
return "unknown", ""
case "geoPoint":
return "{ lon: number; lat: number }", ""
case "select":
return selectType(f), ""
case "file":
if f.MaxSelect > 1 {
return "string[]", ""
}
return "string", ""
case "relation":
target := idToName[f.CollectionID]
if target == "" {
target = f.CollectionID
}
cmt := "relation -> " + target
if f.MaxSelect > 1 {
return "string[]", cmt
}
return "string", cmt
default:
return "unknown", "unhandled type: " + f.Type
}
}
// selectType generates a union type for single-select or union-array for multi-select.
func selectType(f field) string {
if len(f.Values) == 0 {
if f.MaxSelect > 1 {
return "string[]"
}
return "string"
}
quoted := make([]string, len(f.Values))
for i, v := range f.Values {
quoted[i] = "'" + strings.ReplaceAll(v, "'", "\\'") + "'"
}
union := strings.Join(quoted, " | ")
if f.MaxSelect > 1 {
return "(" + union + ")[]"
}
return union
}
// pascalCase converts a snake_case or kebab-case name to PascalCase.
// Examples: "tasks" -> "Tasks", "user_roles" -> "UserRoles", "my-items" -> "MyItems"
func pascalCase(s string) string {
var buf strings.Builder
buf.Grow(len(s))
upper := true
for _, r := range s {
if r == '_' || r == '-' {
upper = true
continue
}
if upper {
buf.WriteRune(unicode.ToUpper(r))
upper = false
} else {
buf.WriteRune(r)
}
}
return buf.String()
}