Files
sarin/internal/script/js_http.go
T

221 lines
5.7 KiB
Go

package script
import (
"errors"
"github.com/dop251/goja"
"go.aykhans.me/sarin/internal/types"
)
// registerHTTP defines the global http function.
func (e *JsEngine) registerHTTP() error {
rt := e.runtime
jsonParse, ok := goja.AssertFunction(rt.Get("JSON").ToObject(rt).Get("parse"))
if !ok {
return errors.New("JSON.parse is not available")
}
e.jsonParse = jsonParse
e.errorConstructor = rt.Get("Error")
return rt.Set("http", func(call goja.FunctionCall) goja.Value {
return e.httpCall(call.Argument(0), call.Argument(1))
})
}
func (e *JsEngine) httpCall(urlValue, optsValue goja.Value) goja.Value {
rt := e.runtime
url, ok := urlValue.Export().(string)
if !ok {
panic(rt.NewTypeError("http: url must be a string, got %s", jsTypeName(urlValue)))
}
req := &HTTPRequest{Method: httpDefaultMethod, URL: url}
if !goja.IsUndefined(optsValue) && !goja.IsNull(optsValue) {
opts, ok := optsValue.(*goja.Object)
if !ok {
panic(rt.NewTypeError("http: options must be an object, got %s", jsTypeName(optsValue)))
}
if err := e.parseHTTPOptions(opts, req); err != nil {
e.throw(err)
}
}
resp, err := e.http.do(req)
if err != nil {
e.throw(err)
}
return e.httpResponseToObject(resp)
}
// parseHTTPOptions fills req from a JavaScript options object.
// It can return the following errors:
// - types.ScriptHTTPOptionError
func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error {
for _, name := range opts.Keys() {
value := opts.Get(name)
if goja.IsUndefined(value) {
continue
}
switch name {
case httpOptionMethod:
method, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", jsTypeName(value)))
}
req.Method = method
case httpOptionHeaders, httpOptionParams, httpOptionCookies:
object, ok := value.(*goja.Object)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("object", jsTypeName(value)))
}
values := e.objectToStringSliceMap(object)
switch name {
case httpOptionHeaders:
req.Headers = values
case httpOptionParams:
req.Params = values
default:
req.Cookies = values
}
case httpOptionBody:
body, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", jsTypeName(value)))
}
req.Body = body
case httpOptionTimeout:
timeout, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("duration string", jsTypeName(value)))
}
var err error
if req.Timeout, err = parseHTTPTimeout(timeout); err != nil {
return err
}
case httpOptionInsecure:
flag, ok := value.Export().(bool)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", jsTypeName(value)))
}
req.Insecure = flag
case httpOptionMaxRedirects:
count, ok := numberValue(value)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", jsTypeName(value)))
}
var err error
if req.MaxRedirects, err = parseHTTPMaxRedirects(count); err != nil {
return err
}
default:
return types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
}
}
return nil
}
// httpResponseToObject converts a response to a JavaScript object.
func (e *JsEngine) httpResponseToObject(resp *HTTPResponse) *goja.Object {
rt := e.runtime
obj := rt.NewObject()
headers := rt.NewObject()
for key, values := range resp.Headers {
_ = headers.Set(key, e.stringSliceToArray(values))
}
lookup := func(find func(string) (string, bool)) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
name, ok := call.Argument(0).Export().(string)
if !ok {
panic(rt.NewTypeError("name must be a string, got %s", jsTypeName(call.Argument(0))))
}
if value, ok := find(name); ok {
return rt.ToValue(value)
}
return goja.Null()
}
}
_ = obj.Set("status", resp.Status)
_ = obj.Set("headers", headers)
_ = obj.Set("body", resp.Body)
_ = obj.Set("header", lookup(resp.Header))
_ = obj.Set("cookie", lookup(resp.Cookie))
_ = obj.Set("json", func(goja.FunctionCall) goja.Value {
value, err := e.jsonParse(goja.Undefined(), rt.ToValue(resp.Body))
if err != nil {
panic(err)
}
return value
})
// body is a JavaScript string, so bytes that are not valid UTF-8 reach the script
// only through this.
_ = obj.Set("bytes", func(goja.FunctionCall) goja.Value {
buffer := rt.NewArrayBuffer([]byte(resp.Body))
value, err := rt.New(rt.Get("Uint8Array").ToObject(rt), rt.ToValue(buffer))
if err != nil {
panic(err)
}
return value
})
return obj
}
// jsTypeName returns a short type name for error messages.
func jsTypeName(value goja.Value) string {
switch {
case value == nil || goja.IsUndefined(value):
return "undefined"
case goja.IsNull(value):
return "null"
}
switch value.Export().(type) {
case string:
return "string"
case bool:
return "boolean"
case int64, float64:
return "number"
case []any:
return "array"
default:
if _, ok := goja.AssertFunction(value); ok {
return "function"
}
return "object"
}
}
// throw raises err as a plain JavaScript Error instead of a GoError.
func (e *JsEngine) throw(err error) {
errorValue, newErr := e.runtime.New(e.errorConstructor, e.runtime.ToValue(err.Error()))
if newErr != nil {
panic(e.runtime.NewGoError(err))
}
panic(errorValue)
}
// numberValue reports whether value is a JavaScript number, and returns it.
func numberValue(value goja.Value) (float64, bool) {
switch number := value.Export().(type) {
case int64:
return float64(number), true
case float64:
return number, true
default:
return 0, false
}
}