diff --git a/go.mod b/go.mod index cfca96e2..6a7d33d9 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-ruby-abbrev/abbrev v0.0.0-20260629150957-97117892cd38 github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 + github.com/go-ruby-actionpack/actionpack v0.0.0-20260706115418-8ba13fbf84e5 github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a github.com/go-ruby-activerecord/activerecord v0.0.0-20260702222646-da57bd9e07f6 diff --git a/go.sum b/go.sum index fdcd8f98..d1ddf1b7 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,8 @@ github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe h1:oYFDl+kcAkTOX github.com/go-ruby-acme/acme v0.0.0-20260704112859-415fad2a4cbe/go.mod h1:Bh8Vr5Vy9UO7JqU5f6myUOWl0jjnnvtlGdIVTBbdLRw= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783 h1:VAFOmKa1u2HKA2LFQhelusPVIAtgp+wAOkXl8IjURbQ= github.com/go-ruby-actioncable/actioncable v0.0.0-20260706115409-19f2e354a783/go.mod h1:5DS3icNZKImRwvDP7Kf9IHv+9d6EYtcvRpBALgh/s/Y= +github.com/go-ruby-actionpack/actionpack v0.0.0-20260706115418-8ba13fbf84e5 h1:+CxMtA9K0j7BWiWdq+/rMfgeWjViJStyfVKcvMlDSvA= +github.com/go-ruby-actionpack/actionpack v0.0.0-20260706115418-8ba13fbf84e5/go.mod h1:3lXE8Ou6UitS4sOxQhn/KCI6eKLdREUcFxsky4oMEbk= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4 h1:vRJAWboG75rQWpfTiNQ8XkFRJ1Am9RNKdRKSlTLQBiA= github.com/go-ruby-activejob/activejob v0.0.0-20260706172137-830f5bbe94b4/go.mod h1:HBZ9KJKoGTzo4h6/5+gaYyzMYWQtucT660mE68zHZGw= github.com/go-ruby-activemodel/activemodel v0.0.0-20260706115433-f36d70b64b7a h1:d8kCh4DxIJEQJhd/ZD6niTnxEzWgnZ5NlOmxfdBRDZQ= diff --git a/internal/vm/actionpack.go b/internal/vm/actionpack.go new file mode 100644 index 00000000..4f6156c7 --- /dev/null +++ b/internal/vm/actionpack.go @@ -0,0 +1,277 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-ruby-actionpack/actionpack/dispatch" + "github.com/go-ruby-actionpack/actionpack/parameters" + "github.com/go-ruby-actionpack/actionpack/routing" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file (with actionpack_routing.go, actionpack_controller.go, +// actionpack_params.go and actionpack_dispatch.go) binds +// github.com/go-ruby-actionpack/actionpack — a pure-Go (CGO=0) port of the four +// deterministic cores of Rails' Action Pack: the routing DSL/route set +// (ActionDispatch::Routing), the controller dispatch core (AbstractController + +// ActionController::Metal), strong parameters (ActionController::Parameters) and +// the HTTP layer (ActionDispatch::Request/Response) — into rbgo (require +// "action_controller" / "action_dispatch"): +// +// class PostsController < ActionController::Base +// before_action :authenticate, except: [:index] +// rescue_from ArgumentError do |e| render plain: "bad: #{e.message}", status: 400 end +// def index; render plain: "posts"; end +// def show; render plain: "post #{params[:id]}"; end +// end +// PostsController.dispatch(:index, env) # => [status, headers, body] Rack triple +// +// routes = ActionDispatch::Routing::RouteSet.new +// routes.draw { resources :posts; root to: "home#index" } +// routes.recognize("GET", "/posts/1") # => {"controller"=>"posts", "action"=>"show", "id"=>"1"} +// routes.path("post", id: 1) # => "/posts/1" +// +// The library owns the route pattern compiler + recognizer + reverse routing, +// the filter/rescue/render dispatch pipeline and the strong-parameters +// permit/require semantics. rbgo supplies the seams the library leaves +// injectable, all run inline on the VM goroutine under the GVL: +// +// - RunAction — the controller's Ruby action method (`def index; …; end`), +// sent to the controller instance; the primary seam. +// - the before/after/around filter and rescue_from bodies — Ruby blocks or +// named instance methods, sent to the controller instance. +// - Renderer — the view-layer seam: it sends a `render` message to an +// ActionView-style context object (set with `view_context`), so the +// consolidated binary dispatches to the bound actionview; the library owns +// the plain/status/content-type response assembly. +// +// actionpack does NOT import actionview: rendering is the Renderer seam above. +// The routing/dispatch value objects (RouteSet/Mapper/Parameters/Request/ +// Response) each carry their Ruby class so classOf reports it; a controller +// instance is a plain RObject of the user's subclass whose live *controller.Base +// is reached through the @__ac handle (mirroring the ActionCable channel binding). + +// ACRouteSet is the Ruby wrapper over a *routing.RouteSet — the ordered route +// collection built by ActionDispatch::Routing::RouteSet.new and its `draw` +// block, queried with recognize/path/url_for. It carries its class so classOf +// reports ActionDispatch::Routing::RouteSet. +type ACRouteSet struct { + rs *routing.RouteSet + cls *RClass +} + +func (s *ACRouteSet) ToS() string { return "#" } +func (s *ACRouteSet) Inspect() string { return s.ToS() } +func (s *ACRouteSet) Truthy() bool { return true } + +// ACMapper is the self the routes DSL runs against inside `draw do … end` — a +// thin wrapper over the library's *routing.Mapper exposing the verb matchers, +// resources/resource, root, namespace/scope and member/collection blocks. +type ACMapper struct { + m *routing.Mapper + cls *RClass +} + +func (m *ACMapper) ToS() string { return "#" } +func (m *ACMapper) Inspect() string { return m.ToS() } +func (m *ACMapper) Truthy() bool { return true } + +// ACParams is the Ruby wrapper over a *parameters.Parameters — Rails' strong +// parameters, with permit/require/[]/to_h. It carries its class so classOf +// reports ActionController::Parameters. +type ACParams struct { + p *parameters.Parameters + cls *RClass +} + +func (p *ACParams) ToS() string { return p.p.String() } +func (p *ACParams) Inspect() string { return p.p.String() } +func (p *ACParams) Truthy() bool { return true } + +// ACRequest is the Ruby wrapper over a *dispatch.Request — an ActionDispatch +// request over a Rack env, exposing the merged params, the path parameters and +// the format negotiation. It carries its class so classOf reports +// ActionDispatch::Request. +type ACRequest struct { + r *dispatch.Request + cls *RClass +} + +func (r *ACRequest) ToS() string { return "#" } +func (r *ACRequest) Inspect() string { return r.ToS() } +func (r *ACRequest) Truthy() bool { return true } + +// ACResponse is the Ruby wrapper over a *dispatch.Response — the mutable Rack +// response (status/headers/body/write). It carries its class so classOf reports +// ActionDispatch::Response. +type ACResponse struct { + r *dispatch.Response + cls *RClass +} + +func (r *ACResponse) ToS() string { return "#" } +func (r *ACResponse) Inspect() string { return r.ToS() } +func (r *ACResponse) Truthy() bool { return true } + +// registerActionPack installs the Action Pack surface (require +// "action_controller" / "action_dispatch"): the ActionDispatch module tree +// (Routing::RouteSet/Mapper, Request, Response), the ActionController module +// tree (Base, Metal, Parameters and the ParameterMissing/UnpermittedParameters/ +// UnfilteredParameters/DoubleRenderError/ActionNotFound errors) and the +// AbstractController::Base alias. The routing DSL, the controller dispatch +// pipeline, strong parameters and the request/response surfaces are wired in the +// sibling files. +func (vm *VM) registerActionPack() { + std := vm.consts["StandardError"].(*RClass) + + // --- ActionDispatch ----------------------------------------------------- + dispatchMod := newClass("ActionDispatch", nil) + dispatchMod.isModule = true + vm.consts["ActionDispatch"] = dispatchMod + + routingMod := newClass("ActionDispatch::Routing", nil) + routingMod.isModule = true + dispatchMod.consts["Routing"] = routingMod + vm.consts["ActionDispatch::Routing"] = routingMod + + routeSet := newClass("ActionDispatch::Routing::RouteSet", vm.cObject) + routingMod.consts["RouteSet"] = routeSet + vm.consts["ActionDispatch::Routing::RouteSet"] = routeSet + vm.cACRouteSet = routeSet + + mapper := newClass("ActionDispatch::Routing::Mapper", vm.cObject) + routingMod.consts["Mapper"] = mapper + vm.consts["ActionDispatch::Routing::Mapper"] = mapper + vm.cACMapper = mapper + + request := newClass("ActionDispatch::Request", vm.cObject) + dispatchMod.consts["Request"] = request + vm.consts["ActionDispatch::Request"] = request + vm.cACRequest = request + + response := newClass("ActionDispatch::Response", vm.cObject) + dispatchMod.consts["Response"] = response + vm.consts["ActionDispatch::Response"] = response + vm.cACResponse = response + + // --- AbstractController ------------------------------------------------- + abstractMod := newClass("AbstractController", nil) + abstractMod.isModule = true + vm.consts["AbstractController"] = abstractMod + // AbstractController::ActionNotFound / DoubleRenderError — the analogues the + // library returns, exposed as rescuable error classes. + acNotFound := newClass("AbstractController::ActionNotFound", std) + abstractMod.consts["ActionNotFound"] = acNotFound + vm.consts["AbstractController::ActionNotFound"] = acNotFound + doubleRender := newClass("AbstractController::DoubleRenderError", std) + abstractMod.consts["DoubleRenderError"] = doubleRender + vm.consts["AbstractController::DoubleRenderError"] = doubleRender + + // --- ActionController --------------------------------------------------- + controllerMod := newClass("ActionController", nil) + controllerMod.isModule = true + vm.consts["ActionController"] = controllerMod + + metal := newClass("ActionController::Metal", vm.cObject) + controllerMod.consts["Metal"] = metal + vm.consts["ActionController::Metal"] = metal + + base := newClass("ActionController::Base", metal) + controllerMod.consts["Base"] = base + vm.consts["ActionController::Base"] = base + vm.cACControllerBase = base + + // AbstractController::Base — the shared ancestor Rails exposes; alias it to + // ActionController::Metal so `AbstractController::Base` resolves. + abstractMod.consts["Base"] = metal + vm.consts["AbstractController::Base"] = metal + + params := newClass("ActionController::Parameters", vm.cObject) + controllerMod.consts["Parameters"] = params + vm.consts["ActionController::Parameters"] = params + vm.cACParameters = params + + // ActionController::ParameterMissing / UnpermittedParameters / + // UnfilteredParameters — the strong-parameters errors, rescuable by name. + for _, name := range []string{"ParameterMissing", "UnpermittedParameters", "UnfilteredParameters", "UrlGenerationError"} { + e := newClass("ActionController::"+name, std) + controllerMod.consts[name] = e + vm.consts["ActionController::"+name] = e + } + + vm.registerACRouteSet(routeSet, mapper) + vm.registerACParameters(params) + vm.registerACRequest(request) + vm.registerACResponse(response) + vm.registerACController(base) +} + +// apStr coerces an argument to its String contents (String or Symbol), else its +// to_s — the shared key/name coercion for the Action Pack DSLs. +func apStr(v object.Value) string { + switch n := v.(type) { + case *object.String: + return n.Str() + case object.Symbol: + return string(n) + } + return v.ToS() +} + +// apInt coerces an argument to an int (for a status / redirect code), yielding 0 +// for a non-numeric value. +func apInt(v object.Value) int { + switch n := v.(type) { + case object.Integer: + return int(n) + case object.Float: + return int(n) + } + return 0 +} + +// apArg returns args[i] or nil when i is out of range. +func apArg(args []object.Value, i int) object.Value { + if i < len(args) { + return args[i] + } + return object.NilV +} + +// apStrList reads a String/Symbol or an Array of them into a []string — the +// shape used by via:, only: and except:. +func apStrList(v object.Value) []string { + if arr, ok := v.(*object.Array); ok { + out := make([]string, len(arr.Elems)) + for i, e := range arr.Elems { + out[i] = apStr(e) + } + return out + } + return []string{apStr(v)} +} + +// apStrMap reads a Ruby Hash into a string→string map (stringifying keys and +// values), the shape constraints:/defaults: compile to; a non-Hash yields nil. +func apStrMap(v object.Value) map[string]string { + h, ok := v.(*object.Hash) + if !ok { + return nil + } + out := make(map[string]string, len(h.Keys)) + for _, k := range h.Keys { + val, _ := h.Get(k) + if rx, ok := val.(*Regexp); ok { + // A Ruby Regexp constraint (id: /\d+/) contributes its raw, unanchored + // source — the shape the route pattern compiler expects — not its + // inspect form (?-mix:…). + out[apStr(k)] = rx.source + continue + } + out[apStr(k)] = apStr(val) + } + return out +} diff --git a/internal/vm/actionpack_controller.go b/internal/vm/actionpack_controller.go new file mode 100644 index 00000000..f294c9e7 --- /dev/null +++ b/internal/vm/actionpack_controller.go @@ -0,0 +1,498 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "errors" + + "github.com/go-ruby-actionpack/actionpack/controller" + "github.com/go-ruby-actionpack/actionpack/dispatch" + rack "github.com/go-ruby-rack/rack" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file wires the ActionController::Base controller: the class-level DSL +// (before_action/after_action/around_action, rescue_from, view_context), the +// instance surface (render/redirect_to/head/params/request/response), and the +// PostsController.dispatch(action, env) entry point. A controller instance is a +// plain RObject of the user's subclass; its live *controller.Base is reached +// through the @__ac handle, so a bare `render` inside `def index` resolves to +// the inherited native method that writes into that request's response. Every +// seam — the action body (RunAction), the filter/rescue bodies, the view render +// — runs inline on the VM goroutine under the GVL. + +// acControllerDef is the per-ActionController::Base-subclass declaration the +// class DSL accumulates: the before/after/around filter specs, the rescue_from +// specs and the view-render context. Each subclass owns one; dispatch materialises +// it (and its ancestors') into a live *controller.Class bound to the instance. +type acControllerDef struct { + befores []acFilterSpec + afters []acFilterSpec + arounds []acFilterSpec + rescuers []acRescueSpec + viewContext object.Value // the ActionView-style render context (responds to #render), or nil +} + +// acFilterSpec is one before/after/around filter: a captured Ruby block or a +// symbol naming an instance method, with the only/except/if/unless conditions. +type acFilterSpec struct { + blk *Proc + sym string + only []string + except []string + ifCond acCond + unlessCond acCond +} + +// acCond is an if:/unless: condition — a Ruby block (lambda) or a symbol method +// name; set reports whether the condition was declared. +type acCond struct { + blk *Proc + sym string + set bool +} + +// acRescueSpec is one rescue_from declaration: the exception classes it matches, +// and the handler (a block or a with: method name). +type acRescueSpec struct { + classes []*RClass + blk *Proc + sym string +} + +// acRubyErr carries a Ruby exception raised by a controller body out to the +// library's rescue/dispatch layer and back into a Ruby rescue handler. +type acRubyErr struct{ e RubyError } + +func (e *acRubyErr) Error() string { return e.e.Error() } + +// acControllerDefFor returns the def for a controller subclass, creating it on +// first use (keyed by the class object, so each subclass keeps its own filters). +func (vm *VM) acControllerDefFor(cls *RClass) *acControllerDef { + if vm.acControllerDefs == nil { + vm.acControllerDefs = map[*RClass]*acControllerDef{} + } + d, ok := vm.acControllerDefs[cls] + if !ok { + d = &acControllerDef{} + vm.acControllerDefs[cls] = d + } + return d +} + +// acControllerChain returns cls's defs, outermost-ancestor first, so a subclass +// inherits its parents' filters/rescuers (parents run first, matching Rails). +func (vm *VM) acControllerChain(cls *RClass) []*acControllerDef { + var classes []*RClass + for c := cls; c != nil && c != vm.cACControllerBase.super; c = c.super { + classes = append(classes, c) + } + defs := make([]*acControllerDef, 0, len(classes)) + for i := len(classes) - 1; i >= 0; i-- { + if d, ok := vm.acControllerDefs[classes[i]]; ok { + defs = append(defs, d) + } + } + return defs +} + +// registerACController installs the ActionController::Base class DSL and the +// instance/dispatch surface (inherited by every subclass). +func (vm *VM) registerACController(base *RClass) { + // --- class-level DSL ---------------------------------------------------- + filterDSL := func(kind int) NativeFn { + return func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + d := vm.acControllerDefFor(self.(*RClass)) + specs := acFilterSpecs(args, blk) + switch kind { + case 0: + d.befores = append(d.befores, specs...) + case 1: + d.afters = append(d.afters, specs...) + default: + d.arounds = append(d.arounds, specs...) + } + return object.NilV + } + } + base.smethods["before_action"] = &Method{name: "before_action", owner: base, native: filterDSL(0)} + base.smethods["after_action"] = &Method{name: "after_action", owner: base, native: filterDSL(1)} + base.smethods["around_action"] = &Method{name: "around_action", owner: base, native: filterDSL(2)} + + // rescue_from Klass, ... [with: :meth] { |e| … } + base.smethods["rescue_from"] = &Method{name: "rescue_from", owner: base, native: func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + spec := acRescueSpec{blk: blk} + for _, a := range args { + if c, ok := a.(*RClass); ok { + spec.classes = append(spec.classes, c) + } + } + if h := lastHashOrNil(args); h != nil { + if v, ok := h.Get(object.Symbol("with")); ok { + spec.sym = apStr(v) + } + } + if len(spec.classes) == 0 { + raise("ArgumentError", "rescue_from requires at least one exception class") + } + d := vm.acControllerDefFor(self.(*RClass)) + d.rescuers = append(d.rescuers, spec) + return object.NilV + }} + + // view_context(obj) — set the ActionView-style render context (any object + // responding to #render). The Renderer seam sends it a `render` message. + base.smethods["view_context"] = &Method{name: "view_context", owner: base, native: func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + vm.acControllerDefFor(self.(*RClass)).viewContext = apArg(args, 0) + return object.NilV + }} + + // PostsController.dispatch(action, env = nil) -> [status, headers, body]. + base.smethods["dispatch"] = &Method{name: "dispatch", owner: base, native: func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.acDispatch(self.(*RClass), apStr(apArg(args, 0)), apArg(args, 1)) + }} + + // --- instance surface --------------------------------------------------- + base.define("render", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if err := acBaseOf(self).Render(acRenderOptions(args)); err != nil { + raise("AbstractController::DoubleRenderError", "%s", err.Error()) + } + return object.NilV + }) + base.define("redirect_to", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") + } + var status []int + if h := lastHashOrNil(args); h != nil { + if v, ok := h.Get(object.Symbol("status")); ok { + status = append(status, apInt(v)) + } + } + if err := acBaseOf(self).RedirectTo(rackStr(args[0]), status...); err != nil { + raise("AbstractController::DoubleRenderError", "%s", err.Error()) + } + return object.NilV + }) + base.define("head", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if err := acBaseOf(self).Head(apInt(apArg(args, 0))); err != nil { + raise("AbstractController::DoubleRenderError", "%s", err.Error()) + } + return object.NilV + }) + base.define("params", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &ACParams{p: acBaseOf(self).Params(), cls: vm.cACParameters} + }) + base.define("request", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &ACRequest{r: acBaseOf(self).Request(), cls: vm.cACRequest} + }) + base.define("response", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &ACResponse{r: acBaseOf(self).Response(), cls: vm.cACResponse} + }) + base.define("action_name", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(acBaseOf(self).Action()) + }) + base.define("performed?", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(acBaseOf(self).Performed()) + }) +} + +// acBaseOf returns the live *controller.Base backing a controller instance (from +// its @__ac handle, set by dispatch before the action runs). +func acBaseOf(self object.Value) *controller.Base { + return getIvar(self, "@__ac").(acHandle).v.(*controller.Base) +} + +// acDispatch materialises the controller class for one request, runs the action +// through the library pipeline and returns the SPEC [status, headers, body] +// Rack triple. An unrescued error is re-raised into Ruby. +func (vm *VM) acDispatch(cls *RClass, action string, envArg object.Value) object.Value { + inst := &RObject{class: cls, ivars: map[string]object.Value{}} + req := dispatch.NewRequest(acEnv(envArg)) + req.SetPathParameters(map[string]any{"controller": cls.name, "action": action}) + params, err := req.Parameters() + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + cl := vm.acBuildController(inst, cls) + b := cl.New(req, dispatch.NewResponse(), params) + setIvar(inst, "@__ac", acHandle{b}) + if perr := b.Process(action); perr != nil { + panic(vm.acRubyErrorOf(perr)) + } + r := b.Response() + return object.NewArray(object.IntValue(int64(r.Status())), rackHeadersToHash(r.Headers()), rackBodyArray(r.Body())) +} + +// acEnv reads dispatch's optional env argument into a rack.Env: a nil argument +// yields an empty env, any Hash is converted as the Rack bindings do. +func acEnv(v object.Value) rack.Env { + if object.IsNil(v) { + return rack.Env{} + } + return rackEnv(v) +} + +// acBuildController assembles a *controller.Class from cls's def chain, every +// seam bound to inst: before/after/around filters, rescue_from handlers, the +// RunAction body (the Ruby action method) and the Renderer (the view context). +func (vm *VM) acBuildController(inst object.Value, cls *RClass) *controller.Class { + cl := controller.NewClass(cls.name) + var view object.Value + for _, d := range vm.acControllerChain(cls) { + for _, s := range d.befores { + spec := s + cl.BeforeAction(func(_ *controller.Base) error { return vm.acInvoke(inst, spec.blk, spec.sym) }, vm.acFilterOpts(inst, spec)...) + } + for _, s := range d.afters { + spec := s + cl.AfterAction(func(_ *controller.Base) error { return vm.acInvoke(inst, spec.blk, spec.sym) }, vm.acFilterOpts(inst, spec)...) + } + for _, s := range d.arounds { + spec := s + cl.AroundAction(func(_ *controller.Base, yield func() error) error { return vm.acAround(inst, spec, yield) }, vm.acFilterOpts(inst, spec)...) + } + for _, r := range d.rescuers { + spec := r + cl.RescueFrom(vm.acRescueMatch(spec), vm.acRescueHandle(inst, spec)) + } + if !object.IsNil(d.viewContext) { + view = d.viewContext + } + } + cl.RunAction(func(c *controller.Base, action string) error { + if vm.findMethod(inst, action) == nil { + return &controller.ActionNotFound{Controller: c.Class().Name, Action: action} + } + return vm.acInvoke(inst, nil, action) + }) + if !object.IsNil(view) { + cl.SetRenderer(vm.acRenderer(inst, view)) + } + return cl +} + +// acInvoke runs a controller body against inst — a captured block or a named +// instance method — converting a Ruby raise into a Go error the pipeline routes +// through rescue_from. +func (vm *VM) acInvoke(inst object.Value, blk *Proc, sym string) (err error) { + defer acRecover(&err) + if blk != nil { + vm.callBlockSelf(blk, inst, nil) + } else { + vm.send(inst, sym, nil, nil) + } + return nil +} + +// acRecover converts a recovered Ruby exception into an *acRubyErr stored in +// *err; a non-Ruby panic is re-raised untouched. +func acRecover(err *error) { + if r := recover(); r != nil { + if re, ok := r.(RubyError); ok { + *err = &acRubyErr{e: re} + return + } + panic(r) + } +} + +// acAround runs an around filter method, passing it a native yield block: when +// the Ruby method yields, the rest of the chain (and the action) runs, and any +// error it produces is re-raised at the yield point so the method may rescue it. +func (vm *VM) acAround(inst object.Value, spec acFilterSpec, yield func() error) (err error) { + defer acRecover(&err) + yp := &Proc{native: func(_ *VM, _ []object.Value) object.Value { + if e := yield(); e != nil { + panic(vm.acRubyErrorOf(e)) + } + return object.NilV + }} + vm.send(inst, spec.sym, nil, yp) + return nil +} + +// acFilterOpts maps a filter spec's only/except/if/unless conditions into the +// library's []controller.FilterOption. +func (vm *VM) acFilterOpts(inst object.Value, spec acFilterSpec) []controller.FilterOption { + var opts []controller.FilterOption + if len(spec.only) > 0 { + opts = append(opts, controller.Only(spec.only...)) + } + if len(spec.except) > 0 { + opts = append(opts, controller.Except(spec.except...)) + } + if spec.ifCond.set { + opts = append(opts, controller.If(vm.acCondFunc(inst, spec.ifCond))) + } + if spec.unlessCond.set { + opts = append(opts, controller.Unless(vm.acCondFunc(inst, spec.unlessCond))) + } + return opts +} + +// acCondFunc adapts an if:/unless: condition into a func(*Base) bool: a block +// runs against inst, a symbol sends the named predicate method. +func (vm *VM) acCondFunc(inst object.Value, cond acCond) func(*controller.Base) bool { + return func(_ *controller.Base) bool { + if cond.blk != nil { + return vm.callBlockSelf(cond.blk, inst, nil).Truthy() + } + return vm.send(inst, cond.sym, nil, nil).Truthy() + } +} + +// acRescueMatch adapts a rescue_from spec into the library's error matcher: the +// raised exception's Ruby class is compared against the declared classes. +func (vm *VM) acRescueMatch(spec acRescueSpec) func(error) bool { + return func(err error) bool { + ec := vm.classOf(vm.acErrValue(err)) + for _, c := range spec.classes { + if classIsA(ec, c) { + return true + } + } + return false + } +} + +// acRescueHandle adapts a rescue_from spec into the library's handler: it runs +// the block or sends the with: method against inst with the exception object. +func (vm *VM) acRescueHandle(inst object.Value, spec acRescueSpec) func(*controller.Base, error) error { + return func(_ *controller.Base, err error) error { + obj := vm.acErrValue(err) + if spec.blk != nil { + vm.callBlockSelf(spec.blk, inst, []object.Value{obj}) + } else { + vm.send(inst, spec.sym, []object.Value{obj}, nil) + } + return nil + } +} + +// acRenderer adapts a view context into the library's Renderer seam: it sends a +// `render` message with the render options as a Ruby Hash and returns the body. +func (vm *VM) acRenderer(inst object.Value, view object.Value) controller.Renderer { + return func(_ *controller.Base, opts controller.RenderOptions) (string, error) { + r := vm.send(view, "render", []object.Value{acRenderOptsHash(opts)}, nil) + if s, ok := r.(*object.String); ok { + return s.Str(), nil + } + return r.ToS(), nil + } +} + +// acErrValue maps a pipeline error back into the Ruby exception object a rescue +// handler receives. +func (vm *VM) acErrValue(err error) object.Value { + return vm.exceptionObject(vm.acRubyErrorOf(err)) +} + +// acRubyErrorOf converts a pipeline error into the RubyError to raise: a wrapped +// Ruby exception carries its original object; the only other error the pipeline +// yields is an unmapped action, reported as AbstractController::ActionNotFound. +func (vm *VM) acRubyErrorOf(err error) RubyError { + var re *acRubyErr + if errors.As(err, &re) { + return re.e + } + return RubyError{Class: "AbstractController::ActionNotFound", Message: err.Error()} +} + +// acFilterSpecs turns a before/after/around DSL call's arguments (symbol method +// names + a trailing options Hash) and optional block into filter specs: one per +// symbol plus one for the block, all sharing the only/except/if/unless options. +func acFilterSpecs(args []object.Value, blk *Proc) []acFilterSpec { + var only, except []string + var ifCond, unlessCond acCond + if h := lastHashOrNil(args); h != nil { + for _, k := range h.Keys { + val, _ := h.Get(k) + switch apStr(k) { + case "only": + only = apStrList(val) + case "except": + except = apStrList(val) + case "if": + ifCond = acCondFrom(val) + case "unless": + unlessCond = acCondFrom(val) + } + } + } + mk := func(blk *Proc, sym string) acFilterSpec { + return acFilterSpec{blk: blk, sym: sym, only: only, except: except, ifCond: ifCond, unlessCond: unlessCond} + } + var specs []acFilterSpec + for _, a := range args { + if _, ok := a.(*object.Hash); ok { + continue + } + specs = append(specs, mk(nil, apStr(a))) + } + if blk != nil { + specs = append(specs, mk(blk, "")) + } + return specs +} + +// acCondFrom reads an if:/unless: value into an acCond: a Proc (lambda) is a +// block condition, anything else a symbol predicate-method name. +func acCondFrom(v object.Value) acCond { + if p, ok := v.(*Proc); ok { + return acCond{blk: p, set: true} + } + return acCond{sym: apStr(v), set: true} +} + +// acRenderOptions builds a controller.RenderOptions from a render call: a leading +// String is the template/action name; the options Hash supplies plain/json/ +// template/action/status/content_type/layout. +func acRenderOptions(args []object.Value) controller.RenderOptions { + var o controller.RenderOptions + if len(args) > 0 { + if s, ok := args[0].(*object.String); ok { + o.Template = s.Str() + } + } + if h := lastHashOrNil(args); h != nil { + for _, k := range h.Keys { + val, _ := h.Get(k) + switch apStr(k) { + case "plain": + o.Plain = rackStr(val) + case "json": + o.JSON = rackToGo(val) + case "template": + o.Template = apStr(val) + case "action": + o.Action = apStr(val) + case "status": + o.Status = apInt(val) + case "content_type": + o.ContentType = rackStr(val) + case "layout": + o.Layout = apStr(val) + } + } + } + return o +} + +// acRenderOptsHash maps a controller.RenderOptions into the Ruby Hash a view +// context's #render receives (symbol keys), so the ActionView seam sees the +// full render request. +func acRenderOptsHash(opts controller.RenderOptions) *object.Hash { + h := object.NewHash() + h.Set(object.Symbol("template"), object.NewString(opts.Template)) + h.Set(object.Symbol("action"), object.NewString(opts.Action)) + h.Set(object.Symbol("plain"), object.NewString(opts.Plain)) + h.Set(object.Symbol("json"), rackFromGo(opts.JSON)) + h.Set(object.Symbol("status"), object.IntValue(int64(opts.Status))) + h.Set(object.Symbol("content_type"), object.NewString(opts.ContentType)) + h.Set(object.Symbol("layout"), object.NewString(opts.Layout)) + return h +} diff --git a/internal/vm/actionpack_dispatch.go b/internal/vm/actionpack_dispatch.go new file mode 100644 index 00000000..79f48144 --- /dev/null +++ b/internal/vm/actionpack_dispatch.go @@ -0,0 +1,125 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-ruby-actionpack/actionpack/dispatch" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file wires ActionDispatch::Request (over a Rack env: merged params, path +// parameters, format negotiation) and ActionDispatch::Response (the mutable Rack +// response). The env parsing, param merging and format negotiation are the +// library's; this shell maps values in and out. + +// registerACRequest installs the ActionDispatch::Request surface. +func (vm *VM) registerACRequest(cls *RClass) { + self := func(v object.Value) *dispatch.Request { return v.(*ACRequest).r } + + cls.smethods["new"] = &Method{name: "new", owner: cls, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return &ACRequest{r: dispatch.NewRequest(rackEnv(rackArg(args))), cls: cls} + }} + + // params — the merged strong-parameters view as ActionController::Parameters. + cls.define("params", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + p, err := self(v).Parameters() + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return &ACParams{p: p, cls: vm.cACParameters} + }) + + cls.define("query_parameters", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + m, err := self(v).QueryParameters() + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return rackFromGo(m) + }) + cls.define("request_parameters", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + m, err := self(v).RequestParameters() + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return rackFromGo(m) + }) + cls.define("path_parameters", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return rackFromGo(self(v).PathParameters()) + }) + cls.define("set_path_parameters", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetPathParameters(acAnyMap(lastHashOrNil(args))) + return object.NilV + }) + cls.define("controller_name", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).ControllerName()) + }) + cls.define("action_name", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).ActionName()) + }) + cls.define("format", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).Format()) + }) + cls.define("request_method", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).RequestMethod()) + }) + cls.define("path", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).PathInfo()) + }) +} + +// registerACResponse installs the ActionDispatch::Response surface. +func (vm *VM) registerACResponse(cls *RClass) { + self := func(v object.Value) *dispatch.Response { return v.(*ACResponse).r } + + cls.smethods["new"] = &Method{name: "new", owner: cls, native: func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return &ACResponse{r: dispatch.NewResponse(), cls: cls} + }} + + cls.define("status", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(self(v).Status())) + }) + cls.define("status=", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetStatus(apInt(apArg(args, 0))) + return apArg(args, 0) + }) + cls.define("write", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + for _, a := range args { + self(v).Write(rackStr(a)) + } + return object.NilV + }) + cls.define("body", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).BodyString()) + }) + cls.define("headers", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return rackHeadersToHash(self(v).Headers()) + }) + cls.define("[]", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + return rackFromGo(self(v).Headers().Get(apStr(apArg(args, 0)))) + }) + cls.define("[]=", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + self(v).Headers().Set(apStr(args[0]), rackStr(args[1])) + return args[1] + }) + cls.define("content_type=", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).SetContentType(rackStr(apArg(args, 0))) + return apArg(args, 0) + }) + cls.define("redirect", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + status := 302 + if len(args) > 1 { + status = apInt(args[1]) + } + self(v).Redirect(rackStr(args[0]), status) + return object.NilV + }) +} diff --git a/internal/vm/actionpack_internal_test.go b/internal/vm/actionpack_internal_test.go new file mode 100644 index 00000000..6857516c --- /dev/null +++ b/internal/vm/actionpack_internal_test.go @@ -0,0 +1,701 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "testing" + + "github.com/go-ruby-actionpack/actionpack/controller" + "github.com/go-ruby-actionpack/actionpack/dispatch" + "github.com/go-ruby-actionpack/actionpack/parameters" + rack "github.com/go-ruby-rack/rack" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// TestActionPackValueInspect covers the value wrappers' string/truthy surface. +func TestActionPackValueInspect(t *testing.T) { + rs := &ACRouteSet{} + mp := &ACMapper{} + rq := &ACRequest{} + rp := &ACResponse{} + for _, c := range []struct { + v interface{ ToS() string } + want string + }{ + {rs, "#"}, + {mp, "#"}, + {rq, "#"}, + {rp, "#"}, + } { + if c.v.ToS() != c.want { + t.Errorf("ToS = %q, want %q", c.v.ToS(), c.want) + } + } + if !rs.Truthy() || !mp.Truthy() || !rq.Truthy() || !rp.Truthy() { + t.Error("Truthy should be true") + } + if rs.Inspect() != rs.ToS() || mp.Inspect() != mp.ToS() || rq.Inspect() != rq.ToS() || rp.Inspect() != rp.ToS() { + t.Error("Inspect should equal ToS") + } + // ACParams delegates ToS/Inspect to the library's String(). + pw := &ACParams{p: parameters.New(map[string]any{"a": int64(1)})} + if pw.ToS() != pw.Inspect() || pw.ToS() == "" || !pw.Truthy() { + t.Errorf("ACParams ToS/Inspect/Truthy: %q", pw.ToS()) + } +} + +// TestActionPackHelpers covers the standalone coercion helpers' every branch. +func TestActionPackHelpers(t *testing.T) { + if apStr(object.NewString("s")) != "s" || apStr(object.Symbol("y")) != "y" || apStr(object.IntValue(3)) != "3" { + t.Error("apStr") + } + if apInt(object.IntValue(5)) != 5 || apInt(object.Float(2.9)) != 2 || apInt(object.NewString("x")) != 0 { + t.Error("apInt") + } + if apArg([]object.Value{object.IntValue(1)}, 0) != object.IntValue(1) || apArg(nil, 2) != object.NilV { + t.Error("apArg") + } + if l := apStrList(object.NewArray(object.Symbol("a"), object.NewString("b"))); len(l) != 2 || l[0] != "a" || l[1] != "b" { + t.Error("apStrList array") + } + if l := apStrList(object.Symbol("only")); len(l) != 1 || l[0] != "only" { + t.Error("apStrList single") + } + if apStrMap(object.NilV) != nil { + t.Error("apStrMap non-hash") + } + h := object.NewHash() + h.Set(object.Symbol("id"), object.NewString(`\d+`)) + if m := apStrMap(h); m["id"] != `\d+` { + t.Error("apStrMap hash") + } + if lastHashOrNil([]object.Value{object.IntValue(1)}) != nil { + t.Error("lastHashOrNil non-hash") + } + // acErrValue reuses acRubyErrorOf; the ActionNotFound branch is covered here. + vm := New(nil) + nf := &controller.ActionNotFound{Controller: "c", Action: "a"} + if got := vm.acRubyErrorOf(nf); got.Class != "AbstractController::ActionNotFound" { + t.Errorf("acRubyErrorOf(ActionNotFound) class = %q", got.Class) + } + if obj := vm.acErrValue(nf); vm.classOf(obj).name != "AbstractController::ActionNotFound" { + t.Errorf("acErrValue(ActionNotFound) class = %q", vm.classOf(obj).name) + } + if (&acRubyErr{e: RubyError{Class: "X", Message: "y"}}).Error() != "X: y" { + t.Error("acRubyErr.Error") + } +} + +// --- routing --------------------------------------------------------------- + +func TestActionPackRoutingDraw(t *testing.T) { + src := ` +routes = ActionDispatch::Routing::RouteSet.new +routes.draw do + get "posts", to: "posts#index", as: "posts" + get "posts/:id", to: "posts#show", as: "post" + post "posts", to: "posts#create" + put "posts/:id", to: "posts#update" + patch "posts/:id", to: "posts#patch_it" + delete "posts/:id", to: "posts#destroy" + match "ping", to: "meta#ping", via: [:get, :head] + root to: "home#index" +end +puts routes.class +r = routes.recognize("GET", "/posts/7") +puts r["controller"] +puts r["action"] +puts r["id"] +puts routes.recognize("GET", "/nope").inspect +puts routes.path("post", id: 3) +puts routes.path_args("post", 9) +puts routes.url_for(controller: "posts", action: "index") +puts routes.routes.length +puts routes.routes[0]["verb"] +` + got := acRun(t, src) + want := "ActionDispatch::Routing::RouteSet\nposts\nshow\n7\nnil\n/posts/3\n/posts/9\n/posts\n9\nGET" + if got != want { + t.Errorf("routing draw:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackRoutingResources(t *testing.T) { + src := ` +routes = ActionDispatch::Routing::RouteSet.new +routes.draw do + resources :posts, only: [:index, :show] do + member { get "preview", to: "posts#preview" } + collection { get "search", to: "posts#search" } + end + resources :photos, except: [:destroy] + resource :profile + resources :legacy, path: "old", controller: "legacy_ctrl", param: "key" + namespace :admin do + resources :users + end + scope path: "api", module: "api", as: "api" do + get "status", to: "status#show", as: "status" + end + constraints id: /\d+/ do + get "num/:id", to: "num#show" + end +end +puts routes.recognize("GET", "/posts")["action"] +puts routes.recognize("GET", "/posts/1")["action"] +puts routes.recognize("GET", "/posts/1/preview")["action"] +puts routes.recognize("GET", "/posts/search")["action"] +puts routes.recognize("GET", "/profile")["action"] +puts routes.recognize("GET", "/admin/users")["controller"] +puts routes.recognize("GET", "/api/status")["controller"] +puts routes.recognize("GET", "/num/12")["action"] +` + got := acRun(t, src) + want := "index\nshow\npreview\nsearch\nshow\nadmin/users\napi/status\nshow" + if got != want { + t.Errorf("routing resources:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackRoutingErrors(t *testing.T) { + cases := []struct{ src, want string }{ + {`ActionDispatch::Routing::RouteSet.new.draw`, "ArgumentError"}, + {`ActionDispatch::Routing::RouteSet.new.draw { get "x(", to: "a#b" }`, "ArgumentError"}, + {`ActionDispatch::Routing::RouteSet.new.path("nope")`, "ActionController::UrlGenerationError"}, + {`r=ActionDispatch::Routing::RouteSet.new; r.draw { get "p/:id", to: "a#b", as: "p" }; r.path("p")`, "ActionController::UrlGenerationError"}, + {`ActionDispatch::Routing::RouteSet.new.path_args("nope")`, "ActionController::UrlGenerationError"}, + {`r=ActionDispatch::Routing::RouteSet.new; r.draw { get "p/:id", to: "a#b", as: "p" }; r.path_args("p", 1, 2)`, "ActionController::UrlGenerationError"}, + {`ActionDispatch::Routing::RouteSet.new.path_args`, "ArgumentError"}, + {`ActionDispatch::Routing::RouteSet.new.url_for(controller: "x", action: "y")`, "ActionController::UrlGenerationError"}, + {`ActionDispatch::Routing::RouteSet.new.draw { namespace(:a) }`, "ArgumentError"}, + } + for _, c := range cases { + if got := acRunErr(t, c.src); got != c.want { + t.Errorf("err(%q) = %q, want %q", c.src, got, c.want) + } + } +} + +func TestActionPackRoutingDefaultsAndScopeExtras(t *testing.T) { + // defaults:, constraints:, controller:/action:, on:, scope defaults/constraints/as. + src := ` +routes = ActionDispatch::Routing::RouteSet.new +routes.draw do + get "widgets", controller: "widgets", action: "index", defaults: { fmt: "json" } + get "items/:id", to: "items#show", constraints: { id: "[0-9]+" } + scope path: "s", as: "s", constraints: { x: "1" }, defaults: { d: "2" } do + get "inner", to: "inner#go" + end +end +r = routes.recognize("GET", "/widgets") +puts r["controller"] +puts r["fmt"] +puts routes.recognize("GET", "/items/5")["action"] +puts routes.recognize("GET", "/s/inner")["controller"] +` + got := acRun(t, src) + want := "widgets\njson\nshow\ninner" + if got != want { + t.Errorf("routing defaults:\n got=%q\nwant=%q", got, want) + } +} + +// --- parameters ------------------------------------------------------------ + +func TestActionPackParameters(t *testing.T) { + src := ` +p = ActionController::Parameters.new(name: "Bob", age: 30, tags: ["a","b"], user: { email: "e", role: "r" }) +puts p.class +puts p.permitted? +puts p.key?(:name) +puts p.has_key?(:missing) +puts p.keys.sort.join(",") +permitted = p.permit(:name, tags: [], user: [:email]) +puts permitted.permitted? +puts permitted[:name] +puts permitted[:missing].inspect +puts permitted.to_h["name"] +puts p.to_unsafe_h["age"] +puts p.require(:name) +nested = p.require(:user) +puts nested.class +puts nested[:email] +vals = p.require([:name, :age]) +puts vals.join("/") +merged = p.merge(ActionController::Parameters.new(name: "Alice")) +puts merged[:name] +` + got := acRun(t, src) + want := "ActionController::Parameters\nfalse\ntrue\nfalse\nage,name,tags,user\ntrue\nBob\nnil\nBob\n30\nBob\nActionController::Parameters\ne\nBob/30\nAlice" + if got != want { + t.Errorf("parameters:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackParametersErrors(t *testing.T) { + cases := []struct{ src, want string }{ + {`ActionController::Parameters.new(a: 1).require(:missing)`, "ActionController::ParameterMissing"}, + {`ActionController::Parameters.new(a: 1).require([:a, :missing])`, "ActionController::ParameterMissing"}, + {`ActionController::Parameters.new(a: 1).to_h`, "ActionController::UnfilteredParameters"}, + {`ActionController::Parameters.new(a: 1).merge(42)`, "TypeError"}, + } + for _, c := range cases { + if got := acRunErr(t, c.src); got != c.want { + t.Errorf("err(%q) = %q, want %q", c.src, got, c.want) + } + } +} + +// --- dispatch (Request/Response) ------------------------------------------- + +func TestActionPackDispatchRequest(t *testing.T) { + src := ` +req = ActionDispatch::Request.new("REQUEST_METHOD" => "GET", "PATH_INFO" => "/posts", "QUERY_STRING" => "id=5&q=hi") +req.set_path_parameters(controller: "posts", action: "show") +puts req.class +puts req.request_method +puts req.path +puts req.controller_name +puts req.action_name +puts req.format +puts req.params[:id] +puts req.query_parameters["q"] +puts req.request_parameters.length +puts req.path_parameters["action"] +` + got := acRun(t, src) + want := "ActionDispatch::Request\nGET\n/posts\nposts\nshow\nhtml\n5\nhi\n0\nshow" + if got != want { + t.Errorf("dispatch request:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackDispatchRequestErrors(t *testing.T) { + bad := `ActionDispatch::Request.new("QUERY_STRING" => "a=%zz")` + for _, m := range []string{".params", ".query_parameters"} { + if got := acRunErr(t, bad+m); got != "ArgumentError" { + t.Errorf("err(%s) = %q, want ArgumentError", m, got) + } + } +} + +// TestActionPackRequestParamsError covers the request_parameters (POST body) +// parse-error branch, which needs a real rack.Input the Ruby env cannot express. +func TestActionPackRequestParamsError(t *testing.T) { + vm := New(nil) + env := rack.Env{ + "REQUEST_METHOD": "POST", + "CONTENT_TYPE": "application/x-www-form-urlencoded", + "rack.input": &acBadInput{data: []byte("a=%zz")}, + } + req := &ACRequest{r: dispatch.NewRequest(env), cls: vm.cACRequest} + var class string + func() { + defer func() { + if r := recover(); r != nil { + if re, ok := r.(RubyError); ok { + class = re.Class + } + } + }() + vm.send(req, "request_parameters", nil, nil) + }() + if class != "ArgumentError" { + t.Errorf("request_parameters bad body raised %q, want ArgumentError", class) + } +} + +// acBadInput is a one-shot rack.Input returning a malformed urlencoded body. +type acBadInput struct { + data []byte + done bool +} + +func (b *acBadInput) Read(int) ([]byte, error) { + if b.done { + return nil, nil + } + b.done = true + return b.data, nil +} + +func TestActionPackDispatchResponse(t *testing.T) { + src := ` +resp = ActionDispatch::Response.new +puts resp.class +puts resp.status +resp.status = 201 +resp.write("hello ") +resp.write("world") +puts resp.status +puts resp.body +resp["X-Test"] = "yes" +resp.content_type = "text/plain" +puts resp["X-Test"] +puts resp.headers["content-type"] +resp2 = ActionDispatch::Response.new +resp2.redirect("/here", 303) +puts resp2.status +puts resp2.headers["location"] +` + got := acRun(t, src) + want := "ActionDispatch::Response\n200\n201\nhello world\nyes\ntext/plain\n303\n/here" + if got != want { + t.Errorf("dispatch response:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackDispatchResponseErrors(t *testing.T) { + cases := []struct{ src, want string }{ + {`r=ActionDispatch::Response.new; r["only-key"]=nil; r.send(:[]=, "k")`, "ArgumentError"}, + {`ActionDispatch::Response.new.redirect`, "ArgumentError"}, + } + for _, c := range cases { + if got := acRunErr(t, c.src); got != c.want { + t.Errorf("err(%q) = %q, want %q", c.src, got, c.want) + } + } +} + +// --- controller ------------------------------------------------------------ + +func TestActionPackControllerBasic(t *testing.T) { + src := ` +class PostsController < ActionController::Base + def index + render plain: "all posts" + end + def show + render plain: "post #{params[:id]}", status: 200, content_type: "text/plain" + end + def by_name + render "posts/named" + end + def created + render json: { ok: true }, status: 201, layout: "app", action: "created" + end + def go_away + redirect_to "/login" + end + def go_perm + redirect_to "/new", status: 301 + end + def gone + head 410 + end + def whoami + render plain: "#{action_name}/#{performed?}/#{request.request_method}/#{response.status}" + end +end +puts PostsController.dispatch(:index)[2][0] +puts PostsController.dispatch(:show, "QUERY_STRING" => "id=7")[2][0] +puts PostsController.dispatch(:go_away)[0] +puts PostsController.dispatch(:go_away, {})[1]["location"] +puts PostsController.dispatch(:go_perm)[0] +puts PostsController.dispatch(:gone)[0] +puts PostsController.dispatch(:whoami, "REQUEST_METHOD" => "POST")[2][0] +puts PostsController.dispatch(:created)[0] +puts PostsController.dispatch(:by_name)[0] +` + got := acRun(t, src) + want := "all posts\npost 7\n302\n/login\n301\n410\nwhoami/false/POST/200\n201\n200" + if got != want { + t.Errorf("controller basic:\n got=%q\nwant=%q", got, want) + } +} + +// TestActionPackControllerEdgeCases covers the remaining seam branches: an +// around filter whose inner action raises (re-raised at the yield point), a +// non-Ruby panic (throw) surfacing through the body recover, and a dispatch +// whose request params fail to parse. +func TestActionPackControllerEdgeCases(t *testing.T) { + aroundRaise := ` +class AroundRaiseController < ActionController::Base + around_action :wrap + def wrap; yield; end + def boom; raise "inner"; end +end +AroundRaiseController.dispatch(:boom) +` + if got := acRunErr(t, aroundRaise); got != "RuntimeError" { + t.Errorf("around raise = %q, want RuntimeError", got) + } + + throwSrc := ` +class ThrowController < ActionController::Base + def t; throw :nope; end +end +ThrowController.dispatch(:t) +` + if got := acRunErr(t, throwSrc); got == "" { + t.Error("throw should surface an uncaught error") + } + + badQuery := ` +class QController < ActionController::Base + def index; render plain: "x"; end +end +QController.dispatch(:index, "QUERY_STRING" => "a=%zz") +` + if got := acRunErr(t, badQuery); got != "ArgumentError" { + t.Errorf("dispatch bad query = %q, want ArgumentError", got) + } +} + +// TestActionPackRoutingExtras covers the routing-helper branches not hit by the +// main DSL tests: a verb with no options, `on:`, a positional/empty root target, +// a bare scope, and path_args with a trailing options Hash. +func TestActionPackRoutingExtras(t *testing.T) { + src := ` +r1 = ActionDispatch::Routing::RouteSet.new +r1.draw do + get "healthz" + get to: "home#dashboard" + resources :things do + get "activate", to: "things#activate", on: :member + end + root "welcome#index" + scope do + get "bare", to: "bare#go" + end +end +puts r1.recognize("GET", "/things/1/activate")["action"] +puts r1.recognize("GET", "/")["controller"] +puts r1.recognize("GET", "/bare")["controller"] +puts r1.path_args("thing", 5, format: "json") + +r2 = ActionDispatch::Routing::RouteSet.new +r2.draw { root } +puts r2.recognize("GET", "/")["controller"].inspect +` + got := acRun(t, src) + want := "activate\nhome\nbare\n/things/5.json\n\"\"" + if got != want { + t.Errorf("routing extras:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackControllerFilters(t *testing.T) { + src := ` +class AppController < ActionController::Base + before_action :log_start + def log_start + @trace = "start" + end +end +class GuardedController < AppController + before_action :require_login, only: [:secret] + before_action { @trace = "#{@trace}|blk" } + after_action :log_end, except: [:secret] + around_action :timing + before_action :block_it, if: :blocked?, unless: -> { false } + + def open + render plain: "#{@trace}|open|#{@timing}" + end + def secret + render plain: "secret" + end + def blocked + render plain: "should not reach" + end + + def require_login + render plain: "denied", status: 403 + end + def log_end + @trace = "#{@trace}|end" + end + def timing + @timing = "T0" + yield + @timing = "T1" + end + def blocked? + action_name == "blocked" + end + def block_it + render plain: "blocked!", status: 400 + end +end +puts GuardedController.dispatch(:open)[2][0] +puts GuardedController.dispatch(:secret)[0] +puts GuardedController.dispatch(:secret)[2][0] +puts GuardedController.dispatch(:blocked)[0] +puts GuardedController.dispatch(:blocked)[2][0] +` + got := acRun(t, src) + // open: before log_start ("start") + block ("|blk"), around sets @timing T0 before + // action runs (render reads it), timing filter runs, no login guard (only :secret), + // no block_it (blocked? false for "open"). + want := "start|blk|open|T0\n403\ndenied\n400\nblocked!" + if got != want { + t.Errorf("controller filters:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackControllerRescue(t *testing.T) { + src := ` +class RescuingController < ActionController::Base + rescue_from ArgumentError do |e| + render plain: "rescued: #{e.message}", status: 422 + end + rescue_from RuntimeError, with: :handle_runtime + rescue_from AbstractController::ActionNotFound do |e| + render plain: "no action", status: 404 + end + + def boom + raise ArgumentError, "bad arg" + end + def kaboom + raise "runtime issue" + end + def handle_runtime(e) + render plain: "runtime: #{e.message}", status: 500 + end +end +puts RescuingController.dispatch(:boom)[0] +puts RescuingController.dispatch(:boom)[2][0] +puts RescuingController.dispatch(:kaboom)[2][0] +puts RescuingController.dispatch(:no_such_action)[0] +puts RescuingController.dispatch(:no_such_action)[2][0] +` + got := acRun(t, src) + want := "422\nrescued: bad arg\nruntime: runtime issue\n404\nno action" + if got != want { + t.Errorf("controller rescue:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackControllerRescueMiss(t *testing.T) { + // rescue_from that does not match the raised error -> propagates to Ruby. + src := ` +class NarrowController < ActionController::Base + rescue_from ArgumentError do |e| render plain: "nope" end + def boom; raise "not an ArgumentError"; end +end +NarrowController.dispatch(:boom) +` + if got := acRunErr(t, src); got != "RuntimeError" { + t.Errorf("rescue miss = %q, want RuntimeError", got) + } + // A truly unmapped action with no rescue -> AbstractController::ActionNotFound. + src2 := ` +class BareController < ActionController::Base; end +BareController.dispatch(:ghost) +` + if got := acRunErr(t, src2); got != "AbstractController::ActionNotFound" { + t.Errorf("bare missing action = %q, want AbstractController::ActionNotFound", got) + } +} + +func TestActionPackControllerViewContext(t *testing.T) { + src := ` +class StringView + def render(opts) + "view[#{opts[:template]}/#{opts[:action]}/#{opts[:plain]}/#{opts[:status]}/#{opts[:layout]}]" + end +end +class IntView + def render(opts); 4242; end +end +class ViewedController < ActionController::Base + view_context StringView.new + def show; render template: "posts/show", status: 200, layout: "main"; end +end +class IntViewedController < ActionController::Base + view_context IntView.new + def show; render plain: "ignored"; end +end +puts ViewedController.dispatch(:show)[2][0] +puts IntViewedController.dispatch(:show)[2][0] +` + got := acRun(t, src) + want := "view[posts/show///200/main]\n4242" + if got != want { + t.Errorf("controller view context:\n got=%q\nwant=%q", got, want) + } +} + +func TestActionPackControllerDoubleRender(t *testing.T) { + src := ` +class DoubleController < ActionController::Base + def twice + render plain: "one" + render plain: "two" + end + def render_then_redirect + render plain: "x" + redirect_to "/y" + end + def double_head + head 200 + head 201 + end +end +DoubleController.dispatch(:twice) +` + if got := acRunErr(t, src); got != "AbstractController::DoubleRenderError" { + t.Errorf("double render = %q", got) + } + for _, a := range []string{":render_then_redirect", ":double_head"} { + s := ` +class DoubleController < ActionController::Base + def render_then_redirect; render plain: "x"; redirect_to "/y"; end + def double_head; head 200; head 201; end +end +DoubleController.dispatch(` + a + `)` + if got := acRunErr(t, s); got != "AbstractController::DoubleRenderError" { + t.Errorf("double(%s) = %q", a, got) + } + } +} + +func TestActionPackControllerNoArgErrors(t *testing.T) { + src := ` +class ArgController < ActionController::Base + def bad; redirect_to; end +end +ArgController.dispatch(:bad) +` + if got := acRunErr(t, src); got != "ArgumentError" { + t.Errorf("redirect_to no arg = %q", got) + } +} + +func TestActionPackControllerRescueWithBlockException(t *testing.T) { + // A rescue_from whose exception has a plain message; covers acErrValue's + // acRubyErr path and the with:-method handler branch already; here we cover a + // controller whose action defines its own render context via inheritance. + src := ` +class BaseWithRescue < ActionController::Base + rescue_from StandardError, with: :on_error + def on_error(e); render plain: "handled #{e.class}"; end +end +class ChildController < BaseWithRescue + def boom; raise ArgumentError, "x"; end +end +puts ChildController.dispatch(:boom)[2][0] +` + got := acRun(t, src) + want := "handled ArgumentError" + if got != want { + t.Errorf("inherited rescue:\n got=%q\nwant=%q", got, want) + } +} + +// TestActionPackRescueFromArgError covers rescue_from with no class argument. +func TestActionPackRescueFromArgError(t *testing.T) { + src := ` +class NoClassController < ActionController::Base + rescue_from with: :x +end +` + if got := acRunErr(t, src); got != "ArgumentError" { + t.Errorf("rescue_from no class = %q, want ArgumentError", got) + } +} diff --git a/internal/vm/actionpack_params.go b/internal/vm/actionpack_params.go new file mode 100644 index 00000000..86662494 --- /dev/null +++ b/internal/vm/actionpack_params.go @@ -0,0 +1,132 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-ruby-actionpack/actionpack/parameters" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file wires ActionController::Parameters (Rails strong parameters): +// .new(hash), permit/require, [], to_h/to_unsafe_h, permitted?, key?, keys and +// merge. The permit/require filtering semantics are the library's; this shell +// only maps Ruby values in and out. + +// registerACParameters installs the ActionController::Parameters surface. +func (vm *VM) registerACParameters(cls *RClass) { + self := func(v object.Value) *parameters.Parameters { return v.(*ACParams).p } + + cls.smethods["new"] = &Method{name: "new", owner: cls, native: func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return &ACParams{p: parameters.New(acAnyMap(lastHashOrNil(args))), cls: cls} + }} + + // permit(*filters) — return a new, permitted Parameters filtered by the + // String/Array/Hash filter list. + cls.define("permit", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + return &ACParams{p: self(v).Permit(acPermitFilters(args)...), cls: vm.cACParameters} + }) + + // require(key) — return the value under key (raising ParameterMissing when + // absent/blank); require([:a, :b]) requires each in turn. + cls.define("require", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + if arr, ok := apArg(args, 0).(*object.Array); ok { + keys := make([]string, len(arr.Elems)) + for i, e := range arr.Elems { + keys[i] = apStr(e) + } + vals, err := self(v).RequireAll(keys) + if err != nil { + raise("ActionController::ParameterMissing", "%s", err.Error()) + } + out := make([]object.Value, len(vals)) + for i, val := range vals { + out[i] = vm.acParamValue(val) + } + return object.NewArrayFromSlice(out) + } + val, err := self(v).Require(apStr(apArg(args, 0))) + if err != nil { + raise("ActionController::ParameterMissing", "%s", err.Error()) + } + return vm.acParamValue(val) + }) + + // [] — read the value under key (nil when absent), matching Rails' accessor. + cls.define("[]", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + val, ok := self(v).Get(apStr(apArg(args, 0))) + if !ok { + return object.NilV + } + return vm.acParamValue(val) + }) + + // to_h — a deep plain-Hash of the permitted params (raising when unpermitted). + cls.define("to_h", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + m, err := self(v).ToH() + if err != nil { + raise("ActionController::UnfilteredParameters", "%s", err.Error()) + } + return rackFromGo(m) + }) + + // to_unsafe_h — a deep plain-Hash regardless of the permitted flag. + cls.define("to_unsafe_h", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return rackFromGo(self(v).ToUnsafeH()) + }) + + cls.define("permitted?", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).Permitted()) + }) + + keyP := func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).Has(apStr(apArg(args, 0)))) + } + cls.define("key?", keyP) + cls.define("has_key?", keyP) + + cls.define("keys", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + ks := self(v).Keys() + out := make([]object.Value, len(ks)) + for i, k := range ks { + out[i] = object.NewString(k) + } + return object.NewArrayFromSlice(out) + }) + + // merge(other) — a new Parameters overlaid by another Parameters' pairs. + cls.define("merge", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + other, ok := apArg(args, 0).(*ACParams) + if !ok { + raise("TypeError", "no implicit conversion into ActionController::Parameters") + } + return &ACParams{p: self(v).Merge(other.p), cls: vm.cACParameters} + }) +} + +// acParamValue maps a strong-parameters value (as returned by Get/Require) back +// into the rbgo object graph: a nested *parameters.Parameters becomes a wrapped +// ActionController::Parameters, everything else its rackFromGo form. +func (vm *VM) acParamValue(v any) object.Value { + if p, ok := v.(*parameters.Parameters); ok { + return &ACParams{p: p, cls: vm.cACParameters} + } + return rackFromGo(v) +} + +// acPermitFilters maps permit's arguments into the []any filter list the library +// consumes: a String/Symbol is a scalar key, a Hash (key => [] / [inner] / {}) +// is a nested/array filter mapped through rackToGo. +func acPermitFilters(args []object.Value) []any { + out := make([]any, 0, len(args)) + for _, a := range args { + if h, ok := a.(*object.Hash); ok { + out = append(out, acAnyMap(h)) + continue + } + out = append(out, apStr(a)) + } + return out +} diff --git a/internal/vm/actionpack_routing.go b/internal/vm/actionpack_routing.go new file mode 100644 index 00000000..a42cd39a --- /dev/null +++ b/internal/vm/actionpack_routing.go @@ -0,0 +1,339 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "github.com/go-ruby-actionpack/actionpack/routing" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file wires ActionDispatch::Routing::RouteSet.new, its `draw` DSL and the +// recognize / path / url_for reverse-routing helpers, plus the Mapper DSL self +// the draw block runs against (verb matchers, resources/resource, root, +// namespace/scope, member/collection, constraints). The route compiler, +// recognizer and generator are the library; running the DSL block is the seam. + +// registerACRouteSet installs ActionDispatch::Routing::RouteSet and its Mapper +// DSL surface. +func (vm *VM) registerACRouteSet(routeSet, mapper *RClass) { + routeSet.smethods["new"] = &Method{name: "new", owner: routeSet, native: func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return &ACRouteSet{rs: routing.NewRouteSet(), cls: routeSet} + }} + + // draw do … end — evaluate the routing DSL against a Mapper wrapper. + routeSet.define("draw", func(vm *VM, self object.Value, _ []object.Value, blk *Proc) object.Value { + if blk == nil { + raise("ArgumentError", "RouteSet#draw requires a block") + } + rs := self.(*ACRouteSet) + err := rs.rs.Draw(func(m *routing.Mapper) { + vm.callBlockSelf(blk, &ACMapper{m: m, cls: vm.cACMapper}, nil) + }) + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + return object.NilV + }) + + // recognize(method, path) — resolve a request to {controller, action, params}, + // or nil when nothing matches. + routeSet.define("recognize", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + rec, ok := self.(*ACRouteSet).rs.Recognize(apStr(apArg(args, 0)), apStr(apArg(args, 1))) + if !ok { + return object.NilV + } + return rackFromGo(rec.Params) + }) + + // path(name, **params) — the named-route path helper (post_path(id: 1)). + routeSet.define("path", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s, err := self.(*ACRouteSet).rs.Path(apStr(apArg(args, 0)), acAnyMap(lastHashOrNil(args))) + if err != nil { + raise("ActionController::UrlGenerationError", "%s", err.Error()) + } + return object.NewString(s) + }) + + // path_args(name, *args) — positional path helper (post_path(1)); a trailing + // Hash supplies extra options. + routeSet.define("path_args", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") + } + s, err := self.(*ACRouteSet).rs.PathArgs(apStr(args[0]), acAnyArgs(args[1:])...) + if err != nil { + raise("ActionController::UrlGenerationError", "%s", err.Error()) + } + return object.NewString(s) + }) + + // url_for(controller:, action:, **params) — reverse routing from an options + // Hash. + routeSet.define("url_for", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s, err := self.(*ACRouteSet).rs.UrlFor(acAnyMap(lastHashOrNil(args))) + if err != nil { + raise("ActionController::UrlGenerationError", "%s", err.Error()) + } + return object.NewString(s) + }) + + // routes — the routes in definition order, each as a small Hash. + routeSet.define("routes", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + routes := self.(*ACRouteSet).rs.Routes() + out := make([]object.Value, len(routes)) + for i, r := range routes { + h := object.NewHash() + h.Set(object.NewString("name"), object.NewString(r.Name)) + h.Set(object.NewString("verb"), object.NewString(r.Verb)) + h.Set(object.NewString("spec"), object.NewString(r.Spec)) + h.Set(object.NewString("controller"), object.NewString(r.Controller)) + h.Set(object.NewString("action"), object.NewString(r.Action)) + out[i] = h + } + return object.NewArrayFromSlice(out) + }) + + vm.registerACMapper(mapper) +} + +// registerACMapper installs the routes-DSL surface a `draw` block runs against. +func (vm *VM) registerACMapper(cls *RClass) { + self := func(v object.Value) *routing.Mapper { return v.(*ACMapper).m } + + // Verb matchers: get/post/put/patch/delete "path", to:, as:, via:, … + for name, fn := range map[string]func(*routing.Mapper, string, ...routing.Option){ + "get": (*routing.Mapper).Get, + "post": (*routing.Mapper).Post, + "put": (*routing.Mapper).Put, + "patch": (*routing.Mapper).Patch, + "delete": (*routing.Mapper).Delete, + "match": (*routing.Mapper).Match, + } { + verb := fn + cls.define(name, func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + verb(self(v), acPath(args), acRouteOptions(args)...) + return object.NilV + }) + } + + // root to: "home#index" (or root "home#index"). + cls.define("root", func(vm *VM, v object.Value, args []object.Value, _ *Proc) object.Value { + self(v).Root(acTarget(args)) + return object.NilV + }) + + // resources :posts [do … end] — a plural RESTful resource. + cls.define("resources", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + self(v).Resources(apStr(apArg(args, 0)), vm.acSubMapper(blk), acResOptions(args)...) + return object.NilV + }) + + // resource :profile [do … end] — a singular RESTful resource. + cls.define("resource", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + self(v).Resource(apStr(apArg(args, 0)), vm.acSubMapper(blk), acResOptions(args)...) + return object.NilV + }) + + // namespace :admin do … end — a path/module/name-prefixed block. + cls.define("namespace", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + self(v).Namespace(apStr(apArg(args, 0)), vm.acReqBlock(blk, "namespace")) + return object.NilV + }) + + // scope path:/module:/as: do … end — a scoped block. + cls.define("scope", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + self(v).Scope(acScopeOpts(args), vm.acReqBlock(blk, "scope")) + return object.NilV + }) + + // member / collection do … end — resource-level route groups. + cls.define("member", func(vm *VM, v object.Value, _ []object.Value, blk *Proc) object.Value { + self(v).Member(vm.acReqBlock(blk, "member")) + return object.NilV + }) + cls.define("collection", func(vm *VM, v object.Value, _ []object.Value, blk *Proc) object.Value { + self(v).Collection(vm.acReqBlock(blk, "collection")) + return object.NilV + }) + + // constraints(id: /\d+/) do … end — added segment constraints. + cls.define("constraints", func(vm *VM, v object.Value, args []object.Value, blk *Proc) object.Value { + self(v).ConstraintsBlock(apStrMap(apArg(args, 0)), vm.acReqBlock(blk, "constraints")) + return object.NilV + }) +} + +// acSubMapper adapts an optional resources/resource block into the library's +// func(*Mapper): nil when no block was given (the resource generates its default +// routes), else a closure running the block against a sub-Mapper wrapper. +func (vm *VM) acSubMapper(blk *Proc) func(*routing.Mapper) { + if blk == nil { + return nil + } + return func(sub *routing.Mapper) { + vm.callBlockSelf(blk, &ACMapper{m: sub, cls: vm.cACMapper}, nil) + } +} + +// acReqBlock adapts a required DSL block into the library's func(*Mapper), +// raising when the block is absent (namespace/scope/member/collection/ +// constraints all take a mandatory block). +func (vm *VM) acReqBlock(blk *Proc, name string) func(*routing.Mapper) { + if blk == nil { + raise("ArgumentError", "%s requires a block", name) + } + return func(sub *routing.Mapper) { + vm.callBlockSelf(blk, &ACMapper{m: sub, cls: vm.cACMapper}, nil) + } +} + +// acPath returns the route path (the first positional, non-Hash argument), or +// "/" when only keyword options were given. +func acPath(args []object.Value) string { + if len(args) > 0 { + if _, ok := args[0].(*object.Hash); !ok { + return apStr(args[0]) + } + } + return "/" +} + +// acTarget returns a route's dispatch target: the first positional String, else +// the `to:` option ("controller#action"). +func acTarget(args []object.Value) string { + if len(args) > 0 { + if _, ok := args[0].(*object.Hash); !ok { + return apStr(args[0]) + } + } + if h := lastHashOrNil(args); h != nil { + if v, ok := h.Get(object.Symbol("to")); ok { + return apStr(v) + } + } + return "" +} + +// acRouteOptions reads a route declaration's keyword options into the library's +// []routing.Option (to:/as:/via:/action:/controller:/constraints:/defaults:/on:). +func acRouteOptions(args []object.Value) []routing.Option { + h := lastHashOrNil(args) + if h == nil { + return nil + } + var out []routing.Option + for _, k := range h.Keys { + val, _ := h.Get(k) + switch apStr(k) { + case "to": + out = append(out, routing.To(apStr(val))) + case "as": + out = append(out, routing.As(apStr(val))) + case "via": + out = append(out, routing.Via(apStrList(val)...)) + case "action": + out = append(out, routing.Action(apStr(val))) + case "controller": + out = append(out, routing.Controller(apStr(val))) + case "constraints": + out = append(out, routing.Constraints(apStrMap(val))) + case "defaults": + out = append(out, routing.Defaults(apStrMap(val))) + case "on": + out = append(out, routing.On(apStr(val))) + } + } + return out +} + +// acResOptions reads a resources/resource declaration's keyword options into the +// library's []routing.ResOption (only:/except:/path:/controller:/param:). +func acResOptions(args []object.Value) []routing.ResOption { + h := lastHashOrNil(args) + if h == nil { + return nil + } + var out []routing.ResOption + for _, k := range h.Keys { + val, _ := h.Get(k) + switch apStr(k) { + case "only": + out = append(out, routing.Only(apStrList(val)...)) + case "except": + out = append(out, routing.Except(apStrList(val)...)) + case "path": + out = append(out, routing.PathName(apStr(val))) + case "controller": + out = append(out, routing.ResController(apStr(val))) + case "param": + out = append(out, routing.ResParam(apStr(val))) + } + } + return out +} + +// acScopeOpts reads a scope declaration's keyword options into routing.ScopeOpts. +func acScopeOpts(args []object.Value) routing.ScopeOpts { + var opts routing.ScopeOpts + h := lastHashOrNil(args) + if h == nil { + return opts + } + for _, k := range h.Keys { + val, _ := h.Get(k) + switch apStr(k) { + case "path": + opts.Path = apStr(val) + case "module": + opts.Module = apStr(val) + case "as": + opts.As = apStr(val) + case "constraints": + opts.Constraints = apStrMap(val) + case "defaults": + opts.Defaults = apStrMap(val) + } + } + return opts +} + +// acAnyMap reads a Ruby Hash into the map[string]any the reverse-routing helpers +// consume (each value mapped through rackToGo); a nil Hash yields an empty map. +func acAnyMap(h *object.Hash) map[string]any { + out := map[string]any{} + if h == nil { + return out + } + for _, k := range h.Keys { + val, _ := h.Get(k) + out[apStr(k)] = rackToGo(val) + } + return out +} + +// acAnyArgs maps positional path_args into the []any the helper consumes: a +// trailing Hash becomes a map[string]any option bag, every other argument its +// rackToGo scalar. +func acAnyArgs(args []object.Value) []any { + out := make([]any, 0, len(args)) + for i, a := range args { + if h, ok := a.(*object.Hash); ok && i == len(args)-1 { + out = append(out, acAnyMap(h)) + continue + } + out = append(out, rackToGo(a)) + } + return out +} + +// lastHashOrNil returns the trailing keyword-options Hash of an argument list, +// or nil when the last argument is not a Hash. +func lastHashOrNil(args []object.Value) *object.Hash { + if h, ok := lastHash(args); ok { + return h + } + return nil +} diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6721fcd8..dd2e4dea 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -495,6 +495,7 @@ func (vm *VM) bootstrap() { vm.registerActiveJob() // ActiveJob::Base subclass DSL (queue_as/retry_on/callbacks/perform_later/perform_now/set) + Arguments (require "active_job"), backed by go-ruby-activejob; #perform is a Ruby seam run INLINE under the GVL (default inline adapter); needs ArgumentError for SerializationError vm.registerActiveStorage() // ActiveStorage::Blob/Service/Attachment + Attached::One/Many (require "active_storage"), backed by go-ruby-activestorage; ModelStore/Service/Signer/Random/Clock seams wired to a deterministic in-process config (MemStore + DiskService temp dir); needs StandardError for ActiveStorage::Error vm.registerActionCable() // ActionCable::Server/Connection::Base/Channel::Base + ActionCable.server (require "action_cable"), backed by go-ruby-actioncable; the transport/channel-action/factory seams eval Ruby bodies + the async adapter default keep the byte-exact actioncable-v1-json wire protocol in the library; needs StandardError (ActionCable::Error) + vm.registerActionPack() // ActionController::Base + ActionController::Parameters + ActionDispatch::Routing::RouteSet/Request/Response (require "action_controller" / "action_dispatch"), backed by go-ruby-actionpack; the action body (RunAction), the before/after/around filters, rescue_from and the view render (Renderer -> an ActionView-style context) are the rbgo seams run inline under the GVL; the route compiler/recognizer/generator, filter/rescue pipeline and strong-params semantics are the library; needs StandardError + Rack, so registered after registerRack/registerActionCable vm.registerRailties() // Rails::Railtie/Engine/Application boot framework (require "rails"), backed by go-ruby-railties; the initializer/hook/routes blocks are the rbgo seams (run inline on initialize!); a later rails meta-gem layers the top-level Rails.* accessors vm.registerDevise() // Devise::Config/Resource/Encryptor/TokenGenerator + Strategies::DatabaseAuthenticatable (require "devise"), backed by go-ruby-devise over go-ruby-bcrypt + go-ruby-warden; the model #[]/#[]=/#save + finder callable are the rbgo seams, and the strategy plugs into the bound Warden registry; needs StandardError (Devise::Error). Registered after registerWarden + registerBCrypt (dependency order) vm.registerHanami() // Hanami::Router (hanami-router) + Hanami::Action (hanami-controller) (require "hanami" / "hanami/router" / "hanami/action"), backed by go-ruby-hanami over go-ruby-rack; the endpoint Resolver, the action #handle body (ActionCall), before/after callbacks, handle_exception, params validation and session loading are the rbgo seams; needs Rack, so registered after registerRack diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 297665ed..98e2f88a 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -1334,6 +1334,16 @@ func (vm *VM) classOf(v object.Value) *RClass { return x.cls case *OmniAuthHash: return x.cls + case *ACRouteSet: + return x.cls + case *ACMapper: + return x.cls + case *ACParams: + return x.cls + case *ACRequest: + return x.cls + case *ACResponse: + return x.cls case *Binding: return vm.consts["Binding"].(*RClass) case *JobRedis: diff --git a/internal/vm/require.go b/internal/vm/require.go index 1ce3dd7d..98c3ef8e 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -78,6 +78,7 @@ var providedFeatures = map[string]bool{ "active_job": true, "activejob": true, "active_storage": true, "activestorage": true, "action_cable": true, "actioncable": true, + "action_controller": true, "action_dispatch": true, "actionpack": true, "abstract_controller": true, "rails": true, "rails/railtie": true, "rails/engine": true, "rails/application": true, "devise": true, "hanami": true, "hanami/router": true, "hanami/action": true, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 94d4017c..41128dd1 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -290,6 +290,13 @@ type VM struct { cHanamiResponse *RClass // Hanami::Action::Response, the mutable response a Hanami action's #handle writes into cHanamiFlash *RClass // Hanami::Action::Flash, the two-generation flash store on the request/response hanamiActionDefs map[*RClass]*hanamiActionDef // per-Hanami::Action-subclass before/after/handle_exception/accept/config declarations + cACRouteSet *RClass // ActionDispatch::Routing::RouteSet (require "action_dispatch"), backed by go-ruby-actionpack/routing + cACMapper *RClass // ActionDispatch::Routing::Mapper, the self the routes DSL (draw) runs against + cACRequest *RClass // ActionDispatch::Request, over a Rack env + cACResponse *RClass // ActionDispatch::Response, the mutable Rack response + cACParameters *RClass // ActionController::Parameters (strong parameters) + cACControllerBase *RClass // ActionController::Base (require "action_controller"), the controller superclass a user subclasses + acControllerDefs map[*RClass]*acControllerDef // per-ActionController::Base-subclass before/after/around/rescue_from/view-context declarations cMinitestSpec *RClass // Minitest::Spec, the spec-DSL subclass of Minitest::Test minitestRunnables []*RClass // Minitest::Test subclasses registered via the inherited hook, in definition order (the autorun run set) minitestCurInstance object.Value // the test instance currently running (backs bare must_*/wont_* and _)