diff --git a/internal/resource/file.go b/internal/resource/file.go index 520e064..06eca6a 100644 --- a/internal/resource/file.go +++ b/internal/resource/file.go @@ -22,7 +22,11 @@ import ( "gitea.rosskeen.house/rosskeen.house/machine" "decl/internal/codec" "decl/internal/iofilter" + "decl/internal/data" + "decl/internal/folio" + "decl/internal/transport" "strings" + "embed" ) // Describes the type of file the resource represents @@ -44,10 +48,22 @@ var ErrInvalidFileMode error = errors.New("Invalid Mode") var ErrInvalidFileOwner error = errors.New("Unknown User") var ErrInvalidFileGroup error = errors.New("Unknown Group") +type FileMode string + func init() { - ResourceTypes.Register([]string{"file"}, func(u *url.URL) Resource { + folio.DocumentRegistry.ResourceTypes.Register([]string{"file"}, func(u *url.URL) data.Resource { f := NewFile() + f.parsedURI = u + //f.Uri.SetURL(u) f.Path = filepath.Join(u.Hostname(), u.Path) + f.exttype, f.fileext = f.Uri.Extension() + + slog.Info("folio.DocumentRegistry.ResourceTypes.Register()()", "url", u, "file", f) +/* + if absPath, err := filepath.Abs(f.Path); err == nil { + f.Filesystem = os.DirFS(filepath.Dir(absPath)) + } +*/ return f }) } @@ -62,27 +78,36 @@ The `SerializeContent` the flag allows forcing the content to be serialized in t */ type File struct { + Uri folio.URI `json:"uri,omitempty" yaml:"uri,omitempty"` + parsedURI *url.URL `json:"-" yaml:"-"` + Filesystem fs.FS `json:"-" yaml:"-"` + + exttype string `json:"-" yaml:"-"` + fileext string `json:"-" yaml:"-"` stater machine.Stater `json:"-" yaml:"-"` normalizePath bool `json:"-" yaml:"-"` + absPath string `json:"-" yaml:"-"` + basePath int `json:"-" yaml:"-"` + Path string `json:"path" yaml:"path"` Owner string `json:"owner" yaml:"owner"` Group string `json:"group" yaml:"group"` - Mode string `json:"mode" yaml:"mode"` + Mode FileMode `json:"mode" yaml:"mode"` Atime time.Time `json:"atime,omitempty" yaml:"atime,omitempty"` Ctime time.Time `json:"ctime,omitempty" yaml:"ctime,omitempty"` Mtime time.Time `json:"mtime,omitempty" yaml:"mtime,omitempty"` Content string `json:"content,omitempty" yaml:"content,omitempty"` - ContentSourceRef ResourceReference `json:"sourceref,omitempty" yaml:"sourceref,omitempty"` + ContentSourceRef folio.ResourceReference `json:"sourceref,omitempty" yaml:"sourceref,omitempty"` Sha256 string `json:"sha256,omitempty" yaml:"sha256,omitempty"` Size int64 `json:"size,omitempty" yaml:"size,omitempty"` Target string `json:"target,omitempty" yaml:"target,omitempty"` FileType FileType `json:"filetype" yaml:"filetype"` State string `json:"state,omitempty" yaml:"state,omitempty"` SerializeContent bool `json:"serializecontent,omitempty" yaml:"serializecontent,omitempty"` - config ConfigurationValueGetter - Resources ResourceMapper `json:"-" yaml:"-"` + config data.ConfigurationValueGetter + Resources data.ResourceMapper `json:"-" yaml:"-"` } type ResourceFileInfo struct { @@ -103,13 +128,25 @@ func NewNormalizedFile() *File { return f } -func (f *File) SetResourceMapper(resources ResourceMapper) { +func (f *File) ContentType() string { + if f.parsedURI.Scheme != "file" { + return f.parsedURI.Scheme + } + return f.exttype +} + +func (f *File) SetResourceMapper(resources data.ResourceMapper) { f.Resources = resources } -func (f *File) Clone() Resource { +func (f *File) Clone() data.Resource { return &File { + Uri: f.Uri, + parsedURI: f.parsedURI, + exttype: f.exttype, + fileext: f.fileext, normalizePath: f.normalizePath, + absPath: f.absPath, Path: f.Path, Owner: f.Owner, Group: f.Group, @@ -181,26 +218,69 @@ func (f *File) Notify(m *machine.EventMessage) { } } +func (f *File) PathNormalization(flag bool) { + f.normalizePath = flag +} + +func (f *File) FilePath() string { + return f.Path +} + +func (f *File) SetFS(fsys fs.FS) { + f.Filesystem = fsys +} + func (f *File) URI() string { return fmt.Sprintf("file://%s", f.Path) } -func (f *File) SetURI(uri string) error { - resourceUri, e := url.Parse(uri) - if e == nil { - if resourceUri.Scheme == "file" { - f.Path = filepath.Join(resourceUri.Hostname(), resourceUri.Path) - if err := f.NormalizePath(); err != nil { - return err - } - } else { - e = fmt.Errorf("%w: %s is not a file", ErrInvalidResourceURI, uri) - } - } - return e +func (f *File) RelativePath() string { + return f.Path[f.basePath:] } -func (f *File) UseConfig(config ConfigurationValueGetter) { +func (f *File) SetBasePath(index int) { + if index < len(f.Path) { + f.basePath = index + } +} + +func (f *File) SetURI(uri string) (err error) { + slog.Info("File.SetURI()", "uri", uri, "file", f, "parsed", f.parsedURI) + f.SetURIFromString(uri) + err = f.SetParsedURI(f.Uri.Parse()) + return +} + +func (f *File) SetURIFromString(uri string) { + f.Uri = folio.URI(uri) + f.exttype, f.fileext = f.Uri.Extension() +} + +func (f *File) SetParsedURI(u *url.URL) (err error) { + if u != nil { + if u.Scheme == "" { + u.Scheme = "file" + f.Uri = "" + } + if f.Uri.IsEmpty() { + f.SetURIFromString(u.String()) + } + slog.Info("File.SetParsedURI()", "parsed", u, "path", f.Path) + f.parsedURI = u + if f.parsedURI.Scheme == "file" { + f.Path = filepath.Join(f.parsedURI.Hostname(), f.parsedURI.Path) + slog.Info("File.SetParsedURI()", "path", f.Path) + if err = f.NormalizePath(); err != nil { + return + } + return + } + } + err = fmt.Errorf("%w: %s is not a file", ErrInvalidResourceURI, f.Uri) + return +} + +func (f *File) UseConfig(config data.ConfigurationValueGetter) { f.config = config } @@ -229,15 +309,34 @@ func (f *File) Apply() error { return nil } -func (f *File) LoadDecl(yamlResourceDeclaration string) (err error) { - d := codec.NewYAMLStringDecoder(yamlResourceDeclaration) - err = d.Decode(f) +func (f *File) Load(docData []byte, format codec.Format) (err error) { + err = format.StringDecoder(string(docData)).Decode(f) if err == nil { f.UpdateContentAttributes() } return } +func (f *File) LoadReader(r io.ReadCloser, format codec.Format) (err error) { + err = format.Decoder(r).Decode(f) + if err == nil { + f.UpdateContentAttributes() + } + return +} + +func (f *File) LoadString(docData string, format codec.Format) (err error) { + err = format.StringDecoder(docData).Decode(f) + if err == nil { + f.UpdateContentAttributes() + } + return +} + +func (f *File) LoadDecl(yamlResourceDeclaration string) (err error) { + return f.LoadString(yamlResourceDeclaration, codec.FormatYaml) +} + func (f *File) ResolveId(ctx context.Context) string { if e := f.NormalizePath(); e != nil { panic(e) @@ -245,20 +344,34 @@ func (f *File) ResolveId(ctx context.Context) string { return f.Path } -func (f *File) NormalizePath() error { +func (f *File) NormalizePath() (err error) { if f.config != nil { if prefixPath, configErr := f.config.GetValue("prefix"); configErr == nil { f.Path = filepath.Join(prefixPath.(string), f.Path) } } - if f.normalizePath { - filePath, fileAbsErr := filepath.Abs(f.Path) - if fileAbsErr == nil { - f.Path = filePath - } - return fileAbsErr + if f.absPath, err = filepath.Abs(f.Path); err == nil && f.normalizePath { + f.Path = f.absPath } - return nil + return +} + +func (f *File) GetContentSourceRef() string { + return string(f.ContentSourceRef) +} + +func (f *File) SetContentSourceRef(uri string) { + f.Size = 0 + f.ContentSourceRef = folio.ResourceReference(uri) +} + +func (f *File) Stat() (info fs.FileInfo, err error) { + if _, ok := f.Filesystem.(embed.FS); ok { + info, err = fs.Stat(f.Filesystem, f.Path) + } else { + info, err = os.Lstat(f.absPath) + } + return } func (f *File) FileInfo() fs.FileInfo { @@ -267,7 +380,7 @@ func (f *File) FileInfo() fs.FileInfo { func (f *ResourceFileInfo) Name() string { // return filepath.Base(f.resource.Path) - return f.resource.Path + return f.resource.RelativePath() } func (f *ResourceFileInfo) Size() int64 { @@ -275,8 +388,8 @@ func (f *ResourceFileInfo) Size() int64 { } func (f *ResourceFileInfo) Mode() (mode os.FileMode) { - if fileMode, fileModeErr := strconv.ParseInt(f.resource.Mode, 8, 64); fileModeErr == nil { - mode |= os.FileMode(fileMode) + if fileMode, fileModeErr := f.resource.Mode.GetMode(); fileModeErr == nil { + mode |= fileMode } else { panic(fileModeErr) } @@ -306,12 +419,12 @@ func (f *File) Create(ctx context.Context) error { if gidErr != nil { return gidErr } - mode, modeErr := strconv.ParseInt(f.Mode, 8, 64) + + mode, modeErr := f.Mode.GetMode() if modeErr != nil { - return fmt.Errorf("%w: invalid mode %d", ErrInvalidFileMode, mode) + return modeErr } - //e := os.Stat(f.path) - //if os.IsNotExist(e) { + switch f.FileType { case SymbolicLinkFile: linkErr := os.Symlink(f.Target, f.Path) @@ -319,7 +432,7 @@ func (f *File) Create(ctx context.Context) error { return linkErr } case DirectoryFile: - if mkdirErr := os.MkdirAll(f.Path, os.FileMode(mode)); mkdirErr != nil { + if mkdirErr := os.MkdirAll(f.Path, mode); mkdirErr != nil { return mkdirErr } default: @@ -351,19 +464,13 @@ func (f *File) Create(ctx context.Context) error { return e } defer createdFile.Close() - if chmodErr := createdFile.Chmod(os.FileMode(mode)); chmodErr != nil { + if chmodErr := createdFile.Chmod(mode); chmodErr != nil { return chmodErr } _, writeErr := io.CopyBuffer(createdFile, sumReadData, copyBuffer) if writeErr != nil { return fmt.Errorf("File.Create(): CopyBuffer failed %v %v: %w", createdFile, contentReader, writeErr) } -/* - _, writeErr := createdFile.Write([]byte(f.Content)) - if writeErr != nil { - return writeErr - } -*/ f.Sha256 = fmt.Sprintf("%x", hash.Sum(nil)) if !f.Mtime.IsZero() && !f.Atime.IsZero() { @@ -380,6 +487,10 @@ func (f *File) Create(ctx context.Context) error { return nil } +func (f *File) Update(ctx context.Context) error { + return f.Create(ctx) +} + func (f *File) Delete(ctx context.Context) error { return os.Remove(f.Path) } @@ -389,6 +500,10 @@ func (f *File) UpdateContentAttributes() { f.Sha256 = fmt.Sprintf("%x", sha256.Sum256([]byte(f.Content))) } +func (f *File) SetFileInfo(info os.FileInfo) error { + return f.UpdateAttributesFromFileInfo(info) +} + func (f *File) UpdateAttributesFromFileInfo(info os.FileInfo) error { if info != nil { f.Mtime = info.ModTime() @@ -414,21 +529,61 @@ func (f *File) UpdateAttributesFromFileInfo(info os.FileInfo) error { } } f.Size = info.Size() - f.Mode = fmt.Sprintf("%04o", info.Mode().Perm()) + f.Mode = FileMode(fmt.Sprintf("%04o", info.Mode().Perm())) f.FileType.SetMode(info.Mode()) return nil } return ErrInvalidFileInfo } -func (f *File) ReadStat() error { - info, e := os.Lstat(f.Path) - if e != nil { - f.State = "absent" - return e + +func (f *File) ContentSourceRefStat() (info fs.FileInfo) { + if len(f.ContentSourceRef) > 0 { + rs, _ := f.ContentReaderStream() + info, _ = rs.Stat() + rs.Close() + } + return +} + +func (f *File) ReadStat() (err error) { + var info fs.FileInfo + slog.Info("ReadStat()", "filesystem", f.Filesystem, "path", f.Path) + + info, err = f.Stat() + + slog.Info("ReadStat()", "filesystem", f.Filesystem, "path", f.Path, "info", info, "error", err) + + if err == nil { + _ = f.SetFileInfo(info) + } else { + if refStat := f.ContentSourceRefStat(); refStat != nil { + _ = f.SetFileInfo(refStat) + //f.Size = refStat.Size() + err = nil + } + // XXX compare the mod times and set state to outdated } - return f.UpdateAttributesFromFileInfo(info) + + slog.Info("ReadStat()", "stat", info, "path", f.Path) + if err != nil { + f.State = "absent" + return + } + + return +} + +func (f *File) open() (file fs.File, err error) { + slog.Info("open()", "file", f.Path, "fs", f.Filesystem) + if _, ok := f.Filesystem.(embed.FS); ok { + file, err = f.Filesystem.Open(f.Path) + } else { + file, err = os.Open(f.Path) + } + slog.Info("open()", "file", f.Path, "error", err) + return } func (f *File) Read(ctx context.Context) ([]byte, error) { @@ -444,7 +599,8 @@ func (f *File) Read(ctx context.Context) ([]byte, error) { switch f.FileType { case RegularFile: if len(f.ContentSourceRef) == 0 || f.SerializeContent { - file, fileErr := os.Open(f.Path) + //file, fileErr := os.Open(f.Path) + file, fileErr := f.open() if fileErr != nil { panic(fileErr) } @@ -467,13 +623,85 @@ func (f *File) Read(ctx context.Context) ([]byte, error) { return yaml.Marshal(f) } +// set up reader for source content +func (f *File) readThru() (contentReader io.ReadCloser, err error) { + if len(f.ContentSourceRef) != 0 { + contentReader, err = f.ContentSourceRef.Lookup(nil).ContentReaderStream() + contentReader.(*transport.Reader).SetGzip(false) + slog.Info("File.readThru()", "reader", contentReader) + } else { + if len(f.Content) != 0 { + contentReader = io.NopCloser(strings.NewReader(f.Content)) + } else { + //contentReader, err = os.Open(f.Path) + contentReader, err = f.open() + } + } + contentReader = f.UpdateContentAttributesFromReader(contentReader) + return +} + +func (f *File) UpdateContentAttributesFromReader(reader io.ReadCloser) io.ReadCloser { + var content strings.Builder + hash := sha256.New() + f.Size = 0 + f.Content = "" + f.Sha256 = "" + return iofilter.NewReader(reader, func(p []byte, readn int, readerr error) (n int, err error) { + hash.Write(p[:readn]) + f.Sha256 = fmt.Sprintf("%x", hash.Sum(nil)) + f.Size += int64(readn) + if len(f.ContentSourceRef) == 0 || f.SerializeContent { + content.Write(p[:readn]) + f.Content = content.String() + } + return readn, readerr + }) +} + func (f *File) SetContent(r io.Reader) error { fileContent, ioErr := io.ReadAll(r) f.Content = string(fileContent) - f.Sha256 = fmt.Sprintf("%x", sha256.Sum256(fileContent)) + f.UpdateContentAttributes() return ioErr } +func (f *File) GetContent(w io.Writer) (contentReader io.ReadCloser, err error) { + slog.Info("File.GetContent()", "content", len(f.Content), "sourceref", f.ContentSourceRef) + + switch f.FileType { + case RegularFile: + contentReader, err = f.readThru() + + if w != nil { + copyBuffer := make([]byte, 32 * 1024) + _, writeErr := io.CopyBuffer(w, contentReader, copyBuffer) + if writeErr != nil { + return nil, fmt.Errorf("File.GetContent(): CopyBuffer failed %v %v: %w", w, contentReader, writeErr) + } + return nil, nil + } + } + + return +} + +func (f *File) ContentReaderStream() (*transport.Reader, error) { + if len(f.Content) == 0 && len(f.ContentSourceRef) != 0 { + return f.ContentSourceRef.Lookup(nil).ContentReaderStream() + } + return nil, fmt.Errorf("Cannot provide transport reader for string content") +} + +// ContentWriterStream() would not provide a mechanism to keep the in-memory state up-to-date +func (f *File) ContentWriterStream() (*transport.Writer, error) { + if len(f.Content) == 0 && len(f.ContentSourceRef) != 0 { + return f.ContentSourceRef.Lookup(nil).ContentWriterStream() + } + return nil, fmt.Errorf("Cannot provide transport writer for string content") +} + +func (f *File) GetTarget() string { return f.Target } func (f *File) Type() string { return "file" } func (f *FileType) UnmarshalYAML(value *yaml.Node) error { @@ -528,3 +756,11 @@ func (f *FileType) GetMode() (mode os.FileMode) { } return } + +func (f *FileMode) GetMode() (os.FileMode, error) { + if mode, modeErr := strconv.ParseInt(string(*f), 8, 64); modeErr != nil { + return os.FileMode(mode), fmt.Errorf("%w: %s invalid mode %d - %w", ErrInvalidFileMode, *f, mode, modeErr) + } else { + return os.FileMode(mode), nil + } +} diff --git a/internal/resource/file_test.go b/internal/resource/file_test.go index b614b75..dfda0c2 100644 --- a/internal/resource/file_test.go +++ b/internal/resource/file_test.go @@ -8,19 +8,22 @@ import ( "fmt" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" - _ "io" + "io" _ "log" _ "net/http" _ "net/http/httptest" _ "net/url" "os" "path/filepath" - _ "strings" + "strings" "syscall" "testing" "time" "os/user" "io/fs" + "decl/internal/codec" + "decl/internal/data" + "log/slog" ) func TestNewFileResource(t *testing.T) { @@ -101,12 +104,29 @@ func TestReadFile(t *testing.T) { assert.YAMLEq(t, expected, string(r)) } +func TestUseConfig(t *testing.T) { + + file, _ := filepath.Abs(filepath.Join(TempDir, "missingfile.txt")) + + f := NewFile() + assert.NotNil(t, f) + f.UseConfig(MockConfig(func(key string) (any, error) { + if key == "prefix" { + return "/tmp", nil + } + return nil, data.ErrUnknownConfigurationKey + })) + + assert.Nil(t, f.SetURI(fmt.Sprintf("file://%s", file))) + assert.Equal(t, filepath.Join("/tmp", file), f.FilePath()) +} + func TestReadFileError(t *testing.T) { ctx := context.Background() file, _ := filepath.Abs(filepath.Join(TempDir, "missingfile.txt")) f := NewFile() - assert.NotEqual(t, nil, f) + assert.NotNil(t, f) f.Path = file _, e := f.Read(ctx) assert.ErrorIs(t, e, fs.ErrNotExist) @@ -129,7 +149,7 @@ func TestCreateFile(t *testing.T) { f := NewFile() e := f.LoadDecl(decl) - assert.Equal(t, nil, e) + assert.Nil(t, e) assert.Equal(t, ProcessTestUserName, f.Owner) applyErr := f.Apply() @@ -141,10 +161,44 @@ func TestCreateFile(t *testing.T) { assert.Greater(t, s.Size(), int64(0)) f.State = "absent" - assert.Equal(t, nil, f.Apply()) + assert.Nil(t, f.Apply()) assert.NoFileExists(t, file, nil) } +func TestLoadFile(t *testing.T) { + file, _ := filepath.Abs(filepath.Join(TempDir, "foo.txt")) + + decl := fmt.Sprintf(` + path: "%s" + owner: "%s" + group: "%s" + mode: "0600" + content: |- + test line 1 + test line 2 + state: present +`, file, ProcessTestUserName, ProcessTestGroupName) + + f := NewFile() + assert.Nil(t, f.Load([]byte(decl), codec.FormatYaml)) + assert.Equal(t, ProcessTestUserName, f.Owner) + assert.Greater(t, f.Size, int64(0)) + + reader := io.NopCloser(strings.NewReader(decl)) + fr := NewFile() + assert.Nil(t, fr.LoadReader(reader, codec.FormatYaml)) + assert.Equal(t, ProcessTestUserName, f.Owner) + assert.Greater(t, f.Size, int64(0)) + + contentReaderTransport, trErr := fr.ContentReaderStream() + assert.ErrorContains(t, trErr, "Cannot provide transport reader for string content") + assert.Nil(t, contentReaderTransport) + + contentWriterTransport, trErr := fr.ContentWriterStream() + assert.ErrorContains(t, trErr, "Cannot provide transport writer for string content") + assert.Nil(t, contentWriterTransport) +} + func TestFileType(t *testing.T) { fileType := []byte(` filetype: "directory" @@ -240,6 +294,9 @@ func TestFileUpdateAttributesFromFileInfo(t *testing.T) { updateAttributesErr := f.UpdateAttributesFromFileInfo(info) assert.Nil(t, updateAttributesErr) assert.Equal(t, DirectoryFile, f.FileType) + + assert.ErrorIs(t, f.SetFileInfo(nil), ErrInvalidFileInfo) + } func TestFileReadStat(t *testing.T) { @@ -267,11 +324,14 @@ func TestFileReadStat(t *testing.T) { l := NewFile() assert.NotNil(t, l) + assert.Nil(t, l.NormalizePath()) l.FileType = SymbolicLinkFile l.Path = link l.Target = linkTargetFile l.State = "present" + slog.Info("TestFileReadStat()", "file", f, "link", l) + applyErr := l.Apply() assert.Nil(t, applyErr) readStatErr := l.ReadStat() @@ -292,6 +352,7 @@ func TestFileResourceFileInfo(t *testing.T) { f.Path = testFile f.Mode = "0600" + f.Content = "some test data" f.State = "present" assert.Nil(t, f.Apply()) @@ -300,6 +361,11 @@ func TestFileResourceFileInfo(t *testing.T) { fi := f.FileInfo() assert.Equal(t, os.FileMode(0600), fi.Mode().Perm()) + assert.Equal(t, testFile, fi.Name()) + assert.False(t, fi.IsDir()) + assert.Nil(t, fi.Sys()) + assert.Greater(t, time.Now(), fi.ModTime()) + assert.Greater(t, fi.Size(), int64(0)) } func TestFileClone(t *testing.T) { @@ -352,13 +418,13 @@ func TestFileErrors(t *testing.T) { readStater := read.StateMachine() read.Path = testFile assert.Nil(t, readStater.Trigger("read")) - assert.Equal(t, "0631", read.Mode) + assert.Equal(t, FileMode("0631"), read.Mode) f.Mode = "900" assert.ErrorAs(t, stater.Trigger("create"), &ErrInvalidFileMode, "Apply should fail with NumError when converting invalid octal") assert.Nil(t, readStater.Trigger("read")) - assert.Equal(t, "0631", read.Mode) + assert.Equal(t, FileMode("0631"), read.Mode) f.Mode = "0631" f.Owner = "bar" @@ -466,7 +532,7 @@ func TestFilePathURI(t *testing.T) { f := NewFile() e := f.LoadDecl(decl) assert.Nil(t, e) - assert.Equal(t, "", f.Path) + assert.Equal(t, "", f.FilePath()) assert.ErrorContains(t, f.Validate(), "path: String length must be greater than or equal to 1") } @@ -484,7 +550,7 @@ func TestFileAbsent(t *testing.T) { f := NewFile() stater := f.StateMachine() e := f.LoadDecl(decl) - assert.Equal(t, nil, e) + assert.Nil(t, e) assert.Equal(t, ProcessTestUserName, f.Owner) err := stater.Trigger("read") @@ -493,3 +559,51 @@ func TestFileAbsent(t *testing.T) { assert.Equal(t, "absent", f.State) } + +func TestFileReader(t *testing.T) { + expected := "datatoreadusinganio.readers" + dataReader := strings.NewReader(expected) + file, _ := filepath.Abs(filepath.Join(TempDir, "testabsentstate.txt")) + decl := fmt.Sprintf(` + path: "%s" + owner: "%s" + group: "%s" + mode: "0600" + content: "%s" + filetype: "regular" +`, file, ProcessTestUserName, ProcessTestGroupName, expected) + + f := NewFile() + assert.Nil(t, f.LoadString(decl, codec.FormatYaml)) + assert.Nil(t, f.SetContent(dataReader)) + reader, err := f.GetContent(nil) + assert.Nil(t, err) + value, ioErr := io.ReadAll(reader) + assert.Nil(t, ioErr) + assert.Equal(t, expected, string(value)) + + var writer strings.Builder + _, writerErr := f.GetContent(&writer) + assert.Nil(t, writerErr) + assert.Equal(t, expected, writer.String()) +} + +func TestFileSetURIError(t *testing.T) { + file := fmt.Sprintf("%s/%s", TempDir, "fooread.txt") + f := NewFile() + assert.NotNil(t, f) + e := f.SetURI("foo://" + file) + assert.NotNil(t, e) + assert.ErrorIs(t, e, ErrInvalidResourceURI) +} + +func TestFileContentType(t *testing.T) { + file := fmt.Sprintf("%s/%s", TempDir, "fooread.txt") + f := NewFile() + assert.NotNil(t, f) + e := f.SetURI("file://" + file) + assert.Nil(t, e) + assert.Equal(t, "txt", f.ContentType()) +} + +