mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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.
288 lines
6.7 KiB
Go
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()
|
|
}
|