Skip to content

Commit a5c0582

Browse files
committed
refact: loading
* loading: * feature: added JSONDoc() and JSONMatcher() for symetry with YAMLDoc(). Will allow to deprecate [github.com/go-openapi/loads.JSONDoc] * tests: refactored using embedded fixture * added support for plain fs.FS file systems, not just fs.ReadFileFS * fixed bug in local file loading strategy: using embed.FS on windows should not use '\' for paths. For convenience, strip invalid leading '/' or '.' from embed.FS paths. Basically, local embed FS on windows behaves like on unix. Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
1 parent d881174 commit a5c0582

File tree

9 files changed

+161
-35
lines changed

9 files changed

+161
-35
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"swagger":"2.0","info":{"version":"1.0.0","title":"Swagger Petstore","description":"A sample API that uses a petstore as an example to demonstrate features in the swagger-2.0 specification","termsOfService":"http://helloreverb.com/terms/","contact":{"name":"Swagger API team","email":"foo@example.com","url":"http://swagger.io"},"license":{"name":"MIT","url":"http://opensource.org/licenses/MIT"}},"host":"petstore.swagger.wordnik.com","basePath":"/api","schemes":["http"],"consumes":["application/json"],"produces":["application/json"],"paths":{"/pets":{"get":{"description":"Returns all pets from the system that the user has access to","operationId":"findPets","produces":["application/json","application/xml","text/xml","text/html"],"parameters":[{"name":"tags","in":"query","description":"tags to filter by","required":false,"type":"array","items":{"type":"string"},"collectionFormat":"csv"},{"name":"limit","in":"query","description":"maximum number of results to return","required":false,"type":"integer","format":"int32"}],"responses":{"200":{"description":"pet response","schema":{"type":"array","items":{"$ref":"#/definitions/pet"}}},"default":{"description":"unexpected error","schema":{"$ref":"#/definitions/errorModel"}}}},"post":{"description":"Creates a new pet in the store. Duplicates are allowed","operationId":"addPet","produces":["application/json"],"parameters":[{"name":"pet","in":"body","description":"Pet to add to the store","required":true,"schema":{"$ref":"#/definitions/newPet"}}],"responses":{"200":{"description":"pet response","schema":{"$ref":"#/definitions/pet"}},"default":{"description":"unexpected error","schema":{"$ref":"#/definitions/errorModel"}}}}},"/pets/{id}":{"get":{"description":"Returns a user based on a single ID, if the user does not have access to the pet","operationId":"findPetById","produces":["application/json","application/xml","text/xml","text/html"],"parameters":[{"name":"id","in":"path","description":"ID of pet to fetch","required":true,"type":"integer","format":"int64"}],"responses":{"200":{"description":"pet response","schema":{"$ref":"#/definitions/pet"}},"default":{"description":"unexpected error","schema":{"$ref":"#/definitions/errorModel"}}}},"delete":{"description":"deletes a single pet based on the ID supplied","operationId":"deletePet","parameters":[{"name":"id","in":"path","description":"ID of pet to delete","required":true,"type":"integer","format":"int64"}],"responses":{"204":{"description":"pet deleted"},"default":{"description":"unexpected error","schema":{"$ref":"#/definitions/errorModel"}}}}}},"definitions":{"pet":{"required":["id","name"],"properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"tag":{"type":"string"}}},"newPet":{"allOf":[{"$ref":"#/definitions/pet"},{"required":["name"],"properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}}}]},"errorModel":{"required":["code","message"],"properties":{"code":{"type":"integer","format":"int32"},"message":{"type":"string"}}}}}
File renamed without changes.

loading/json.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package loading
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"path/filepath"
7+
)
8+
9+
// JSONMatcher matches json for a file loader.
10+
func JSONMatcher(path string) bool {
11+
ext := filepath.Ext(path)
12+
return ext == ".json" || ext == ".jsn" || ext == ".jso"
13+
}
14+
15+
// JSONDoc loads a json document from either a file or a remote url.
16+
func JSONDoc(path string, opts ...Option) (json.RawMessage, error) {
17+
data, err := LoadFromFileOrHTTP(path, opts...)
18+
if err != nil {
19+
return nil, errors.Join(err, ErrLoader)
20+
}
21+
return json.RawMessage(data), nil
22+
}

loading/json_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package loading
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestJSONMatcher(t *testing.T) {
13+
t.Run("should recognize a json file", func(t *testing.T) {
14+
assert.True(t, JSONMatcher("local.json"))
15+
assert.True(t, JSONMatcher("local.jso"))
16+
assert.True(t, JSONMatcher("local.jsn"))
17+
assert.False(t, JSONMatcher("local.yml"))
18+
})
19+
}
20+
21+
func TestJSONDoc(t *testing.T) {
22+
t.Run("should retrieve pet store API as JSON", func(t *testing.T) {
23+
serv := httptest.NewServer(http.HandlerFunc(serveJSONPestore))
24+
25+
defer serv.Close()
26+
27+
s, err := JSONDoc(serv.URL)
28+
require.NoError(t, err)
29+
require.NotNil(t, s)
30+
require.JSONEq(t, string(jsonPetStore), string(s))
31+
})
32+
33+
t.Run("should not retrieve any doc", func(t *testing.T) {
34+
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
35+
rw.WriteHeader(http.StatusNotFound)
36+
_, _ = rw.Write([]byte("\n"))
37+
}))
38+
defer ts.Close()
39+
40+
_, err := JSONDoc(ts.URL)
41+
require.Error(t, err)
42+
})
43+
}

