Browse Source

feat: Adding support for web request handling to ECAL

Matthias Ladkau 3 years ago
parent
commit
7c82c310c6

+ 5 - 0
.gitignore

@@ -16,3 +16,8 @@
 /examples/data-mining/docker-images/eliasdb/eliasdb
 /examples/data-mining/docker-images/frontend/app/node_modules
 /examples/data-mining/docker-images/frontend/app/graphiql
+/ssl
+/web
+/db
+/scripts
+eliasdb.config.json

+ 218 - 0
api/v1/ecal.go

@@ -0,0 +1,218 @@
+/*
+ * EliasDB
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package v1
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"devt.de/krotik/ecal/engine"
+	"devt.de/krotik/ecal/scope"
+	"devt.de/krotik/ecal/util"
+	"devt.de/krotik/eliasdb/api"
+	"devt.de/krotik/eliasdb/ecal/dbfunc"
+)
+
+/*
+EndpointECALInternal is the ECAL endpoint URL (rooted) for internal operations. Handles everything under ecal/...
+*/
+const EndpointECALInternal = api.APIRoot + "/ecal/"
+
+/*
+EndpointECALPublic is the ECAL endpoint URL (rooted) for public API operations. Handles everything under api	/...
+*/
+const EndpointECALPublic = api.APIRoot + "/api/"
+
+/*
+EndpointECALInst creates a new endpoint handler.
+*/
+func EndpointECALInst() api.RestEndpointHandler {
+	return &ecalEndpoint{}
+}
+
+/*
+Handler object for ecal operations.
+*/
+type ecalEndpoint struct {
+	*api.DefaultEndpointHandler
+}
+
+/*
+	HandleGET handles a GET request.
+*/
+func (ee *ecalEndpoint) HandleGET(w http.ResponseWriter, r *http.Request, resources []string) {
+	ee.forwardRequest(w, r, resources)
+}
+
+/*
+	HandlePOST handles a POST request.
+*/
+func (ee *ecalEndpoint) HandlePOST(w http.ResponseWriter, r *http.Request, resources []string) {
+	ee.forwardRequest(w, r, resources)
+}
+
+/*
+	HandlePUT handles a PUT request.
+*/
+func (ee *ecalEndpoint) HandlePUT(w http.ResponseWriter, r *http.Request, resources []string) {
+	ee.forwardRequest(w, r, resources)
+}
+
+/*
+	HandleDELETE handles a DELETE request.
+*/
+func (ee *ecalEndpoint) HandleDELETE(w http.ResponseWriter, r *http.Request, resources []string) {
+	ee.forwardRequest(w, r, resources)
+}
+
+func (ee *ecalEndpoint) forwardRequest(w http.ResponseWriter, r *http.Request, resources []string) {
+
+	if api.SI != nil {
+
+		// Make sure the request we are handling comes from a known path for ECAL
+
+		isPublic := strings.HasPrefix(r.URL.Path, EndpointECALPublic)
+		isInternal := strings.HasPrefix(r.URL.Path, EndpointECALInternal)
+
+		if isPublic || isInternal {
+			var eventKind []string
+
+			body, err := ioutil.ReadAll(r.Body)
+
+			if err == nil {
+				if isPublic {
+					eventKind = []string{"db", "web", "api"}
+				} else {
+					eventKind = []string{"db", "web", "ecal"}
+				}
+
+				var data interface{}
+				json.Unmarshal(body, &data)
+
+				query := map[interface{}]interface{}{}
+				for k, v := range r.URL.Query() {
+					query[k] = scope.ConvertJSONToECALObject(v)
+				}
+
+				header := map[interface{}]interface{}{}
+				for k, v := range r.Header {
+					header[k] = scope.ConvertJSONToECALObject(v)
+				}
+
+				proc := api.SI.Interpreter.RuntimeProvider.Processor
+				event := engine.NewEvent(fmt.Sprintf("WebRequest"), eventKind,
+					map[interface{}]interface{}{
+						"path":       strings.Join(resources, "/"),
+						"pathList":   resources,
+						"bodyString": string(body),
+						"bodyJSON":   scope.ConvertJSONToECALObject(data),
+						"query":      query,
+						"method":     r.Method,
+						"header":     header,
+					})
+
+				var m engine.Monitor
+
+				if m, err = proc.AddEventAndWait(event, nil); err == nil {
+					if m != nil {
+						var headers map[interface{}]interface{}
+						status := 0
+						var body []byte
+
+						for _, e := range m.(*engine.RootMonitor).AllErrors() {
+							if len(e.ErrorMap) > 0 {
+								for _, e := range e.ErrorMap {
+									if re, ok := e.(*util.RuntimeErrorWithDetail); ok && re.Type == dbfunc.ErrWebEventHandled {
+										res := re.Data.(map[interface{}]interface{})
+
+										if status, err = strconv.Atoi(fmt.Sprint(res["status"])); err == nil {
+											headers, _ = res["header"].(map[interface{}]interface{})
+											body, err = json.Marshal(scope.ConvertECALToJSONObject(res["body"]))
+										}
+									} else {
+										err = e
+									}
+									break
+								}
+								break
+							}
+						}
+
+						if status != 0 {
+							for k, v := range headers {
+								w.Header().Set(fmt.Sprint(k), fmt.Sprint(v))
+							}
+							w.WriteHeader(status)
+							fmt.Fprintln(w, string(body))
+							return
+						}
+					}
+				}
+			}
+
+			if err != nil {
+				api.SI.Interpreter.RuntimeProvider.Logger.LogError(err)
+			}
+		}
+	}
+
+	http.Error(w, "Resource was not found", http.StatusNotFound)
+}
+
+/*
+SwaggerDefs is used to describe the endpoint in swagger.
+*/
+func (ee *ecalEndpoint) SwaggerDefs(s map[string]interface{}) {
+
+	desc := map[string]interface{}{
+		"summary":     "Forward web requests to the ECAL backend.",
+		"description": "The ecal endpoint forwards web requests to the ECAL backend.",
+		"produces": []string{
+			"text/plain",
+			"application/json",
+		},
+		"responses": map[string]interface{}{
+			"200": map[string]interface{}{
+				"description": "A result object generated by ECAL scripts.",
+			},
+			"default": map[string]interface{}{
+				"description": "Error response",
+				"schema": map[string]interface{}{
+					"$ref": "#/definitions/Error",
+				},
+			},
+		},
+	}
+
+	s["paths"].(map[string]interface{})["/ecal"] = map[string]interface{}{
+		"get":    desc,
+		"post":   desc,
+		"put":    desc,
+		"delete": desc,
+	}
+	s["paths"].(map[string]interface{})["/api"] = map[string]interface{}{
+		"get":    desc,
+		"post":   desc,
+		"put":    desc,
+		"delete": desc,
+	}
+
+	// Add generic error object to definition
+
+	s["definitions"].(map[string]interface{})["Error"] = map[string]interface{}{
+		"description": "A human readable error mesage.",
+		"type":        "string",
+	}
+}

