Skip to content

Commit fc5e247

Browse files
Merge pull request #383 from natalie-o-perret/feat/expose-config-auth-packages
feat: expose embedder helpers for legacy config and auth header
2 parents 6e904ce + c0f88bb commit fc5e247

12 files changed

Lines changed: 898 additions & 183 deletions

File tree

auth/tokencache.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ func NewTokenCache(path string) *TokenCache {
4848
return &TokenCache{path: path}
4949
}
5050

51+
// Path returns the file path the cache reads from and writes to.
52+
func (c *TokenCache) Path() string {
53+
return c.path
54+
}
55+
5156
// Get returns the cached token for key, or (nil, nil) if not found.
5257
func (c *TokenCache) Get(key string) (*CachedToken, error) {
5358
c.mu.Lock()

auth/tokencache_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ func TestTokenCache_MissingFile(t *testing.T) {
2525
}
2626
}
2727

28+
func TestTokenCache_Path(t *testing.T) {
29+
path := filepath.Join(t.TempDir(), "tokens.cbor")
30+
tc := NewTokenCache(path)
31+
if got := tc.Path(); got != path {
32+
t.Errorf("Path() = %q, want %q", got, path)
33+
}
34+
}
35+
2836
func TestTokenCache_RoundTrip(t *testing.T) {
2937
path := filepath.Join(t.TempDir(), "tokens.cbor")
3038
tc := NewTokenCache(path)

config/legacy.go

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"sort"
10+
"strings"
11+
12+
"github.com/tidwall/jsonc"
13+
)
14+
15+
// legacyAPIConfigAuth is the v1 `profiles.<name>.auth` shape.
16+
type legacyAPIConfigAuth struct {
17+
Name string `json:"name"`
18+
Params map[string]string `json:"params,omitempty"`
19+
}
20+
21+
// legacyPKCS11Config is the v1 `tls.pkcs11` shape.
22+
type legacyPKCS11Config struct {
23+
Path string `json:"path,omitempty"`
24+
Label string `json:"label,omitempty"`
25+
}
26+
27+
// legacyTLSConfig is the v1 `tls` shape.
28+
type legacyTLSConfig struct {
29+
PKCS11 *legacyPKCS11Config `json:"pkcs11,omitempty"`
30+
Cert string `json:"cert,omitempty"`
31+
Key string `json:"key,omitempty"`
32+
CACert string `json:"ca_cert,omitempty"`
33+
}
34+
35+
// legacyAPIProfile is the v1 `profiles.<name>` shape.
36+
type legacyAPIProfile struct {
37+
Base string `json:"base,omitempty"`
38+
Headers map[string]string `json:"headers,omitempty"`
39+
Query map[string]string `json:"query,omitempty"`
40+
Auth *legacyAPIConfigAuth `json:"auth,omitempty"`
41+
}
42+
43+
// legacyAPIConfig is the v1 per-API shape from apis.json.
44+
type legacyAPIConfig struct {
45+
Base string `json:"base"`
46+
OperationBase string `json:"operation_base,omitempty"`
47+
SpecFiles []string `json:"spec_files,omitempty"`
48+
Profiles map[string]*legacyAPIProfile `json:"profiles,omitempty"`
49+
TLS *legacyTLSConfig `json:"tls,omitempty"`
50+
}
51+
52+
// ConvertLegacyAPI converts a single v1 (apis.json) APIConfig entry to the
53+
// current v2 shape. The name argument is used for warning messages only; it
54+
// does not affect the converted data. Pass the raw JSON value of one entry
55+
// from an apis.json file, e.g. the bytes associated with one key in the
56+
// top-level map.
57+
//
58+
// Returned warnings describe migration-time decisions such as dropped
59+
// invalid operation_base values or migrated PKCS#11 TLS config that now
60+
// requires the restish-pkcs11 plugin to be installed.
61+
//
62+
// The function does not read or write any files. Callers that want a
63+
// one-call read of an apis.json folder can use ReadLegacyAPIs.
64+
func ConvertLegacyAPI(name string, raw json.RawMessage) (*APIConfig, []string, error) {
65+
stripped := jsonc.ToJSON(raw)
66+
var legacy legacyAPIConfig
67+
if err := json.Unmarshal(stripped, &legacy); err != nil {
68+
return nil, nil, &ParseError{Path: name, Err: err}
69+
}
70+
api, warnings := convertLegacyAPIConfig(name, &legacy)
71+
return api, warnings, nil
72+
}
73+
74+
// ReadLegacyAPIs reads every API from a v1 apis.json in folder, returning them
75+
// in v2 shape. The "$schema" key is skipped. Returns an empty map and no
76+
// error when apis.json does not exist.
77+
//
78+
// This is the read path for embedders that want to read a legacy config
79+
// without going through the restish CLI's automatic migration. ReadLegacyAPIs
80+
// never modifies the user's apis.json or restish.json; the restish CLI's
81+
// own TryMigrate step is responsible for that.
82+
func ReadLegacyAPIs(folder string) (map[string]*APIConfig, error) {
83+
path := filepath.Join(folder, "apis.json")
84+
data, err := os.ReadFile(path)
85+
if err != nil {
86+
if errors.Is(err, os.ErrNotExist) {
87+
return map[string]*APIConfig{}, nil
88+
}
89+
return nil, fmt.Errorf("config: cannot read %s: %w", path, err)
90+
}
91+
stripped := jsonc.ToJSON(data)
92+
var raw map[string]json.RawMessage
93+
if err := json.Unmarshal(stripped, &raw); err != nil {
94+
return nil, &ParseError{Path: path, Err: err}
95+
}
96+
out := make(map[string]*APIConfig, len(raw))
97+
for name, value := range raw {
98+
if strings.HasPrefix(name, "$") {
99+
continue
100+
}
101+
api, _, err := ConvertLegacyAPI(name, value)
102+
if err != nil {
103+
return nil, fmt.Errorf("config: parsing %s entry %q: %w", path, name, err)
104+
}
105+
out[name] = api
106+
}
107+
return out, nil
108+
}
109+
110+
// ReadLegacyAPI reads a single named API from a v1 apis.json in folder,
111+
// returning it in v2 shape. Returns an error when the API is not present.
112+
func ReadLegacyAPI(folder, name string) (*APIConfig, error) {
113+
all, err := ReadLegacyAPIs(folder)
114+
if err != nil {
115+
return nil, err
116+
}
117+
api, ok := all[name]
118+
if !ok {
119+
return nil, fmt.Errorf("config: api %q not found in %s", name, filepath.Join(folder, "apis.json"))
120+
}
121+
return api, nil
122+
}
123+
124+
// ReadAPIs returns every API configured in folder, in v2 shape, regardless of
125+
// whether the user's config is in v2 (restish.json) or v1 (apis.json) format.
126+
// This is the format-agnostic read path for embedders.
127+
//
128+
// restish.json is preferred when present. apis.json is read only when
129+
// restish.json does not exist, so a user who has already migrated is served
130+
// from the v2 file with no v1 conversion. ReadAPIs never modifies either
131+
// file; the restish CLI's TryMigrate step is responsible for that.
132+
func ReadAPIs(folder string) (map[string]*APIConfig, error) {
133+
restishPath := filepath.Join(folder, "restish.json")
134+
if _, err := os.Stat(restishPath); err == nil {
135+
cfg, err := Load(restishPath)
136+
if err != nil {
137+
return nil, err
138+
}
139+
if cfg.APIs == nil {
140+
return map[string]*APIConfig{}, nil
141+
}
142+
return cfg.APIs, nil
143+
} else if !errors.Is(err, os.ErrNotExist) {
144+
return nil, fmt.Errorf("config: cannot stat %s: %w", restishPath, err)
145+
}
146+
return ReadLegacyAPIs(folder)
147+
}
148+
149+
// convertLegacyAPIConfig converts one parsed v1 entry into the v2 shape and
150+
// emits migration-time warnings. Used by both the public ConvertLegacyAPI
151+
// helper and the internal automatic migrator.
152+
func convertLegacyAPIConfig(name string, legacy *legacyAPIConfig) (*APIConfig, []string) {
153+
if legacy == nil {
154+
return &APIConfig{}, nil
155+
}
156+
operationBase, warning := convertLegacyOperationBase(name, legacy.OperationBase)
157+
158+
api := &APIConfig{
159+
BaseURL: legacy.Base,
160+
OperationBase: operationBase,
161+
SpecFiles: append([]string(nil), legacy.SpecFiles...),
162+
}
163+
if len(legacy.Profiles) > 0 {
164+
api.Profiles = make(map[string]*ProfileConfig, len(legacy.Profiles))
165+
for name, profile := range legacy.Profiles {
166+
api.Profiles[name] = convertLegacyProfile(profile)
167+
}
168+
}
169+
170+
var migratedPKCS11 bool
171+
if legacy.TLS != nil && legacy.TLS.PKCS11 != nil {
172+
if api.Profiles == nil {
173+
api.Profiles = map[string]*ProfileConfig{}
174+
}
175+
prof := api.Profiles["default"]
176+
if prof == nil {
177+
prof = &ProfileConfig{}
178+
api.Profiles["default"] = prof
179+
}
180+
if prof.TLSSigner == "" {
181+
prof.TLSSigner = "pkcs11"
182+
}
183+
if prof.TLSSignerParams == nil {
184+
prof.TLSSignerParams = map[string]string{}
185+
}
186+
if legacy.TLS.PKCS11.Path != "" && prof.TLSSignerParams["path"] == "" {
187+
prof.TLSSignerParams["path"] = legacy.TLS.PKCS11.Path
188+
}
189+
if legacy.TLS.PKCS11.Label != "" && prof.TLSSignerParams["label"] == "" {
190+
prof.TLSSignerParams["label"] = legacy.TLS.PKCS11.Label
191+
}
192+
migratedPKCS11 = true
193+
}
194+
195+
if legacy.TLS != nil && (legacy.TLS.Cert != "" || legacy.TLS.Key != "") {
196+
if api.Profiles == nil {
197+
api.Profiles = map[string]*ProfileConfig{}
198+
}
199+
prof := api.Profiles["default"]
200+
if prof == nil {
201+
prof = &ProfileConfig{}
202+
api.Profiles["default"] = prof
203+
}
204+
if prof.ClientCertPath == "" && legacy.TLS.Cert != "" {
205+
prof.ClientCertPath = legacy.TLS.Cert
206+
}
207+
if prof.ClientKeyPath == "" && legacy.TLS.Key != "" {
208+
prof.ClientKeyPath = legacy.TLS.Key
209+
}
210+
}
211+
212+
if legacy.TLS != nil && legacy.TLS.CACert != "" {
213+
if api.Profiles == nil {
214+
api.Profiles = map[string]*ProfileConfig{}
215+
}
216+
prof := api.Profiles["default"]
217+
if prof == nil {
218+
prof = &ProfileConfig{}
219+
api.Profiles["default"] = prof
220+
}
221+
if prof.CACertPath == "" {
222+
prof.CACertPath = legacy.TLS.CACert
223+
}
224+
}
225+
226+
var warnings []string
227+
if warning != "" {
228+
warnings = append(warnings, warning)
229+
}
230+
if migratedPKCS11 {
231+
warnings = append(warnings, fmt.Sprintf("api %q: migrated PKCS#11 TLS config; install the restish-pkcs11 plugin to continue using it (see https://github.com/rest-sh/restish)", name))
232+
}
233+
return api, warnings
234+
}
235+
236+
func convertLegacyOperationBase(apiName, operationBase string) (string, string) {
237+
if operationBase == "" {
238+
return "", ""
239+
}
240+
if err := ValidateOperationBase(operationBase); err == nil {
241+
return operationBase, ""
242+
}
243+
return "", fmt.Sprintf("api %q: dropped invalid legacy operation_base %q; v2 operation_base must be an absolute path", apiName, operationBase)
244+
}
245+
246+
func convertLegacyProfile(legacy *legacyAPIProfile) *ProfileConfig {
247+
if legacy == nil {
248+
return &ProfileConfig{}
249+
}
250+
251+
prof := &ProfileConfig{
252+
BaseURL: legacy.Base,
253+
Headers: sortedHeaderList(legacy.Headers),
254+
Query: sortedQueryList(legacy.Query),
255+
}
256+
if legacy.Auth != nil {
257+
prof.Auth = &AuthConfig{
258+
Type: legacy.Auth.Name,
259+
Params: cloneStringMap(legacy.Auth.Params),
260+
}
261+
}
262+
return prof
263+
}
264+
265+
func sortedHeaderList(values map[string]string) []string {
266+
return sortedPairs(values, ": ")
267+
}
268+
269+
func sortedQueryList(values map[string]string) []string {
270+
return sortedPairs(values, "=")
271+
}
272+
273+
func sortedPairs(values map[string]string, sep string) []string {
274+
if len(values) == 0 {
275+
return nil
276+
}
277+
keys := make([]string, 0, len(values))
278+
for key := range values {
279+
keys = append(keys, key)
280+
}
281+
sort.Strings(keys)
282+
283+
items := make([]string, 0, len(keys))
284+
for _, key := range keys {
285+
items = append(items, key+sep+values[key])
286+
}
287+
return items
288+
}
289+
290+
func cloneStringMap(values map[string]string) map[string]string {
291+
if len(values) == 0 {
292+
return nil
293+
}
294+
out := make(map[string]string, len(values))
295+
for k, v := range values {
296+
out[k] = v
297+
}
298+
return out
299+
}

0 commit comments

Comments
 (0)