Skip to content

Commit 32c28e7

Browse files
committed
Added exec support for actions
1 parent 9d6da10 commit 32c28e7

File tree

16 files changed

+451
-72
lines changed

16 files changed

+451
-72
lines changed

internal/app/action/action.go

Lines changed: 207 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,21 @@ import (
1717

1818
"github.com/claceio/clace/internal/app/apptype"
1919
"github.com/claceio/clace/internal/app/starlark_type"
20+
"github.com/claceio/clace/internal/types"
2021
"github.com/go-chi/chi"
2122
"go.starlark.net/starlark"
23+
"go.starlark.net/starlarkstruct"
2224
)
2325

24-
//go:embed *.go.html static/*
26+
//go:embed *.go.html astatic/*
2527
var embedHtml embed.FS
2628

2729
// Action represents a single action that is exposed by the App. Actions
2830
// provide a way to trigger app operations, with an auto-generated form UI
2931
// and an API interface
3032
type Action struct {
33+
*types.Logger
34+
isDev bool
3135
name string
3236
description string
3337
path string
@@ -41,7 +45,7 @@ type Action struct {
4145
}
4246

4347
// NewAction creates a new action
44-
func NewAction(name, description, apath string, run, suggest starlark.Callable,
48+
func NewAction(logger *types.Logger, isDev bool, name, description, apath string, run, suggest starlark.Callable,
4549
params []apptype.AppParam, paramValuesStr map[string]string, paramDict starlark.StringDict, appPath string) (*Action, error) {
4650
tmpl, err := template.New("form").ParseFS(embedHtml, "*.go.html")
4751
if err != nil {
@@ -52,7 +56,16 @@ func NewAction(name, description, apath string, run, suggest starlark.Callable,
5256
return a.Index - b.Index
5357
})
5458

59+
subLogger := logger.With().Str("action", name).Logger()
60+
appLogger := types.Logger{Logger: &subLogger}
61+
62+
pagePath := path.Join(appPath, apath)
63+
if pagePath == "/" {
64+
pagePath = ""
65+
}
66+
5567
return &Action{
68+
Logger: &appLogger,
5669
name: name,
5770
description: description,
5871
path: apath,
@@ -62,28 +75,212 @@ func NewAction(name, description, apath string, run, suggest starlark.Callable,
6275
paramValuesStr: paramValuesStr,
6376
paramDict: paramDict,
6477
template: tmpl,
65-
pagePath: path.Join(appPath, apath),
78+
pagePath: pagePath,
6679
}, nil
6780
}
6881

6982
func (a *Action) BuildRouter() (*chi.Mux, error) {
70-
fSys, err := fs.Sub(embedHtml, "static")
83+
fSys, err := fs.Sub(embedHtml, "astatic")
7184
if err != nil {
7285
return nil, err
7386
}
7487
staticServer := http.FileServer(http.FS(fSys))
7588

7689
r := chi.NewRouter()
77-
r.Post("/", a.runHandler)
90+
r.Post("/", a.runAction)
7891
r.Get("/", a.getForm)
79-
r.Handle("/static/*", http.StripPrefix(path.Join(a.pagePath, "/static/"), staticServer))
92+
r.Handle("/astatic/*", http.StripPrefix(path.Join(a.pagePath, "/astatic/"), staticServer))
8093
return r, nil
8194
}
8295

83-
func (a *Action) runHandler(w http.ResponseWriter, r *http.Request) {
84-
// Parse the form
85-
// Validate the form
86-
// Run the action
96+
func (a *Action) runAction(w http.ResponseWriter, r *http.Request) {
97+
thread := &starlark.Thread{
98+
Name: a.name,
99+
Print: func(_ *starlark.Thread, msg string) { fmt.Println(msg) },
100+
}
101+
102+
// Save the request context in the starlark thread local
103+
thread.SetLocal(types.TL_CONTEXT, r.Context())
104+
//isHtmxRequest := r.Header.Get("HX-Request") == "true" && !(r.Header.Get("HX-Boosted") == "true")
105+
106+
r.ParseForm()
107+
var err error
108+
dryRun := false
109+
dryRunStr := r.Form.Get("dry-run")
110+
if dryRunStr != "" {
111+
dryRun, err = strconv.ParseBool(dryRunStr)
112+
if err != nil {
113+
http.Error(w, fmt.Sprintf("invalid value for dry-run: %s", dryRunStr), http.StatusBadRequest)
114+
return
115+
}
116+
}
117+
118+
deferredCleanup := func() error {
119+
// Check for any deferred cleanups
120+
err = RunDeferredCleanup(thread)
121+
if err != nil {
122+
a.Error().Err(err).Msg("error cleaning up plugins")
123+
http.Error(w, err.Error(), http.StatusInternalServerError)
124+
return err
125+
}
126+
return nil
127+
}
128+
129+
defer deferredCleanup()
130+
131+
args := starlark.StringDict{}
132+
// Make a copy of the app level param dict
133+
for k, v := range a.paramDict {
134+
args[k] = v
135+
}
136+
137+
// Update args with submitted form values
138+
for _, param := range a.params {
139+
formValue := r.Form.Get(param.Name)
140+
if formValue == "" {
141+
if param.Type == starlark_type.BOOLEAN {
142+
// Form does not submit unchecked checkboxes, set to false
143+
args[param.Name] = starlark.Bool(false)
144+
}
145+
} else {
146+
newVal, err := apptype.ParamStringToType(param.Name, param.Type, formValue)
147+
if err != nil {
148+
http.Error(w, err.Error(), http.StatusBadRequest)
149+
return
150+
}
151+
args[param.Name] = newVal
152+
}
153+
}
154+
155+
argsValue := Args{members: args}
156+
157+
// Call the handler function
158+
var ret starlark.Value
159+
ret, err = starlark.Call(thread, a.run, starlark.Tuple{starlark.Bool(dryRun), &argsValue}, nil)
160+
if err != nil {
161+
a.Error().Err(err).Msg("error calling action run handler")
162+
163+
firstFrame := ""
164+
if evalErr, ok := err.(*starlark.EvalError); ok {
165+
// Iterate through the CallFrame stack for debugging information
166+
for i, frame := range evalErr.CallStack {
167+
a.Warn().Msgf("Function: %s, Position: %s\n", frame.Name, frame.Pos)
168+
if i == 0 {
169+
firstFrame = fmt.Sprintf("Function %s, Position %s", frame.Name, frame.Pos)
170+
}
171+
}
172+
}
173+
174+
msg := err.Error()
175+
if firstFrame != "" && a.isDev {
176+
msg = msg + " : " + firstFrame
177+
}
178+
179+
// No err handler defined, abort
180+
http.Error(w, msg, http.StatusInternalServerError)
181+
return
182+
}
183+
184+
var valuesMap []map[string]any
185+
var valuesStr []string
186+
var message string
187+
var paramErrors map[string]any
188+
189+
resultStruct, ok := ret.(*starlarkstruct.Struct)
190+
if ok {
191+
message, err = apptype.GetOptionalStringAttr(resultStruct, "message")
192+
if err != nil {
193+
http.Error(w, fmt.Sprintf("error getting result attr message: %s", err), http.StatusInternalServerError)
194+
return
195+
}
196+
197+
valuesMap, err = apptype.GetListMapAttr(resultStruct, "values", true)
198+
if err != nil {
199+
valuesStr, err = apptype.GetListStringAttr(resultStruct, "values", true)
200+
if err != nil {
201+
http.Error(w, fmt.Sprintf("error getting result values, not a list of string or list of maps: %s", err), http.StatusInternalServerError)
202+
return
203+
}
204+
}
205+
206+
paramErrors, err = apptype.GetDictAttr(resultStruct, "param_errors", true)
207+
if err != nil {
208+
http.Error(w, fmt.Sprintf("error getting result attr paramErrors: %s", err), http.StatusInternalServerError)
209+
return
210+
}
211+
} else {
212+
// Not a result struct
213+
message = ret.String()
214+
}
215+
216+
a.Info().Msgf("action result message: %s valuesStr %s valuesMap %s paramErrors %s", message, valuesStr, valuesMap, paramErrors)
217+
218+
if deferredCleanup() != nil {
219+
return
220+
}
221+
222+
if err != nil {
223+
http.Error(w, err.Error(), http.StatusInternalServerError)
224+
return
225+
}
226+
227+
// Render the result message
228+
err = a.template.ExecuteTemplate(w, "message", message)
229+
if err != nil {
230+
http.Error(w, err.Error(), http.StatusInternalServerError)
231+
}
232+
233+
// Render the param error messages if any, using HTMX OOB
234+
for paramName, paramError := range paramErrors {
235+
tv := struct {
236+
Name string
237+
Message string
238+
}{
239+
Name: paramName,
240+
Message: fmt.Sprintf("%s", paramError),
241+
}
242+
err = a.template.ExecuteTemplate(w, "paramError", tv)
243+
if err != nil {
244+
http.Error(w, err.Error(), http.StatusInternalServerError)
245+
}
246+
}
247+
248+
if len(valuesStr) > 0 {
249+
// Render the result values, using HTMX OOB
250+
err = a.template.ExecuteTemplate(w, "result-textarea", valuesStr)
251+
if err != nil {
252+
http.Error(w, err.Error(), http.StatusInternalServerError)
253+
}
254+
}
255+
256+
}
257+
258+
func RunDeferredCleanup(thread *starlark.Thread) error {
259+
deferMap := thread.Local(types.TL_DEFER_MAP)
260+
if deferMap == nil {
261+
return nil
262+
}
263+
264+
strictFailures := []string{}
265+
for pluginName, pluginMap := range deferMap.(map[string]map[string]apptype.DeferEntry) {
266+
for key, entry := range pluginMap {
267+
err := entry.Func()
268+
if err != nil {
269+
fmt.Printf("error cleaning up %s %s: %s\n", pluginName, key, err)
270+
}
271+
if entry.Strict {
272+
strictFailures = append(strictFailures, fmt.Sprintf("%s:%s", pluginName, key))
273+
}
274+
}
275+
}
276+
277+
thread.SetLocal(types.TL_DEFER_MAP, nil) // reset the defer map
278+
279+
if len(strictFailures) > 0 {
280+
return fmt.Errorf("resource has not be closed, check handler code: %s", strings.Join(strictFailures, ", "))
281+
}
282+
283+
return nil
87284
}
88285

89286
type ParamDef struct {

internal/app/action/args.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) ClaceIO, LLC
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package action
5+
6+
import (
7+
"fmt"
8+
9+
"go.starlark.net/starlark"
10+
)
11+
12+
// Args is a starlark.Value that represents the arguments being passed to the Action handler. It contains value for the params.
13+
type Args struct {
14+
members starlark.StringDict
15+
}
16+
17+
func (a *Args) Attr(name string) (starlark.Value, error) {
18+
v, ok := a.members[name]
19+
if !ok {
20+
return starlark.None, fmt.Errorf("Args has no attribute '%s'", name)
21+
}
22+
23+
return v, nil
24+
}
25+
26+
func (a *Args) AttrNames() []string {
27+
return a.members.Keys()
28+
}
29+
30+
func (a *Args) String() string {
31+
return a.members.String()
32+
}
33+
34+
func (a *Args) Type() string {
35+
return "Args"
36+
}
37+
38+
func (a *Args) Freeze() {
39+
}
40+
41+
func (a *Args) Truth() starlark.Bool {
42+
return true
43+
}
44+
45+
func (a *Args) Hash() (uint32, error) {
46+
return 0, fmt.Errorf("Hash not implemented for Args")
47+
}
48+
49+
var _ starlark.Value = (*Args)(nil)

internal/app/action/astatic/htmx.min.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
File renamed without changes.
File renamed without changes.

internal/app/action/form.go.html

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
{{ template "header" . }}
22

33

4+
<script>
5+
document.body.addEventListener("htmx:sendError", function (event) {
6+
ActionMessage.innerText = "API call failed: Server is not reachable";
7+
});
8+
document.body.addEventListener("htmx:responseError", function (event) {
9+
ActionMessage.innerText =
10+
"API call failed: " + event.detail.xhr.responseText;
11+
});
12+
</script>
13+
414
<p class="text-center">
515
{{ .description }}
616
</p>
@@ -23,9 +33,12 @@
2333
id="param_{{ .Name }}"
2434
name="{{ .Name }}"
2535
type="checkbox"
36+
value="true"
2637
class="checkbox checkbox-primary justify-self-center"
2738
{{ .Value }} />
28-
<div id="param_{{ .Name }}_Error" class="text-error mt-1"></div>
39+
<div class="pl-4">
40+
<div id="param_{{ .Name }}_error" class="text-error mt-1"></div>
41+
</div>
2942
</div>
3043
{{ else if eq .InputType "select" }}
3144
<div>
@@ -40,7 +53,7 @@
4053
<option value="{{ . }}">{{ . }}</option>
4154
{{ end }}
4255
</select>
43-
<div id="{{ .Name }}_Error" class="text-error mt-1"></div>
56+
<div id="param_{{ .Name }}_error" class="text-error mt-1"></div>
4457
</div>
4558
{{ else }}
4659
<div>
@@ -50,7 +63,7 @@
5063
type="text"
5164
class="input input-bordered w-full"
5265
value="{{ .Value }}" />
53-
<div id="{{ .Name }}_Error" class="text-error mt-1"></div>
66+
<div id="param_{{ .Name }}_error" class="text-error mt-1"></div>
5467
</div>
5568
{{ end }}
5669
</div>
@@ -59,9 +72,22 @@
5972

6073
<!-- Submit Button -->
6174
<div class="form-control mt-6">
62-
<button type="submit" class="btn btn-primary w-full">Run</button>
75+
<button
76+
type="submit"
77+
hx-post="{{ .path }}"
78+
hx-target="#ActionMessage"
79+
hx-swap="innerHTML"
80+
class="btn btn-primary w-full">
81+
Run
82+
</button>
6383
</div>
6484
</form>
6585
</div>
6686

87+
<p id="ActionMessage" class="pt-6 text-center"></p>
88+
89+
<div class="pt-6 card w-full shadow-2xl p-6 rounded-lg">
90+
<div id="action_result"></div>
91+
</div>
92+
6793
{{ template "footer" . }}

0 commit comments

Comments
 (0)