+ 185 - 0
api/v1/ecal_test.go

@@ -0,0 +1,185 @@
+/*
+ * EliasDB
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package v1
+
+import (
+	"testing"
+
+	"devt.de/krotik/eliasdb/api"
+)
+
+func TestECAL(t *testing.T) {
+	internalURL := "http://localhost" + TESTPORT + EndpointECALInternal
+	publicURL := "http://localhost" + TESTPORT + EndpointECALPublic
+
+	// Test normal log output
+
+	writeScript(`
+log("test insert")
+`)
+
+	if err := api.SI.Run(); err != nil {
+		t.Error("Unexpected result:", err)
+		return
+	}
+
+	if err := checkLog(`test insert
+`); err != nil {
+		t.Error(err)
+	}
+
+	writeScript(`
+log("test sinks")
+sink mysink
+  kindmatch [ "db.web.api" ],
+  statematch { "method" : "POST", "path" : "xx/ss" }
+{
+  del(event.state.header, "Accept-Encoding")
+  del(event.state.header, "User-Agent")
+  log("Got public web request: ", event)
+  log("Body data: ", event.state.bodyJSON.data)
+  db.raiseWebEventHandled({
+	"status" : 201,
+	"body" : {
+		"mydata" : [1,2,3]
+	}
+  })
+}
+sink mysink2
+  kindmatch [ "db.web.ecal" ],
+  statematch { "method" : "GET" }
+{
+  del(event.state.header, "Accept-Encoding")
+  del(event.state.header, "User-Agent")
+  log("Got internal web request: ", event)
+  log("Query data: ", event.state.query.xxx)
+  raise("aaa")
+}
+`)
+
+	if err := api.SI.Run(); err != nil {
+		t.Error("Unexpected result:", err)
+		return
+	}
+
+	st, _, res := sendTestRequest(internalURL+"main/n/Test/bar?xxx=1", "GET", nil)
+
+	if st != "404 Not Found" || res != "Resource was not found" {
+		t.Error("Unexpected result:", st, res)
+		return
+	}
+
+	st, header, res := sendTestRequest(publicURL+"xx/ss/?a=1&b=2", "POST", []byte(`
+{
+  "data": 123
+}
+`[1:]))
+
+	if st != "201 Created" || header["Content-Type"][0] != "application/json; charset=utf-8" || string(res) != `{
+  "mydata": [
+    1,
+    2,
+    3
+  ]
+}` {
+		t.Error("Unexpected result:", st, header, string(res))
+		return
+	}
+
+	if err := checkLog(`test sinks
+Got internal web request: {
+  "kind": "db.web.ecal",
+  "name": "WebRequest",
+  "state": {
+    "bodyJSON": null,
+    "bodyString": "",
+    "header": {
+      "Content-Type": [
+        "application/json"
+      ]
+    },
+    "method": "GET",
+    "path": "main/n/Test/bar",
+    "pathList": [
+      "main",
+      "n",
+      "Test",
+      "bar"
+    ],
+    "query": {
+      "xxx": [
+        "1"
+      ]
+    }
+  }
+}
+Query data: [
+  "1"
+]
+error: ECAL error in eliasdb-runtime: aaa () (Line:26 Pos:3)
+Got public web request: {
+  "kind": "db.web.api",
+  "name": "WebRequest",
+  "state": {
+    "bodyJSON": {
+      "data": 123
+    },
+    "bodyString": "{\n  \"data\": 123\n}\n",
+    "header": {
+      "Content-Length": [
+        "18"
+      ],
+      "Content-Type": [
+        "application/json"
+      ]
+    },
+    "method": "POST",
+    "path": "xx/ss",
+    "pathList": [
+      "xx",
+      "ss"
+    ],
+    "query": {
+      "a": [
+        "1"
+      ],
+      "b": [
+        "2"
+      ]
+    }
+  }
+}
+Body data: 123
+`); err != nil {
+		t.Error(err)
+	}
+
+	oldSI := api.SI
+	defer func() {
+		api.SI = oldSI
+	}()
+
+	api.SI = nil
+
+	st, _, res = sendTestRequest(internalURL, "PUT", nil)
+
+	if st != "404 Not Found" || res != "Resource was not found" {
+		t.Error("Unexpected result:", st, res)
+		return
+	}
+
+	st, _, res = sendTestRequest(internalURL, "DELETE", nil)
+
+	if st != "404 Not Found" || res != "Resource was not found" {
+		t.Error("Unexpected result:", st, res)
+		return
+	}
+}

+ 8 - 0
api/v1/rest.go

@@ -49,6 +49,14 @@ var V1EndpointMap = map[string]api.RestEndpointInst{
 	EndpointInfoQuery:            InfoEndpointInst,
 	EndpointQuery:                QueryEndpointInst,
 	EndpointQueryResult:          QueryResultEndpointInst,
+	EndpointECALInternal:         EndpointECALInst,
+}
+
+/*
+V1PublicEndpointMap is a map of urls to public endpoints for version 1 of the API
+*/
+var V1PublicEndpointMap = map[string]api.RestEndpointInst{
+	EndpointECALPublic: EndpointECALInst,
 }
 
 // Helper functions