loading/loading.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package loading
1616

1717
import (
1818
"context"
19+
"embed"
1920
"fmt"
2021
"io"
2122
"log"
@@ -63,30 +64,32 @@ func LoadFromFileOrHTTP(pth string, opts ...Option) ([]byte, error) {
6364
// - `file://host/folder/file` becomes an UNC path like `\\host\folder\file` (no port specification is supported)
6465
// - `file:///c:/folder/file` becomes `C:\folder\file`
6566
// - `file://c:/folder/file` is tolerated (without leading `/`) and becomes `c:\folder\file`
66-
func LoadStrategy(pth string, local, remote func(string) ([]byte, error), _ ...Option) func(string) ([]byte, error) {
67+
func LoadStrategy(pth string, local, remote func(string) ([]byte, error), opts ...Option) func(string) ([]byte, error) {
6768
if strings.HasPrefix(pth, "http") {
6869
return remote
6970
}
71+
o := optionsWithDefaults(opts)
72+
_, isEmbedFS := o.fs.(embed.FS)
7073

7174
return func(p string) ([]byte, error) {
7275
upth, err := url.PathUnescape(p)
7376
if err != nil {
7477
return nil, err
7578
}
7679

77-
if !strings.HasPrefix(p, `file://`) {
80+
cpth, hasPrefix := strings.CutPrefix(upth, "file://")
81+
if !hasPrefix || isEmbedFS || runtime.GOOS != "windows" {
82+
// crude processing: trim the file:// prefix. This leaves full URIs with a host with a (mostly) unexpected result
7883
// regular file path provided: just normalize slashes
79-
return local(filepath.FromSlash(upth))
80-
}
81-
82-
if runtime.GOOS != "windows" {
83-
// crude processing: this leaves full URIs with a host with a (mostly) unexpected result
84-
upth = strings.TrimPrefix(upth, `file://`)
84+
if isEmbedFS {
85+
// on windows, we need to slash the path if FS is an embed FS.
86+
return local(strings.TrimLeft(filepath.ToSlash(cpth), "./")) // remove invalid leading characters for embed FS
87+
}
8588

86-
return local(filepath.FromSlash(upth))
89+
return local(filepath.FromSlash(cpth))
8790
}
8891

89-
// windows-only pre-processing of file://... URIs
92+
// windows-only pre-processing of file://... URIs, excluding embed.FS
9093

9194
// support for canonical file URIs on windows.
9295
u, err := url.Parse(filepath.ToSlash(upth))

loading/loading_test.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ package loading
1616

1717
import (
1818
"context"
19+
"io/fs"
1920
"net/http"
2021
"net/http/httptest"
2122
"os"
23+
"path/filepath"
2224
"runtime"
2325
"testing"
26+
"testing/fstest"
2427
"time"
2528

2629
"github.com/stretchr/testify/assert"
@@ -35,7 +38,7 @@ func TestLoadFromHTTP(t *testing.T) {
3538
content, err := LoadFromFileOrHTTP(ts.URL)
3639
require.NoError(t, err)
3740

38-
assert.YAMLEq(t, string(YAMLPetStore), string(content))
41+
assert.YAMLEq(t, string(yamlPetStore), string(content))
3942
})
4043

4144
t.Run("should not load from invalid URL", func(t *testing.T) {
@@ -116,9 +119,10 @@ func TestLoadFromHTTP(t *testing.T) {
116119

117120
t.Run("with custom HTTP client mocking a remote", func(t *testing.T) {
118121
cwd, _ := os.Getwd()
122+
fixtureDir := filepath.Join(cwd, "fixtures")
119123
client := &http.Client{
120124
// intercepts calls to the server and serves local files instead
121-
Transport: http.NewFileTransport(http.FS(os.DirFS(cwd))),
125+
Transport: http.NewFileTransport(http.Dir(fixtureDir)),
122126
}
123127

124128
t.Run("should not load unknown path", func(t *testing.T) {
@@ -169,19 +173,43 @@ func TestLoadFromHTTP(t *testing.T) {
169173
})
170174
})
171175

172-
t.Run("should load from local embedded file system", func(t *testing.T) {
176+
t.Run("should load from local embedded file system (single file)", func(t *testing.T) {
177+
// using plain fs.FS
178+
rooted, err := fs.Sub(embeddedFixtures, "fixtures")
179+
require.NoError(t, err)
173180
b, err := LoadFromFileOrHTTP("petstore_fixture.yaml",
181+
WithFS(rooted),
182+
)
183+
require.NoError(t, err)
184+
assert.YAMLEq(t, string(yamlPetStore), string(b))
185+
})
186+
187+
t.Run("should load from memory file system (single file)", func(t *testing.T) {
188+
mapfs := make(fstest.MapFS)
189+
mapfs["file"] = &fstest.MapFile{Data: []byte("content"), Mode: fs.ModePerm}
190+
// using fs.ReadFileFS
191+
b, err := LoadFromFileOrHTTP("file",
192+
WithFS(mapfs),
193+
)
194+
require.NoError(t, err)
195+
assert.Equal(t, string("content"), string(b))
196+
})
197+
198+
t.Run("should load from local embedded file system (path)", func(t *testing.T) {
199+
// using plain fs.ReadFileFS
200+
// NOTE: this doesn't work on windows, because embed.FS uses / even on windows
201+
b, err := LoadFromFileOrHTTP("fixtures/petstore_fixture.yaml",
174202
WithFS(embeddedFixtures),
175203
)
176204
require.NoError(t, err)
177-
assert.YAMLEq(t, string(YAMLPetStore), string(b))
205+
assert.YAMLEq(t, string(yamlPetStore), string(b))
178206
})
179207
}
180208

181209
func TestLoadStrategy(t *testing.T) {
182210
const thisIsNotIt = "not it"
183211
loader := func(_ string) ([]byte, error) {
184-
return YAMLPetStore, nil
212+
return yamlPetStore, nil
185213
}
186214
remLoader := func(_ string) ([]byte, error) {
187215
return []byte(thisIsNotIt), nil
@@ -190,7 +218,7 @@ func TestLoadStrategy(t *testing.T) {
190218
t.Run("should serve local strategy", func(t *testing.T) {
191219
ldr := LoadStrategy("blah", loader, remLoader)
192220
b, _ := ldr("")
193-
assert.YAMLEq(t, string(YAMLPetStore), string(b))
221+
assert.YAMLEq(t, string(yamlPetStore), string(b))
194222
})
195223

196224
t.Run("should serve remote strategy with http", func(t *testing.T) {

loading/options.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,17 +90,33 @@ func WithHTTPClient(client *http.Client) Option {
9090
}
9191
}
9292

93-
// WithFS sets a file system for the local file loader.
93+
// WithFileFS sets a file system for the local file loader.
94+
//
95+
// If the provided file system is a [fs.ReadFileFS], the ReadFile function is used.
96+
// Otherwise, ReadFile is wrapped using [fs.ReadFile].
9497
//
9598
// By default, the file system is the one provided by the os package.
9699
//
97100
// For example, this may be set to consume from an embedded file system, or a rooted FS.
98-
func WithFS(fs fs.ReadFileFS) Option {
101+
func WithFS(filesystem fs.FS) Option {
99102
return func(o *options) {
100-
o.fs = fs
103+
if rfs, ok := filesystem.(fs.ReadFileFS); ok {
104+
o.fs = rfs
105+
106+
return
107+
}
108+
o.fs = readFileFS{FS: filesystem}
101109
}
102110
}
103111

112+
type readFileFS struct {
113+
fs.FS
114+
}
115+
116+
func (r readFileFS) ReadFile(name string) ([]byte, error) {
117+
return fs.ReadFile(r.FS, name)
118+
}
119+
104120
func optionsWithDefaults(opts []Option) options {
105121
const defaultTimeout = 30 * time.Second
106122

loading/serve_test.go

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,39 @@ import (
1818
"embed"
1919
"fmt"
2020
"net/http"
21+
"os"
22+
"path"
23+
"testing"
2124
)
2225

2326
// embedded test files
2427

25-
//go:embed petstore_fixture.yaml
28+
//go:embed fixtures/*
2629
var embeddedFixtures embed.FS
2730

28-
// YAMLPetStore embeds the classical pet store API swagger example.
29-
var YAMLPetStore []byte
31+
// yamlPetStore embeds the classical pet store API swagger example.
32+
var yamlPetStore []byte
33+
var jsonPetStore []byte
3034

31-
func init() {
32-
data, err := embeddedFixtures.ReadFile("petstore_fixture.yaml")
33-
if err != nil {
34-
panic(fmt.Errorf("wrong embedded FS configuration: %w", err))
35-
}
35+
func TestMain(m *testing.M) {
36+
yamlPetStore = mustLoadFixture("petstore_fixture.yaml")
37+
jsonPetStore = mustLoadFixture("petstore_fixture.json")
3638

37-
YAMLPetStore = data
39+
os.Exit(m.Run())
3840
}
3941

4042
// test handlers
4143

4244
// serveYAMLPestore is a http handler to serve the YAMLPestore doc.
4345
func serveYAMLPestore(rw http.ResponseWriter, _ *http.Request) {
4446
rw.WriteHeader(http.StatusOK)
45-
_, _ = rw.Write(YAMLPetStore)
47+
_, _ = rw.Write(yamlPetStore)
48+
}
49+
50+
// serveJSONPestore is a http handler to serve the jsonPestore doc.
51+
func serveJSONPestore(rw http.ResponseWriter, _ *http.Request) {
52+
rw.WriteHeader(http.StatusOK)
53+
_, _ = rw.Write(jsonPetStore)
4654
}
4755

4856
func serveOK(rw http.ResponseWriter, _ *http.Request) {
@@ -86,3 +94,13 @@ func serveRequireHeaderFunc(key, value string) func(http.ResponseWriter, *http.R
8694
rw.WriteHeader(http.StatusForbidden)
8795
}
8896
}
97+
98+
func mustLoadFixture(name string) []byte {
99+
const msg = "wrong embedded FS configuration: %w"
100+
data, err := embeddedFixtures.ReadFile(path.Join("fixtures", name))
101+
if err != nil {
102+
panic(fmt.Errorf(msg, err))
103+
}
104+
105+
return data
106+
}

loading/yaml.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,7 @@ func YAMLDoc(path string, opts ...Option) (json.RawMessage, error) {
3434
return nil, err
3535
}
3636

37-
data, err := yamlutils.YAMLToJSON(yamlDoc)
38-
if err != nil {
39-
return nil, err
40-
}
41-
42-
return data, nil
37+
return yamlutils.YAMLToJSON(yamlDoc)
4338
}
4439

4540
// YAMLData loads a yaml document from either http or a file.

0 commit comments

Comments
 (0)