this repo has no description
at master 239 lines 7.7 kB view raw
1// Copyright 2020 CUE Authors 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15package jsonschema 16 17import ( 18 "encoding/base64" 19 "fmt" 20 "net/url" 21 "path" 22 "slices" 23 "strconv" 24 "strings" 25 26 "cuelang.org/go/cue" 27 "cuelang.org/go/cue/ast" 28 "cuelang.org/go/cue/errors" 29 "cuelang.org/go/cue/token" 30 "cuelang.org/go/encoding/json" 31) 32 33func parseRootRef(str string) (cue.Path, error) { 34 u, err := url.Parse(str) 35 if err != nil { 36 return cue.Path{}, fmt.Errorf("invalid JSON reference: %s", err) 37 } 38 if u.Host != "" || u.Path != "" || u.Opaque != "" { 39 return cue.Path{}, fmt.Errorf("external references (%s) not supported in Root", str) 40 } 41 // As a special case for backward compatibility, treat 42 // trim a final slash because the docs specifically 43 // mention that #/ refers to the root document 44 // and the openapi code uses #/components/schemas/. 45 // (technically a trailing slash `/` means there's an empty 46 // final element). 47 u.Fragment = strings.TrimSuffix(u.Fragment, "/") 48 fragmentParts := slices.Collect(json.Pointer(u.Fragment).Tokens()) 49 var selectors []cue.Selector 50 for _, r := range fragmentParts { 51 if i, err := strconv.ParseUint(r, 10, 64); err == nil && strconv.FormatUint(i, 10) == r { 52 // Technically this is incorrect because a numeric element 53 // could also be a string selector and the resulting path 54 // will not allow that. 55 selectors = append(selectors, cue.Index(int64(i))) 56 } else { 57 selectors = append(selectors, cue.Str(r)) 58 } 59 } 60 return cue.MakePath(selectors...), nil 61} 62 63var errRefNotFound = errors.New("JSON Pointer reference not found") 64 65func lookupJSONPointer(v cue.Value, p string) (cue.Value, error) { 66 for part := range json.Pointer(p).Tokens() { 67 // Note: a JSON Pointer doesn't distinguish between indexing 68 // and struct lookup. We have to use the value itself to decide 69 // which operation is appropriate. 70 v, _ = v.Default() 71 switch v.Kind() { 72 case cue.StructKind: 73 v = v.LookupPath(cue.MakePath(cue.Str(part))) 74 case cue.ListKind: 75 idx := int64(0) 76 if len(part) > 1 && part[0] == '0' { 77 // Leading zeros are not allowed 78 return cue.Value{}, errRefNotFound 79 } 80 idx, err := strconv.ParseInt(part, 10, 64) 81 if err != nil { 82 return cue.Value{}, errRefNotFound 83 } 84 v = v.LookupPath(cue.MakePath(cue.Index(idx))) 85 } 86 if !v.Exists() { 87 return cue.Value{}, errRefNotFound 88 } 89 } 90 return v, nil 91} 92 93func sameSchemaRoot(u1, u2 *url.URL) bool { 94 return u1.Host == u2.Host && u1.Path == u2.Path && u1.Opaque == u2.Opaque 95} 96 97// resolveURI parses a URI from s and resolves it in the current context. 98// To resolve it in the current context, it looks for the closest URI from 99// an $id in the parent scopes and the uses the URI resolution to get the 100// new URI. 101// 102// This method is used to resolve any URI, including those from $id and $ref. 103func (s *state) resolveURI(n cue.Value) *url.URL { 104 str, ok := s.strValue(n) 105 if !ok { 106 return nil 107 } 108 109 u, err := url.Parse(str) 110 if err != nil { 111 s.addErr(errors.Newf(n.Pos(), "invalid JSON reference: %v", err)) 112 return nil 113 } 114 115 if u.IsAbs() { 116 // Absolute URI: no need to walk up the tree. 117 if u.Host == DefaultRootIDHost { 118 // No-one should be using the default root ID explicitly. 119 s.errf(n, "invalid use of default root ID host (%v) in URI", DefaultRootIDHost) 120 return nil 121 } 122 return u 123 } 124 125 return s.schemaRoot().id.ResolveReference(u) 126} 127 128// schemaRoot returns the state for the nearest enclosing 129// schema that has its own schema ID. 130func (s *state) schemaRoot() *state { 131 for ; s != nil; s = s.up { 132 if s.id != nil { 133 return s 134 } 135 } 136 // Should never happen, as we ensure there's always an absolute 137 // URI at the root. 138 panic("unreachable") 139} 140 141// DefaultMapRef implements the default logic for mapping a schema location 142// to CUE. 143// It uses a heuristic to map the URL host and path to an import path, 144// and maps the fragment part according to the following: 145// 146// # <empty path> 147// #/definitions/foo #foo or #."foo" 148// #/$defs/foo #foo or #."foo" 149func DefaultMapRef(loc SchemaLoc) (importPath string, path cue.Path, err error) { 150 return defaultMapRef(loc, defaultMap, DefaultMapURL) 151} 152 153// defaultMapRef implements the default MapRef semantics 154// in terms of the default Map and MapURL functions provided 155// in the configuration. 156func defaultMapRef( 157 loc SchemaLoc, 158 mapFn func(pos token.Pos, path []string) ([]ast.Label, error), 159 mapURLFn func(u *url.URL) (importPath string, path cue.Path, err error), 160) (importPath string, path cue.Path, err error) { 161 var fragment string 162 if loc.IsLocal { 163 fragment = mustCUEPathToJSONPointer(loc.Path) 164 } else { 165 // It's external: use mapURLFn. 166 u := ref(*loc.ID) 167 fragment = loc.ID.Fragment 168 u.Fragment = "" 169 var err error 170 importPath, path, err = mapURLFn(u) 171 if err != nil { 172 return "", cue.Path{}, err 173 } 174 } 175 if len(fragment) > 0 && fragment[0] != '/' { 176 return "", cue.Path{}, fmt.Errorf("anchors (%s) not supported", fragment) 177 } 178 parts := slices.Collect(json.Pointer(fragment).Tokens()) 179 labels, err := mapFn(token.Pos{}, parts) 180 if err != nil { 181 return "", cue.Path{}, err 182 } 183 relPath, err := labelsToCUEPath(labels) 184 if err != nil { 185 return "", cue.Path{}, err 186 } 187 return importPath, pathConcat(path, relPath), nil 188} 189 190func defaultMap(p token.Pos, a []string) ([]ast.Label, error) { 191 if len(a) == 0 { 192 return nil, nil 193 } 194 // TODO: technically, references could reference a 195 // non-definition. We disallow this case for the standard 196 // JSON Schema interpretation. We could detect cases that 197 // are not definitions and then resolve those as literal 198 // values. 199 if len(a) != 2 || (a[0] != "definitions" && a[0] != "$defs") { 200 // It's an internal reference (or a nested definition reference). 201 // Fall back to defining it in the internal namespace. 202 // TODO this is needlessly inefficient, as we're putting something 203 // back together that was already joined before defaultMap was 204 // invoked. This does avoid dual implementations though. 205 p := json.PointerFromTokens(slices.Values(a)) 206 return []ast.Label{ast.NewIdent("_#defs"), ast.NewString(string(p))}, nil 207 } 208 name := a[1] 209 if name != rootDefs[1:] && !ast.StringLabelNeedsQuoting(name) { 210 return []ast.Label{ast.NewIdent("#" + name)}, nil 211 } 212 return []ast.Label{ast.NewIdent(rootDefs), ast.NewString(name)}, nil 213} 214 215// DefaultMapURL implements the default schema ID to import 216// path mapping. It trims off any ".json" suffix and uses the 217// package name "schema" if the final component of the path 218// isn't a valid CUE identifier. 219// 220// Deprecated: The [Config.MapURL] API is superceded in 221// factor of [Config.MapRef]. 222func DefaultMapURL(u *url.URL) (string, cue.Path, error) { 223 p := u.Path 224 base := path.Base(p) 225 if !ast.IsValidIdent(base) { 226 base = strings.TrimSuffix(base, ".json") 227 if !ast.IsValidIdent(base) { 228 // Find something more clever to do there. For now just 229 // pick "schema" as the package name. 230 base = "schema" 231 } 232 p += ":" + base 233 } 234 if u.Opaque != "" { 235 // TODO don't use base64 unless we really have to. 236 return base64.RawURLEncoding.EncodeToString([]byte(u.Opaque)), cue.Path{}, nil 237 } 238 return u.Host + p, cue.Path{}, nil 239}