+ 75 - 3
api/v1/rest_test.go

@@ -14,17 +14,22 @@ import (
 	"bytes"
 	"encoding/json"
 	"flag"
+	"fmt"
 	"io/ioutil"
 	"net/http"
 	"os"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"sync"
 	"testing"
 
 	"devt.de/krotik/common/errorutil"
+	"devt.de/krotik/common/fileutil"
 	"devt.de/krotik/common/httputil"
 	"devt.de/krotik/eliasdb/api"
+	"devt.de/krotik/eliasdb/config"
+	"devt.de/krotik/eliasdb/ecal"
 	"devt.de/krotik/eliasdb/eql"
 	"devt.de/krotik/eliasdb/graph"
 	"devt.de/krotik/eliasdb/graph/data"
@@ -35,16 +40,47 @@ const TESTPORT = ":9090"
 
 var gmMSM *graphstorage.MemoryGraphStorage
 
+const testScriptDir = "testscripts"
+
 // Main function for all tests in this package
 
 func TestMain(m *testing.M) {
 	flag.Parse()
 
+	defer func() {
+		if res, _ := fileutil.PathExists(testScriptDir); res {
+			if err := os.RemoveAll(testScriptDir); err != nil {
+				fmt.Print("Could not remove test directory:", err.Error())
+			}
+		}
+	}()
+
+	if res, _ := fileutil.PathExists(testScriptDir); res {
+		if err := os.RemoveAll(testScriptDir); err != nil {
+			fmt.Print("Could not remove test directory:", err.Error())
+		}
+	}
+
+	ensurePath(testScriptDir)
+
+	data := make(map[string]interface{})
+	for k, v := range config.DefaultConfig {
+		data[k] = v
+	}
+
+	config.Config = data
+
+	config.Config[config.EnableECALScripts] = true
+	config.Config[config.ECALScriptFolder] = testScriptDir
+	config.Config[config.ECALLogFile] = filepath.Join(testScriptDir, "interpreter.log")
+
 	gm, msm := filterGraph()
 	api.GM = gm
 	api.GS = msm
 	gmMSM = msm
 
+	resetSI()
+
 	hs, wg := startServer()
 	if hs == nil {
 		return
@@ -53,16 +89,15 @@ func TestMain(m *testing.M) {
 	// Register endpoints for version 1
 
 	api.RegisterRestEndpoints(V1EndpointMap)
+	api.RegisterRestEndpoints(V1PublicEndpointMap)
 
 	// Run the tests
 
-	res := m.Run()
+	m.Run()
 
 	// Teardown
 
 	stopServer(hs, wg)
-
-	os.Exit(res)
 }
 
 func TestSwaggerDefs(t *testing.T) {
@@ -351,3 +386,40 @@ func filterGraph() (*graph.Manager, *graphstorage.MemoryGraphStorage) {
 
 	return gm, mgs
 }
+
+func ensurePath(path string) {
+	if res, _ := fileutil.PathExists(path); !res {
+		if err := os.Mkdir(path, 0770); err != nil {
+			fmt.Print("Could not create directory:", err.Error())
+			return
+		}
+	}
+}
+
+func resetSI() {
+	api.SI = ecal.NewScriptingInterpreter(testScriptDir, api.GM)
+}
+
+func writeScript(content string) {
+	filename := filepath.Join(testScriptDir, config.Str(config.ECALEntryScript))
+	err := ioutil.WriteFile(
+		filename,
+		[]byte(content), 0600)
+	errorutil.AssertOk(err)
+	os.Remove(config.Str(config.ECALLogFile))
+}
+
+func checkLog(expected string) error {
+	var err error
+
+	content, err := ioutil.ReadFile(config.Str(config.ECALLogFile))
+
+	if err == nil {
+		logtext := string(content)
+
+		if logtext != expected {
+			err = fmt.Errorf("Unexpected log text:\n%v", logtext)
+		}
+	}
+	return err
+}

+ 49 - 0
cli/eliasdb.go

@@ -47,10 +47,12 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"time"
 
 	"devt.de/krotik/common/errorutil"
 	"devt.de/krotik/common/fileutil"
 	"devt.de/krotik/common/termutil"
+	"devt.de/krotik/eliasdb/api"
 	"devt.de/krotik/eliasdb/config"
 	"devt.de/krotik/eliasdb/console"
 	"devt.de/krotik/eliasdb/graph"
@@ -289,10 +291,15 @@ handleServerCommandLine handles all command line options for the server
 */
 func handleServerCommandLine(gm *graph.Manager) bool {
 	var err error
+	var ecalConsole *bool
 
 	importDb := flag.String("import", "", "Import a database from a zip file")
 	exportDb := flag.String("export", "", "Export the current database to a zip file")
 
+	if config.Bool(config.EnableECALScripts) {
+		ecalConsole = flag.Bool("ecal-console", false, "Start an interactive interpreter console for ECAL")
+	}
+
 	noServ := flag.Bool("no-serv", false, "Do not start the server after initialization")
 
 	showHelp := flag.Bool("help", false, "Show this help message")
@@ -368,6 +375,48 @@ func handleServerCommandLine(gm *graph.Manager) bool {
 		}
 	}
 
+	if ecalConsole != nil && *ecalConsole {
+		var term termutil.ConsoleLineTerminal
+
+		isExitLine := func(s string) bool {
+			return s == "exit" || s == "q" || s == "quit" || s == "bye" || s == "\x04"
+		}
+
+		term, err = termutil.NewConsoleLineTerminal(os.Stdout)
+		if err == nil {
+			term, err = termutil.AddHistoryMixin(term, "", isExitLine)
+			if err == nil {
+				tid := api.SI.Interpreter.RuntimeProvider.NewThreadID()
+
+				runECALConsole := func(delay int) {
+					defer term.StopTerm()
+
+					time.Sleep(time.Duration(delay) * time.Millisecond)
+
+					term.WriteString(fmt.Sprintln("Type 'q' or 'quit' to exit the shell and '?' to get help"))
+
+					line, err := term.NextLine()
+					for err == nil && !isExitLine(line) {
+						trimmedLine := strings.TrimSpace(line)
+
+						api.SI.Interpreter.HandleInput(term, trimmedLine, tid)
+
+						line, err = term.NextLine()
+					}
+				}
+
+				if err = term.StartTerm(); err == nil {
+
+					if *noServ {
+						runECALConsole(0)
+					} else {
+						go runECALConsole(3000)
+					}
+				}
+			}
+		}
+	}
+
 	if err != nil {
 		fmt.Println(err.Error())
 		return true

+ 1 - 1
config/config.go

@@ -98,7 +98,7 @@ var DefaultConfig = map[string]interface{}{
 	ClusterStateInfoFile:     "cluster.stateinfo",
 	ClusterConfigFile:        "cluster.config.json",
 	ClusterLogHistory:        100.0,
-	ECALScriptFolder:         "ecal",
+	ECALScriptFolder:         "scripts",
 	ECALEntryScript:          "main.ecal",
 	ECALLogLevel:             "info",
 	ECALLogFile:              "",

+ 70 - 6
ecal.md

@@ -1,12 +1,41 @@
 EliasDB Event Condition Action Language
 =======================================
 
-EliasDB supports a scripting language called [Event Condition Action Language (ECAL)](https://devt.de/krotik/ecal/) to enable rule based scripting functionality.
+EliasDB supports a scripting language called [Event Condition Action Language (ECAL)](https://devt.de/krotik/ecal/) to enable rule based scripting functionality. ECAL provides [database trigger](https://en.wikipedia.org/wiki/Database_trigger) functionality for EliasDB.
+
+ECAL was added for the following use-cases:
+- Providing a way to manipulate data in response to events
+- Enforce certain aspects of a database schema
+- Providing back-end logic for web applications using EliasDB
+
+ECAL related config values:
+--
+| Configuration Option | Description |
+| --- | --- |
+| EnableECALScripts | Enable ECAL scripting. |
+| ECALScriptFolder | Scripting folder for ECAL scripts. |
+| ECALEntryScript | Entry script in the script folder. |
+| ECALLogFile | File in which the logs should be written (use an empty string for stdout). |
+| ECALLogLevel | Log level for the printed logs. |
+| EnableECALDebugServer | Start an ECAL debug server. |
+| ECALDebugServerHost | Host for the debug server. |
+| ECALDebugServerPort | Port for the debug server. |
+
+ECAL Debugging
+--
+If the debug server is enabled in the config file then it is possible to debug ECAL scripts with [VSCode](https://devt.de/krotik/ecal/src/master/ecal-support/README.md). The debugger supports break points and thread state inspection. It is also possible to restart and reload the scripts.
+
+Using the `-ecal-console` parameter it is possible to open an interactive console into the server process. If used together with the debug server additional debug commands are available also there. Enter `?` to see the build-in documentation.
 
 EliasDB specific events which can be handled:
 --
 The ECAL interpreter in EliasDB receives the following events:
 
+Web Request | ECAL event kind | Event state contents | Description
+-|-|-|-
+/db/api/|`db.web.api`| bodyJSON, bodyString, header, method, path, pathList, query | Any web request to /db/api/*. These endpoints are public and never require authentication.
+/db/ecal/|`db.web.ecal`| bodyJSON, bodyString, header, method, path, pathList, query | Any web request to /db/ecal/*. These endpoints are considered internal and require authentication if access control is enabled.
+
 EliasDB Graph Event | ECAL event kind | Event state contents | Description
 -|-|-|-
 graph.EventNodeCreated | `db.node.created` | part, trans, node | A node was created.
@@ -16,15 +45,12 @@ graph.EventEdgeCreated | `db.edge.created` | part, trans, edge | An edge was cre
 graph.EventEdgeUpdated | `db.edge.updated` | part, trans, edge, old_edge | An edge was updated.
 graph.EventEdgeDeleted | `db.edge.deleted` | part, trans, edge | An edge was deleted.
 graph.EventNodeStore | `db.node.store` | part, trans, node | A node is about to be stored (always overwriting existing values).
-graph.EventNodeUpdate | `db.node.update` | part, trans, node | A node is about to be updated. Only thrown when graph.UpdateNode is called directly.
+graph.EventNodeUpdate | `db.node.update` | part, trans, node | A node is about to be updated.
 graph.EventNodeDelete | `db.node.delete` | part, trans, key, kind | A node is about to be deleted.
 graph.EventEdgeStore | `db.edge.store` | part, trans, edge | An edge is about to be stored.
 graph.EventEdgeDelete | `db.edge.delete` | part, trans, key, kind | An edge is about to be deleted.
 
-TODO: Explaination
-Add special function to signal that an event has been handled
-
-Note: EliasDB will wait for the event cascade to be finished before performing the actual operation (e.g. inserting a node). If the event handling requires a time consuming operation then a new event cascade should be started using `addEvent` with a scope:
+Note: EliasDB will wait for the event cascade to be finished before performing the actual operation (e.g. inserting a node). If the event handling requires a time consuming operation then a new parallel event cascade can be started using `addEvent` with a scope:
 ```
 addEvent("request", "foo.bar.xxx", {
    "payload" : 123
@@ -55,6 +81,24 @@ db.storeNode("main", {
 })
 ```
 
+#### `db.updateNode(partition, nodeMap, [transaction])`
+Updates a node in EliasDB (only update the given values of the node).
+
+Parameter | Description
+-|-
+partition | Partition of the node
+nodeMap | Node object as a map with at least a key and a kind attribute
+transaction | Optional a transaction to group a set of changes
+
+Example:
+```
+db.updateNode("main", {
+  "key" : "foo",
+  "kind" : "bar",
+  "data" : 123,
+})
+```
+
 #### `db.removeNode(partition, nodeKey, nodeKind, [transaction])`
 Removes a node in EliasDB.
 
@@ -227,3 +271,23 @@ sink mysink
   db.raiseGraphEventHandled()
 }
 ```
+
+#### `db.raiseWebEventHandled()`
+"When handling a web event, notify the web API of EliasDB that the web request was handled.
+
+Example:
+```
+sink mysink
+  kindmatch [ "web.*.*" ],
+{
+  db.raiseWebEventHandled({
+    "status" : 200,
+    "headers" : {
+      "Date": "today"
+    },
+    "body" : {
+      "mydata" : [1,2,3]
+    }
+  })
+}
+```

+ 61 - 2
ecal/dbfunc/node.go

@@ -19,7 +19,7 @@ import (
 )
 
 /*
-StoreNodeFunc inserts or updates a node in EliasDB.
+StoreNodeFunc inserts a node in EliasDB.
 */
 type StoreNodeFunc struct {
 	GM *graph.Manager
@@ -74,7 +74,66 @@ func (f *StoreNodeFunc) Run(instanceID string, vs parser.Scope, is map[string]in
 DocString returns a descriptive string.
 */
 func (f *StoreNodeFunc) DocString() (string, error) {
-	return "Inserts or updates a node in EliasDB.", nil
+	return "Inserts a node in EliasDB.", nil
+}
+
+/*
+UpdateNodeFunc updates a node in EliasDB (only update the given values of the node).
+*/
+type UpdateNodeFunc struct {
+	GM *graph.Manager
+}
+
+/*
+Run executes the ECAL function.
+*/
+func (f *UpdateNodeFunc) Run(instanceID string, vs parser.Scope, is map[string]interface{}, tid uint64, args []interface{}) (interface{}, error) {
+	var err error
+
+	if arglen := len(args); arglen != 2 && arglen != 3 {
+		err = fmt.Errorf("Function requires 2 or 3 parameters: partition, node" +
+			" map and optionally a transaction")
+	}
+
+	if err == nil {
+		var trans graph.Trans
+
+		part := fmt.Sprint(args[0])
+		nodeMap, ok := args[1].(map[interface{}]interface{})
+
+		// Check parameters
+
+		if !ok {
+			err = fmt.Errorf("Second parameter must be a map")
+		}
+
+		if err == nil && len(args) > 2 {
+			if trans, ok = args[2].(graph.Trans); !ok {
+				err = fmt.Errorf("Third parameter must be a transaction")
+			}
+		}
+
+		// Store the node
+
+		if err == nil {
+			node := NewGraphNodeFromECALMap(nodeMap)
+
+			if trans != nil {
+				err = trans.UpdateNode(part, node)
+			} else {
+				err = f.GM.UpdateNode(part, node)
+			}
+		}
+	}
+
+	return nil, err
+}
+
+/*
+DocString returns a descriptive string.
+*/
+func (f *UpdateNodeFunc) DocString() (string, error) {
+	return "Updates a node in EliasDB (only update the given values of the node).", nil
 }
 
 /*

+ 58 - 4
ecal/dbfunc/node_test.go

@@ -57,8 +57,57 @@ func TestStoreAndRemoveNode(t *testing.T) {
 	}
 
 	if _, err := sn.Run("", nil, nil, 0, []interface{}{"main", map[interface{}]interface{}{
+		"key":   "foo",
+		"kind":  "bar",
+		"data":  "123",
+		"data2": "1234",
+	}}); err != nil {
+		t.Error(err)
+		return
+	}
+
+	if res := gm.NodeCount("bar"); res != 1 {
+		t.Error("Unexpected result:", res)
+		return
+	}
+
+	un := &UpdateNodeFunc{gm}
+
+	if _, err := un.DocString(); err != nil {
+		t.Error(err)
+		return
+	}
+
+	if _, err := un.Run("", nil, nil, 0, []interface{}{""}); err == nil ||
+		err.Error() != "Function requires 2 or 3 parameters: partition, node map and optionally a transaction" {
+		t.Error(err)
+		return
+	}
+
+	if _, err := un.Run("", nil, nil, 0, []interface{}{"", "bla"}); err == nil ||
+		err.Error() != "Second parameter must be a map" {
+		t.Error(err)
+		return
+	}
+
+	if _, err := un.Run("", nil, nil, 0, []interface{}{"main", map[interface{}]interface{}{}, "bla"}); err == nil ||
+		err.Error() != "Third parameter must be a transaction" {
+		t.Error(err)
+		return
+	}
+
+	if _, err := un.Run("", nil, nil, 0, []interface{}{"main", map[interface{}]interface{}{
+		"key": "foo",
+	}}); err == nil ||
+		err.Error() != "GraphError: Invalid data (Node is missing a kind value)" {
+		t.Error(err)
+		return
+	}
+
+	if _, err := un.Run("", nil, nil, 0, []interface{}{"main", map[interface{}]interface{}{
 		"key":  "foo",
 		"kind": "bar",
+		"data": "1234",
 	}}); err != nil {
 		t.Error(err)
 		return
@@ -89,12 +138,15 @@ func TestStoreAndRemoveNode(t *testing.T) {
 	}
 
 	res, err := fn.Run("", nil, nil, 0, []interface{}{"main", "foo", "bar"})
+
 	if fmt.Sprint(NewGraphNodeFromECALMap(res.(map[interface{}]interface{}))) != `
 GraphNode:
-     key : foo
-    kind : bar
+      key : foo
+     kind : bar
+     data : 1234
+    data2 : 1234
 `[1:] || err != nil {
-		t.Error("Unexpected result:", res, err)
+		t.Error("Unexpected result:\n", res, err)
 		return
 	}
 
@@ -213,7 +265,9 @@ func TestStoreNodeTrans(t *testing.T) {
 		return
 	}
 
-	if _, err := sn.Run("", nil, nil, 0, []interface{}{"main", map[interface{}]interface{}{
+	un := &UpdateNodeFunc{gm}
+
+	if _, err := un.Run("", nil, nil, 0, []interface{}{"main", map[interface{}]interface{}{
 		"key":  "foo3",
 		"kind": "bar",
 	}, trans}); err != nil {

+ 57 - 0
ecal/dbfunc/util.go

@@ -11,7 +11,11 @@
 package dbfunc
 
 import (
+	"fmt"
+
+	"devt.de/krotik/ecal/interpreter"
 	"devt.de/krotik/ecal/parser"
+	"devt.de/krotik/ecal/util"
 	"devt.de/krotik/eliasdb/graph"
 )
 
@@ -36,3 +40,56 @@ DocString returns a descriptive string.
 func (f *RaiseGraphEventHandledFunc) DocString() (string, error) {
 	return "When handling a graph event, notify the GraphManager of EliasDB that no further action is necessary.", nil
 }
+
+var ErrWebEventHandled = fmt.Errorf("Web event handled")
+
+/*
+RaiseWebEventHandledFunc returns a special error which a sink can return to notify
+the web API that a web request was handled.
+*/
+type RaiseWebEventHandledFunc struct {
+}
+
+/*
+Run executes the ECAL function.
+*/
+func (f *RaiseWebEventHandledFunc) Run(instanceID string, vs parser.Scope, is map[string]interface{}, tid uint64, args []interface{}) (interface{}, error) {
+	if arglen := len(args); arglen != 1 {
+		return nil, fmt.Errorf("Function requires 1 parameter: request response object")
+	}
+
+	res := args[0]
+
+	if resMap, ok := res.(map[interface{}]interface{}); !ok {
+		return nil, fmt.Errorf("Request response object should be a map")
+	} else {
+		if _, ok := resMap["status"]; !ok {
+			resMap["status"] = 200
+		}
+		if _, ok := resMap["headers"]; !ok {
+			resMap["header"] = map[interface{}]interface{}{
+				"Content-Type":           "application/json; charset=utf-8",
+				"X-Content-Type-Options": "nosniff",
+			}
+		}
+		if _, ok := resMap["body"]; !ok {
+			resMap["body"] = map[interface{}]interface{}{}
+		}
+	}
+
+	erp := is["erp"].(*interpreter.ECALRuntimeProvider)
+	node := is["astnode"].(*parser.ASTNode)
+
+	return nil, &util.RuntimeErrorWithDetail{
+		RuntimeError: erp.NewRuntimeError(ErrWebEventHandled, "", node).(*util.RuntimeError),
+		Environment:  vs,
+		Data:         res,
+	}
+}
+
+/*
+DocString returns a descriptive string.
+*/
+func (f *RaiseWebEventHandledFunc) DocString() (string, error) {
+	return "When handling a web event, notify the web API of EliasDB that the web request was handled.", nil
+}

+ 42 - 3
ecal/dbfunc/util_test.go

@@ -13,19 +13,58 @@ package dbfunc
 import (
 	"testing"
 
+	"devt.de/krotik/ecal/interpreter"
+	"devt.de/krotik/ecal/parser"
+	"devt.de/krotik/ecal/util"
 	"devt.de/krotik/eliasdb/graph"
 )
 
 func TestRaiseGraphEventHandled(t *testing.T) {
 
-	sn := &RaiseGraphEventHandledFunc{}
+	f := &RaiseGraphEventHandledFunc{}
 
-	if _, err := sn.DocString(); err != nil {
+	if _, err := f.DocString(); err != nil {
 		t.Error(err)
 		return
 	}
 
-	if _, err := sn.Run("", nil, nil, 0, []interface{}{}); err != graph.ErrEventHandled {
+	if _, err := f.Run("", nil, nil, 0, []interface{}{}); err != graph.ErrEventHandled {
+		t.Error("Unexpected result:", err)
+		return
+	}
+}
+
+func TestRaiseWebEventHandled(t *testing.T) {
+
+	f := &RaiseWebEventHandledFunc{}
+
+	if _, err := f.DocString(); err != nil {
+		t.Error(err)
+		return
+	}
+
+	if _, err := f.Run("", nil, nil, 0, []interface{}{}); err == nil ||
+		err.Error() != "Function requires 1 parameter: request response object" {
+		t.Error(err)
+		return
+	}
+
+	if _, err := f.Run("", nil, nil, 0, []interface{}{""}); err == nil ||
+		err.Error() != "Request response object should be a map" {
+		t.Error(err)
+		return
+	}
+
+	astnode, _ := parser.ASTFromJSONObject(map[string]interface{}{
+		"name": "foo",
+	})
+
+	_, err := f.Run("", nil, map[string]interface{}{
+		"erp":     interpreter.NewECALRuntimeProvider("", nil, nil),
+		"astnode": astnode,
+	}, 0, []interface{}{map[interface{}]interface{}{}})
+
+	if err.(*util.RuntimeErrorWithDetail).Type != ErrWebEventHandled {
 		t.Error("Unexpected result:", err)
 		return
 	}

+ 1 - 1
ecal/eventbridge.go

@@ -225,7 +225,7 @@ func (eb *EventBridge) Handle(gm *graph.Manager, trans graph.Trans, event int, e
 				}
 
 				if len(errList) > 0 {
-					err = &errorutil.CompositeError{errList}
+					err = &errorutil.CompositeError{Errors: errList}
 				} else {
 					err = graph.ErrEventHandled
 				}

+ 38 - 26
ecal/interpreter.go

@@ -29,11 +29,13 @@ import (
 ScriptingInterpreter models a ECAL script interpreter instance.
 */
 type ScriptingInterpreter struct {
-	GM        *graph.Manager // GraphManager for the interpreter
-	Dir       string         // Root dir for interpreter
-	EntryFile string         // Entry file for the program
-	LogLevel  string         // Log level string (Debug, Info, Error)
-	LogFile   string         // Logfile (blank for stdout)
+	GM          *graph.Manager       // GraphManager for the interpreter
+	Interpreter *tool.CLIInterpreter // ECAL Interpreter object
+
+	Dir       string // Root dir for interpreter
+	EntryFile string // Entry file for the program
+	LogLevel  string // Log level string (Debug, Info, Error)
+	LogFile   string // Logfile (blank for stdout)
 
 	RunDebugServer  bool   // Run a debug server
 	DebugServerHost string // Debug server host
@@ -65,6 +67,13 @@ const dummyEntryFile = `0 # Write your ECAL code here
 
 /*
 Run runs the ECAL scripting interpreter.
+
+After this function completes:
+- EntryScript in config and all related scripts in the interpreter root dir have been executed
+- ECAL Interpreter object is fully initialized
+- A debug server might be running which can reload the entry script
+- ECAL's event processor has been started
+- GraphManager events are being forwarded to ECAL
 */
 func (si *ScriptingInterpreter) Run() error {
 	var err error
@@ -77,6 +86,7 @@ func (si *ScriptingInterpreter) Run() error {
 
 	if err == nil {
 		i := tool.NewCLIInterpreter()
+		si.Interpreter = i
 
 		i.Dir = &si.Dir
 		i.LogFile = &si.LogFile
@@ -89,21 +99,7 @@ func (si *ScriptingInterpreter) Run() error {
 
 		// Adding functions
 
-		stdlib.AddStdlibPkg("db", "EliasDB related functions")
-
-		stdlib.AddStdlibFunc("db", "storeNode", &dbfunc.StoreNodeFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "removeNode", &dbfunc.RemoveNodeFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "fetchNode", &dbfunc.FetchNodeFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "storeEdge", &dbfunc.StoreEdgeFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "removeEdge", &dbfunc.RemoveEdgeFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "fetchEdge", &dbfunc.FetchEdgeFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "traverse", &dbfunc.TraverseFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "newTrans", &dbfunc.NewTransFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "newRollingTrans", &dbfunc.NewRollingTransFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "commit", &dbfunc.CommitTransFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "query", &dbfunc.QueryFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "graphQL", &dbfunc.GraphQLFunc{GM: si.GM})
-		stdlib.AddStdlibFunc("db", "raiseGraphEventHandled", &dbfunc.RaiseGraphEventHandledFunc{})
+		AddEliasDBStdlibFunctions(si.GM)
 
 		if err == nil {
 
@@ -127,12 +123,7 @@ func (si *ScriptingInterpreter) Run() error {
 				err = i.Interpret(false)
 			}
 
-			// Rules for the GraphManager are loaded after the code has been
-			// executed and all rules have been added. The ECA processor can now be started.
-
-			i.RuntimeProvider.Processor.Start()
-
-			// EliasDB graph events can not be forwarded to ECAL via the eventbridge.
+			// EliasDB graph events are now forwarded to ECAL via the eventbridge.
 
 			si.GM.SetGraphRule(&EventBridge{
 				Processor: i.RuntimeProvider.Processor,
@@ -149,3 +140,24 @@ func (si *ScriptingInterpreter) Run() error {
 
 	return err
 }
+
+func AddEliasDBStdlibFunctions(gm *graph.Manager) {
+	stdlib.AddStdlibPkg("db", "EliasDB related functions")
+
+	stdlib.AddStdlibFunc("db", "storeNode", &dbfunc.StoreNodeFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "updateNode", &dbfunc.UpdateNodeFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "removeNode", &dbfunc.RemoveNodeFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "fetchNode", &dbfunc.FetchNodeFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "storeEdge", &dbfunc.StoreEdgeFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "removeEdge", &dbfunc.RemoveEdgeFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "fetchEdge", &dbfunc.FetchEdgeFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "traverse", &dbfunc.TraverseFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "newTrans", &dbfunc.NewTransFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "newRollingTrans", &dbfunc.NewRollingTransFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "commit", &dbfunc.CommitTransFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "query", &dbfunc.QueryFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "graphQL", &dbfunc.GraphQLFunc{GM: gm})
+	stdlib.AddStdlibFunc("db", "raiseGraphEventHandled", &dbfunc.RaiseGraphEventHandledFunc{})
+	stdlib.AddStdlibFunc("db", "raiseWebEventHandled", &dbfunc.RaiseWebEventHandledFunc{})
+
+}

+ 1 - 1
go.mod

@@ -4,6 +4,6 @@ go 1.12
 
 require (
 	devt.de/krotik/common v1.4.1
-	devt.de/krotik/ecal v1.2.0
+	devt.de/krotik/ecal v1.3.1
 	github.com/gorilla/websocket v1.4.1
 )

+ 2 - 2
go.sum

@@ -2,7 +2,7 @@ devt.de/krotik/common v1.4.0 h1:chZihshmuv1yehyujrYyW7Yg4cgRqqIWEG2IAzhfFkA=
 devt.de/krotik/common v1.4.0/go.mod h1:X4nsS85DAxyHkwSg/Tc6+XC2zfmGeaVz+37F61+eSaI=
 devt.de/krotik/common v1.4.1 h1:gsZ9OrV+Eo4ar8Y5iLs1lAdWd8aRIcPQJ0CVxLp0uys=
 devt.de/krotik/common v1.4.1/go.mod h1:X4nsS85DAxyHkwSg/Tc6+XC2zfmGeaVz+37F61+eSaI=
-devt.de/krotik/ecal v1.2.0 h1:gaGUNVpx9YInNHctQ7Ciur53Wd8qdCwn5CKJoVWZgJ8=
-devt.de/krotik/ecal v1.2.0/go.mod h1:0qIx3h+EjUnStgdEUnwAeO44UluTSLcpBWXA5zEw0hQ=
+devt.de/krotik/ecal v1.3.1 h1:WuEJNHKupvTSg2+IGyNatMAaIXi9OjZ0mu1PYka9bW8=
+devt.de/krotik/ecal v1.3.1/go.mod h1:0qIx3h+EjUnStgdEUnwAeO44UluTSLcpBWXA5zEw0hQ=
 github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
 github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

BIN
old/old_brawler_integration.tar


+ 1 - 0
server/server.go

@@ -267,6 +267,7 @@ func StartServerWithSingleOp(singleOperation func(*graph.Manager) bool) {
 	// Register public REST endpoints - these will never be checked for authentication
 
 	api.RegisterRestEndpoints(api.GeneralEndpointMap)
+	api.RegisterRestEndpoints(v1.V1PublicEndpointMap)
 
 	// Setup access control