Skip to content

Commit

Permalink
Implement fs module with open method, File and FileInfo abstractions
Browse files Browse the repository at this point in the history
  • Loading branch information
oleiade committed Jul 12, 2023
1 parent c2f09fc commit abee5b0
Show file tree
Hide file tree
Showing 6 changed files with 703 additions and 0 deletions.
51 changes: 51 additions & 0 deletions js/modules/k6/experimental/fs/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package fs

import "fmt"

// NewError creates a new Error object of the provided kind and with the
// provided message.
func NewError(k ErrorKind, message string) *Error {
return &Error{
Name: k,
Message: message,
}
}

// ErrorKind is a type alias for the kind of an error.
//
// Note that this is defined as a type alias, and not a binding, so
// that it is not interpreted as an object by goja.
type ErrorKind = string

const (
// NotFoundError is emitted when a file is not found.
NotFoundError ErrorKind = "NotFoundError"

// InvalidResourceError is emitted when a resource is invalid: for
// instance when attempting to open a directory, which is not supported.
InvalidResourceError ErrorKind = "InvalidResourceError"

// ForbiddenError is emitted when an operation is forbidden.
ForbiddenError ErrorKind = "ForbiddenError"

// TypeError is emitted when an incorrect type has been used.
TypeError ErrorKind = "TypeError"
)

// Error represents a custom error object emitted by the fs module.
type Error struct {
// Name contains the name of the error as formalized by the [ErrorKind]
// type.
Name ErrorKind `json:"name"`

// Message contains the error message as presented to the user.
Message string `json:"message"`
}

// Ensure that the Error type implements the Go `error` interface.
var _ error = (*Error)(nil)

// Error implements the Go `error` interface.
func (e *Error) Error() string {
return fmt.Sprintf(e.Name)
}
32 changes: 32 additions & 0 deletions js/modules/k6/experimental/fs/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package fs

import (
"path/filepath"
)

// file is an abstraction for interacting with files.
type file struct {
// name holds the name of the file, as presented to [Open].
path string

// data holds a pointer to the file's data
data []byte
}

// Stat returns a FileInfo describing the named file.
func (f *file) Stat() *FileInfo {
filename := filepath.Base(f.path)
return &FileInfo{Name: filename, Size: len(f.data)}
}

// FileInfo holds information about a file.
//
// It is a wrapper around the [fileInfo] struct, which is meant to be directly
// exposed to the JS runtime.
type FileInfo struct {
// Name holds the base name of the file.
Name string `json:"name"`

// Name holds the length in bytes of the file.
Size int `json:"size"`
}
185 changes: 185 additions & 0 deletions js/modules/k6/experimental/fs/module.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Package fs provides a k6 module that allows users to interact with files from the
// local filesystem.
package fs

import (
"fmt"
"path/filepath"

"github.com/dop251/goja"
"go.k6.io/k6/js/common"
"go.k6.io/k6/js/modules"
"go.k6.io/k6/js/promises"
"go.k6.io/k6/lib/fsext"
)

type (
// RootModule is the global module instance that will create instances of our
// module for each VU.
RootModule struct {
fileRegistry fileRegistry
}

// ModuleInstance represents an instance of the fs module for a single VU.
ModuleInstance struct {
vu modules.VU

fileRegistry *fileRegistry
}
)

var (
_ modules.Module = &RootModule{}
_ modules.Instance = &ModuleInstance{}
)

// New returns a pointer to a new [RootModule] instance.
func New() *RootModule {
return &RootModule{
fileRegistry: *newFileRegistry(),
}
}

// NewModuleInstance implements the modules.Module interface and returns a new
// instance of our module for the given VU.
func (rm *RootModule) NewModuleInstance(vu modules.VU) modules.Instance {
return &ModuleInstance{vu: vu, fileRegistry: &rm.fileRegistry}
}

// Exports implements the modules.Module interface and returns the exports of
// our module.
func (mi *ModuleInstance) Exports() modules.Exports {
return modules.Exports{
Named: map[string]any{
"open": mi.Open,
"File": File{},
"FileInfo": FileInfo{},
},
}
}

// Open opens a file and returns a promise that will resolve to a [File] instance
func (mi *ModuleInstance) Open(path goja.Value) *goja.Promise {
rt := mi.vu.Runtime()
promise, resolve, reject := promises.New(mi.vu)

// Files can only be opened in the init context.
if mi.vu.State() != nil {
reject(NewError(ForbiddenError, "open() failed; reason: opening a file in the VU context is forbidden"))
return promise
}

if common.IsNullish(path) || path == nil {
reject(NewError(TypeError, "open() failed; reason: path cannot be null or undefined"))
return promise
}

// Obtain the underlying path string from the JS value.
var pathStr string
if err := rt.ExportTo(path, &pathStr); err != nil {
reject(err)
return promise
}

if pathStr == "" {
reject(NewError(TypeError, "open() failed; reason: path cannot be empty"))
return promise
}

go func() {
file, err := mi.openImpl(pathStr)
if err != nil {
reject(err)
return
}

resolve(file)
}()

return promise
}

func (mi *ModuleInstance) openImpl(path string) (*File, error) {
initEnv := mi.vu.InitEnv()

// Here IsAbs should be enough but unfortunately it doesn't handle absolute paths starting from
// the current drive on windows like `\users\noname\...`. Also it makes it more easy to test and
// will probably be need for archive execution under windows if always consider '/...' as an
// absolute path.
if path[0] != '/' && path[0] != '\\' && !filepath.IsAbs(path) {
path = filepath.Join(initEnv.CWD.Path, path)
}
path = filepath.Clean(path)

if path[0:1] != fsext.FilePathSeparator {
path = fsext.FilePathSeparator + path
}

fs, ok := initEnv.FileSystems["file"]
if !ok {
panic("open() failed; reason: unable to access the filesystem")
}

if exists, err := fsext.Exists(fs, path); err != nil {
return nil, fmt.Errorf("open() failed, unable to verify if %q exists; reason: %w", path, err)
} else if !exists {
return nil, NewError(NotFoundError, fmt.Sprintf("no such file or directory %q", path))
}

if isDir, err := fsext.IsDir(fs, path); err != nil {
return nil, fmt.Errorf("open() failed, unable to verify if %q is a directory; reason: %w", path, err)
} else if isDir {
return nil, NewError(InvalidResourceError, fmt.Sprintf("cannot open %q: opening a directory is not supported", path))
}

data, err := mi.fileRegistry.open(path, fs)
if err != nil {
return nil, err
}

return &File{
Path: path,
file: file{
path: path,
data: data,
},
vu: mi.vu,
registry: mi.fileRegistry,
}, nil
}

// File represents a file and exposes methods to interact with it.
//
// It is a wrapper around the [file] struct, which is meant to be directly
// exposed to the JS runtime.
type File struct {
// Path holds the name of the file, as presented to [Open].
Path string `json:"path"`

// fileImpl contains the actual implementation of the FileImpl.
file

// vu holds a reference to the VU this file is associated with.
//
// We need this to be able to access the VU's runtime, and produce
// promises that are handled by the VU's runtime.
vu modules.VU

// registry holds a pointer to the file registry this file is associated
// with. That way we are able to close the file when it's not needed
// anymore.
registry *fileRegistry
}

// Stat returns a promise that will resolve to a [FileInfo] instance describing
// the file.
func (f *File) Stat() *goja.Promise {
promise, resolve, _ := promises.New(f.vu)

go func() {
fmt.Printf("Address of the file data: %p\n", f.file.data)
resolve(f.file.Stat())
}()

return promise
}
Loading

0 comments on commit abee5b0

Please sign in to comment.