Files
goodie/pkg/server/server.go
Marc 822a91f8cf Replace custom setup wizard with Database Manager (like Python Odoo)
Flow now mirrors Python Odoo exactly:
1. Empty DB → /web redirects to /web/database/manager
2. User fills: master_pwd, email (login), password, phone, lang, country, demo
3. Backend creates admin user, company, seeds chart of accounts
4. Auto-login → redirect to /odoo (webclient)

Removed:
- Custom /web/setup wizard
- Auto-seed on startup

Added:
- /web/database/manager (mirrors odoo/addons/web/controllers/database.py)
- /web/database/create (mirrors exp_create_database)
- Auto-login after DB creation with session cookie

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:38:16 +02:00

929 lines
25 KiB
Go

// Package server implements the HTTP server and RPC dispatch.
// Mirrors: odoo/http.py, odoo/service/server.py
package server
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"odoo-go/pkg/orm"
"odoo-go/pkg/tools"
)
// Server is the main Odoo HTTP server.
// Mirrors: odoo/service/server.py ThreadedServer
type Server struct {
config *tools.Config
pool *pgxpool.Pool
mux *http.ServeMux
sessions *SessionStore
// xmlTemplateBundle is the JS source for the compiled XML templates,
// generated at startup by compileXMLTemplates(). It replaces the
// pre-compiled build/js/web/static/src/xml_templates_bundle.js that
// was previously produced by tools/compile_templates.py.
xmlTemplateBundle string
// jsBundle is the concatenated JS bundle built at startup. It contains
// all JS files (except module_loader.js) plus the XML template bundle,
// served as a single file to avoid hundreds of individual HTTP requests.
jsBundle string
}
// New creates a new server instance.
func New(cfg *tools.Config, pool *pgxpool.Pool) *Server {
s := &Server{
config: cfg,
pool: pool,
mux: http.NewServeMux(),
sessions: NewSessionStore(24 * time.Hour),
}
// Compile XML templates to JS at startup, replacing the Python build step.
log.Println("odoo: compiling XML templates...")
s.xmlTemplateBundle = compileXMLTemplates(cfg.FrontendDir)
// Concatenate all JS files into a single bundle served at /web/assets/bundle.js.
// This reduces ~539 individual <script> requests to 1.
log.Println("odoo: building JS bundle...")
s.jsBundle = buildJSBundle(cfg, s.xmlTemplateBundle)
s.registerRoutes()
return s
}
// registerRoutes sets up HTTP routes.
// Mirrors: odoo/http.py Application._setup_routes()
func (s *Server) registerRoutes() {
// Webclient HTML shell
s.mux.HandleFunc("/web", s.handleWebClient)
s.mux.HandleFunc("/web/", s.handleWebRoute)
s.mux.HandleFunc("/odoo", s.handleWebClient)
s.mux.HandleFunc("/odoo/", s.handleWebClient)
// Login page
s.mux.HandleFunc("/web/login", s.handleLogin)
// JSON-RPC endpoint (main API)
s.mux.HandleFunc("/jsonrpc", s.handleJSONRPC)
s.mux.HandleFunc("/web/dataset/call_kw", s.handleCallKW)
s.mux.HandleFunc("/web/dataset/call_kw/", s.handleCallKW)
// Session endpoints
s.mux.HandleFunc("/web/session/authenticate", s.handleAuthenticate)
s.mux.HandleFunc("/web/session/get_session_info", s.handleSessionInfo)
s.mux.HandleFunc("/web/session/check", s.handleSessionCheck)
s.mux.HandleFunc("/web/session/modules", s.handleSessionModules)
// Webclient endpoints
s.mux.HandleFunc("/web/webclient/load_menus", s.handleLoadMenus)
s.mux.HandleFunc("/web/webclient/translations", s.handleTranslations)
s.mux.HandleFunc("/web/webclient/version_info", s.handleVersionInfo)
s.mux.HandleFunc("/web/webclient/bootstrap_translations", s.handleBootstrapTranslations)
// Action loading
s.mux.HandleFunc("/web/action/load", s.handleActionLoad)
s.mux.HandleFunc("/web/action/load_breadcrumbs", s.handleLoadBreadcrumbs)
// Database endpoints
s.mux.HandleFunc("/web/database/list", s.handleDBList)
// Database manager (mirrors Python Odoo's /web/database/manager)
s.mux.HandleFunc("/web/database/manager", s.handleDatabaseManager)
s.mux.HandleFunc("/web/database/create", s.handleDatabaseCreate)
// Image serving (placeholder for uploaded images)
s.mux.HandleFunc("/web/image", s.handleImage)
s.mux.HandleFunc("/web/image/", s.handleImage)
// Concatenated JS bundle (all modules in one file)
s.mux.HandleFunc("/web/assets/bundle.js", s.handleJSBundle)
// PWA manifest
s.mux.HandleFunc("/web/manifest.webmanifest", s.handleManifest)
// File upload
s.mux.HandleFunc("/web/binary/upload_attachment", s.handleUpload)
// Health check
s.mux.HandleFunc("/health", s.handleHealth)
// Static files (catch-all for /<addon>/static/...)
// NOTE: must be last since it's a broad pattern
}
// handleWebRoute dispatches /web/* sub-routes or falls back to static files.
func (s *Server) handleWebRoute(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Known sub-routes are handled by specific handlers above.
// Anything under /web/static/ is a static file request.
if strings.HasPrefix(path, "/web/static/") {
s.handleStatic(w, r)
return
}
// For all other /web/* paths, serve the webclient (SPA routing)
s.handleWebClient(w, r)
}
// Start starts the HTTP server.
func (s *Server) Start() error {
addr := fmt.Sprintf("%s:%d", s.config.HTTPInterface, s.config.HTTPPort)
log.Printf("odoo: HTTP service running on %s", addr)
srv := &http.Server{
Addr: addr,
Handler: LoggingMiddleware(AuthMiddleware(s.sessions, s.mux)),
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
return srv.ListenAndServe()
}
// --- JSON-RPC ---
// Mirrors: odoo/http.py JsonRPCDispatcher
// JSONRPCRequest is the JSON-RPC 2.0 request format.
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
ID interface{} `json:"id"`
Params json.RawMessage `json:"params"`
}
// JSONRPCResponse is the JSON-RPC 2.0 response format.
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
// RPCError represents a JSON-RPC error.
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// CallKWParams mirrors the /web/dataset/call_kw parameters.
type CallKWParams struct {
Model string `json:"model"`
Method string `json:"method"`
Args []interface{} `json:"args"`
KW Values `json:"kwargs"`
}
// Values is a generic key-value map for RPC parameters.
type Values = map[string]interface{}
func (s *Server) handleJSONRPC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32700, Message: "Parse error",
})
return
}
// Dispatch based on method
s.writeJSONRPC(w, req.ID, map[string]string{"status": "ok"}, nil)
}
// handleCallKW handles ORM method calls via JSON-RPC.
// Mirrors: odoo/service/model.py execute_kw()
func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32700, Message: "Parse error",
})
return
}
var params CallKWParams
if err := json.Unmarshal(req.Params, &params); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32602, Message: "Invalid params",
})
return
}
// Extract UID from session, default to 1 (admin) if no session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
// Create environment for this request
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
})
if err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32000, Message: err.Error(),
})
return
}
defer env.Close()
// Dispatch ORM method
result, rpcErr := s.dispatchORM(env, params)
if rpcErr != nil {
s.writeJSONRPC(w, req.ID, nil, rpcErr)
return
}
if err := env.Commit(); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: -32000, Message: err.Error(),
})
return
}
s.writeJSONRPC(w, req.ID, result, nil)
}
// checkAccess verifies the current user has permission for the operation.
// Mirrors: odoo/addons/base/models/ir_model.py IrModelAccess.check()
func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCError {
if env.IsSuperuser() || env.UID() == 1 {
return nil // Superuser bypasses all checks
}
perm := "perm_read"
switch method {
case "create":
perm = "perm_create"
case "write":
perm = "perm_write"
case "unlink":
perm = "perm_unlink"
}
// Check if any ACL exists for this model
var count int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM ir_model_access a
JOIN ir_model m ON m.id = a.model_id
WHERE m.model = $1`, model).Scan(&count)
if err != nil || count == 0 {
return nil // No ACLs defined → open access (like Odoo superuser mode)
}
// Check if user's groups grant permission
var granted bool
err = env.Tx().QueryRow(env.Ctx(), fmt.Sprintf(`
SELECT EXISTS(
SELECT 1 FROM ir_model_access a
JOIN ir_model m ON m.id = a.model_id
LEFT JOIN res_groups_res_users_rel gu ON gu.res_groups_id = a.group_id
WHERE m.model = $1
AND a.active = true
AND a.%s = true
AND (a.group_id IS NULL OR gu.res_users_id = $2)
)`, perm), model, env.UID()).Scan(&granted)
if err != nil {
return nil // On error, allow (fail-open for now)
}
if !granted {
return &RPCError{
Code: 403,
Message: fmt.Sprintf("Access Denied: %s on %s", method, model),
}
}
return nil
}
// dispatchORM dispatches an ORM method call.
// Mirrors: odoo/service/model.py call_kw()
func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interface{}, *RPCError) {
// Check access control
if err := s.checkAccess(env, params.Model, params.Method); err != nil {
return nil, err
}
rs := env.Model(params.Model)
switch params.Method {
case "has_group":
// Always return true for admin user, stub for now
return true, nil
case "check_access_rights":
return true, nil
case "fields_get":
return fieldsGetForModel(params.Model), nil
case "web_read_group", "read_group":
// Basic implementation: if groupby is provided, return one group with all records
groupby := []string{}
if gb, ok := params.KW["groupby"].([]interface{}); ok {
for _, g := range gb {
if s, ok := g.(string); ok {
groupby = append(groupby, s)
}
}
}
if len(groupby) == 0 {
// No groupby → return empty groups
return map[string]interface{}{
"groups": []interface{}{},
"length": 0,
}, nil
}
// With groupby: return all records in one "ungrouped" group
domain := parseDomain(params.Args)
if domain == nil {
if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 {
domain = parseDomain([]interface{}{domainRaw})
}
}
count, _ := rs.SearchCount(domain)
return map[string]interface{}{
"groups": []interface{}{
map[string]interface{}{
"__domain": []interface{}{},
"__count": count,
groupby[0]: false,
"__records": []interface{}{},
},
},
"length": 1,
}, nil
case "web_search_read":
return handleWebSearchRead(env, params.Model, params)
case "web_read":
return handleWebRead(env, params.Model, params)
case "web_save":
// Combined create-or-update used by the Odoo web client.
// Mirrors: odoo/addons/web/models/models.py web_save()
ids := parseIDs(params.Args)
vals := parseValuesAt(params.Args, 1)
spec, _ := params.KW["specification"].(map[string]interface{})
if len(ids) > 0 && ids[0] > 0 {
// Update existing record(s)
err := rs.Browse(ids...).Write(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
} else {
// Create new record
created, err := rs.Create(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
ids = created.IDs()
}
// Return the saved record via web_read format
readParams := CallKWParams{
Model: params.Model,
Method: "web_read",
Args: []interface{}{ids},
KW: map[string]interface{}{"specification": spec},
}
return handleWebRead(env, params.Model, readParams)
case "get_views":
return handleGetViews(env, params.Model, params)
case "onchange":
// Return default values and run onchange handlers for changed fields.
// Mirrors: odoo/orm/models.py BaseModel.onchange()
model := orm.Registry.Get(params.Model)
defaults := make(orm.Values)
if model != nil {
orm.ApplyDefaults(model, defaults)
// Call model-specific DefaultGet for dynamic defaults (DB lookups etc.)
if model.DefaultGet != nil {
for k, v := range model.DefaultGet(env, nil) {
if _, exists := defaults[k]; !exists {
defaults[k] = v
}
}
}
// Run onchange handlers for changed fields
// args[1] = current record values, args[2] = list of changed field names
if len(params.Args) >= 3 {
if vals, ok := params.Args[1].(map[string]interface{}); ok {
if fieldNames, ok := params.Args[2].([]interface{}); ok {
for _, fn := range fieldNames {
fname, _ := fn.(string)
if handler, exists := model.OnchangeHandlers[fname]; exists {
updates := handler(env, vals)
for k, v := range updates {
defaults[k] = v
}
}
}
}
}
}
}
return map[string]interface{}{
"value": defaults,
}, nil
case "default_get":
// Return default values for the requested fields.
// Mirrors: odoo/orm/models.py BaseModel.default_get()
model := orm.Registry.Get(params.Model)
defaults := make(orm.Values)
if model != nil {
orm.ApplyDefaults(model, defaults)
// Call model-specific DefaultGet for dynamic defaults (DB lookups etc.)
if model.DefaultGet != nil {
for k, v := range model.DefaultGet(env, nil) {
if _, exists := defaults[k]; !exists {
defaults[k] = v
}
}
}
}
return defaults, nil
case "search_read":
domain := parseDomain(params.Args)
fields := parseFields(params.KW)
records, err := rs.SearchRead(domain, fields)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return records, nil
case "read":
ids := parseIDs(params.Args)
fields := parseFields(params.KW)
records, err := rs.Browse(ids...).Read(fields)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return records, nil
case "create":
vals := parseValues(params.Args)
record, err := rs.Create(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return record.ID(), nil
case "write":
ids := parseIDs(params.Args)
vals := parseValuesAt(params.Args, 1)
err := rs.Browse(ids...).Write(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return true, nil
case "unlink":
ids := parseIDs(params.Args)
err := rs.Browse(ids...).Unlink()
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return true, nil
case "search":
domain := parseDomain(params.Args)
opts := orm.SearchOpts{}
if v, ok := params.KW["limit"].(float64); ok {
opts.Limit = int(v)
}
if v, ok := params.KW["offset"].(float64); ok {
opts.Offset = int(v)
}
if v, ok := params.KW["order"].(string); ok {
opts.Order = v
}
found, err := rs.Search(domain, opts)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return found.IDs(), nil
case "search_count":
domain := parseDomain(params.Args)
count, err := rs.SearchCount(domain)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return count, nil
case "name_get":
ids := parseIDs(params.Args)
names, err := rs.Browse(ids...).NameGet()
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
// Convert map to Odoo format: [[id, "name"], ...]
var result [][]interface{}
for id, name := range names {
result = append(result, []interface{}{id, name})
}
return result, nil
case "name_search":
// name_search: search by name, return [[id, "name"], ...]
// Mirrors: odoo/orm/models.py BaseModel._name_search()
nameStr := ""
if len(params.Args) > 0 {
nameStr, _ = params.Args[0].(string)
}
// Also accept name from kwargs
if nameStr == "" {
if v, ok := params.KW["name"].(string); ok {
nameStr = v
}
}
limit := 8
if v, ok := params.KW["limit"].(float64); ok {
limit = int(v)
}
operator := "ilike"
if v, ok := params.KW["operator"].(string); ok {
operator = v
}
// Build domain: name condition + additional args domain
var nodes []orm.DomainNode
if nameStr != "" {
nodes = append(nodes, orm.Leaf("name", operator, nameStr))
}
// Parse extra domain from kwargs "args"
if extraDomainRaw, ok := params.KW["args"].([]interface{}); ok && len(extraDomainRaw) > 0 {
extraDomain := parseDomain([]interface{}{extraDomainRaw})
for _, n := range extraDomain {
nodes = append(nodes, n)
}
}
domain := orm.And(nodes...)
found, err := rs.Search(domain, orm.SearchOpts{Limit: limit})
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
names, err := found.NameGet()
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
var nameResult [][]interface{}
for id, name := range names {
nameResult = append(nameResult, []interface{}{id, name})
}
if nameResult == nil {
nameResult = [][]interface{}{}
}
return nameResult, nil
case "read_progress_bar":
return map[string]interface{}{}, nil
case "activity_format":
return []interface{}{}, nil
case "action_archive":
ids := parseIDs(params.Args)
if len(ids) > 0 {
rs.Browse(ids...).Write(orm.Values{"active": false})
}
return true, nil
case "action_unarchive":
ids := parseIDs(params.Args)
if len(ids) > 0 {
rs.Browse(ids...).Write(orm.Values{"active": true})
}
return true, nil
case "copy":
ids := parseIDs(params.Args)
if len(ids) == 0 {
return nil, &RPCError{Code: -32000, Message: "No record to copy"}
}
// Read the original record
records, err := rs.Browse(ids[0]).Read(nil)
if err != nil || len(records) == 0 {
return nil, &RPCError{Code: -32000, Message: "Record not found"}
}
// Remove id and unique fields, create copy
vals := records[0]
delete(vals, "id")
delete(vals, "create_uid")
delete(vals, "write_uid")
delete(vals, "create_date")
delete(vals, "write_date")
// Append "(copy)" to name if it exists
if name, ok := vals["name"].(string); ok {
vals["name"] = name + " (copy)"
}
created, err := rs.Create(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return created.ID(), nil
default:
// Try registered business methods on the model
model := orm.Registry.Get(params.Model)
if model != nil && model.Methods != nil {
if method, ok := model.Methods[params.Method]; ok {
ids := parseIDs(params.Args)
result, err := method(rs.Browse(ids...), params.Args[1:]...)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return result, nil
}
}
return nil, &RPCError{
Code: -32601,
Message: fmt.Sprintf("Method %q not found on %s", params.Method, params.Model),
}
}
}
// --- Session / Auth Endpoints ---
func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
return
}
var params struct {
DB string `json:"db"`
Login string `json:"login"`
Password string `json:"password"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
return
}
// Query user by login
var uid int64
var companyID int64
var partnerID int64
var hashedPw string
var userName string
err := s.pool.QueryRow(r.Context(),
`SELECT u.id, u.password, u.company_id, u.partner_id, p.name
FROM res_users u
JOIN res_partner p ON p.id = u.partner_id
WHERE u.login = $1 AND u.active = true`,
params.Login,
).Scan(&uid, &hashedPw, &companyID, &partnerID, &userName)
if err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: 100, Message: "Access Denied: invalid login or password",
})
return
}
// Check password (support both bcrypt and plaintext for migration)
if !tools.CheckPassword(hashedPw, params.Password) && hashedPw != params.Password {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: 100, Message: "Access Denied: invalid login or password",
})
return
}
// Create session
sess := s.sessions.New(uid, companyID, params.Login)
// Set session cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sess.ID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
s.writeJSONRPC(w, req.ID, map[string]interface{}{
"uid": uid,
"session_id": sess.ID,
"company_id": companyID,
"partner_id": partnerID,
"is_admin": uid == 1,
"name": userName,
"username": params.Login,
"server_version": "19.0-go",
"server_version_info": []interface{}{19, 0, 0, "final", 0, "g"},
"db": s.config.DBName,
}, nil)
}
func (s *Server) handleSessionInfo(w http.ResponseWriter, r *http.Request) {
// Try context first, then fall back to cookie lookup
sess := GetSession(r)
if sess == nil {
if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" {
sess = s.sessions.Get(cookie.Value)
}
}
if sess == nil {
s.writeJSONRPC(w, nil, nil, &RPCError{
Code: 100, Message: "Session expired",
})
return
}
s.writeJSONRPC(w, nil, s.buildSessionInfo(sess), nil)
}
func (s *Server) handleDBList(w http.ResponseWriter, r *http.Request) {
s.writeJSONRPC(w, nil, []string{s.config.DBName}, nil)
}
func (s *Server) handleVersionInfo(w http.ResponseWriter, r *http.Request) {
s.writeJSONRPC(w, nil, map[string]interface{}{
"server_version": "19.0-go",
"server_version_info": []interface{}{19, 0, 0, "final", 0, "g"},
"server_serie": "19.0",
"protocol_version": 1,
}, nil)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
err := s.pool.Ping(context.Background())
if err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
}
// --- Helpers ---
func (s *Server) writeJSONRPC(w http.ResponseWriter, id interface{}, result interface{}, rpcErr *RPCError) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: id,
Result: result,
Error: rpcErr,
}
json.NewEncoder(w).Encode(resp)
}
// parseDomain converts JSON-RPC domain args to orm.Domain.
// JSON format: [["field", "op", value], ...] or ["&", ["field", "op", value], ...]
func parseDomain(args []interface{}) orm.Domain {
if len(args) == 0 {
return nil
}
// First arg should be the domain list
domainRaw, ok := args[0].([]interface{})
if !ok {
return nil
}
if len(domainRaw) == 0 {
return nil
}
var nodes []orm.DomainNode
for _, item := range domainRaw {
switch v := item.(type) {
case string:
// Operator: "&", "|", "!"
nodes = append(nodes, orm.Operator(v))
case []interface{}:
// Leaf: ["field", "op", value]
if len(v) == 3 {
field, _ := v[0].(string)
op, _ := v[1].(string)
nodes = append(nodes, orm.Leaf(field, op, v[2]))
}
}
}
// If we have multiple leaves without explicit operators, AND them together
// (Odoo default: implicit AND between leaves)
var leaves []orm.DomainNode
for _, n := range nodes {
leaves = append(leaves, n)
}
if len(leaves) == 0 {
return nil
}
return orm.Domain(leaves)
}
func parseIDs(args []interface{}) []int64 {
if len(args) == 0 {
return nil
}
switch v := args[0].(type) {
case []interface{}:
ids := make([]int64, len(v))
for i, item := range v {
switch n := item.(type) {
case float64:
ids[i] = int64(n)
case int64:
ids[i] = n
case int32:
ids[i] = int64(n)
}
}
return ids
case []int64:
return v
case float64:
return []int64{int64(v)}
case int64:
return []int64{v}
}
return nil
}
func parseFields(kw Values) []string {
if kw == nil {
return nil
}
fieldsRaw, ok := kw["fields"]
if !ok {
return nil
}
fieldsSlice, ok := fieldsRaw.([]interface{})
if !ok {
return nil
}
fields := make([]string, len(fieldsSlice))
for i, f := range fieldsSlice {
fields[i], _ = f.(string)
}
return fields
}
func parseValues(args []interface{}) orm.Values {
if len(args) == 0 {
return nil
}
vals, ok := args[0].(map[string]interface{})
if !ok {
return nil
}
return orm.Values(vals)
}
func parseValuesAt(args []interface{}, idx int) orm.Values {
if len(args) <= idx {
return nil
}
vals, ok := args[idx].(map[string]interface{})
if !ok {
return nil
}
return orm.Values(vals)
}