github.com/speakeasy-api/sdk-gen-config@v1.14.2/workflow/source.go (about) 1 package workflow 2 3 import ( 4 "fmt" 5 "math/rand" 6 "os" 7 "path/filepath" 8 "slices" 9 "strings" 10 11 "github.com/speakeasy-api/sdk-gen-config/workspace" 12 ) 13 14 // Ensure your update schema/workflow.schema.json on changes 15 type Source struct { 16 Inputs []Document `yaml:"inputs"` 17 Overlays []Document `yaml:"overlays,omitempty"` 18 Output *string `yaml:"output,omitempty"` 19 Ruleset *string `yaml:"ruleset,omitempty"` 20 Registry *SourceRegistry `yaml:"registry,omitempty"` 21 } 22 23 type Document struct { 24 Location string `yaml:"location"` 25 Auth *Auth `yaml:",inline"` 26 } 27 28 type SpeakeasyRegistryDocument struct { 29 OrganizationSlug string 30 WorkspaceSlug string 31 NamespaceID string 32 NamespaceName string 33 // Reference could be tag or revision hash sha256:... 34 Reference string 35 } 36 37 type Auth struct { 38 Header string `yaml:"authHeader,omitempty"` 39 Secret string `yaml:"authSecret,omitempty"` 40 } 41 42 type SourceRegistryLocation string 43 type SourceRegistry struct { 44 Location SourceRegistryLocation `yaml:"location"` 45 Tags []string `yaml:"tags,omitempty"` 46 } 47 48 func (s Source) Validate() error { 49 if len(s.Inputs) == 0 { 50 return fmt.Errorf("no inputs found") 51 } 52 53 for i, input := range s.Inputs { 54 if err := input.Validate(); err != nil { 55 return fmt.Errorf("failed to validate input %d: %w", i, err) 56 } 57 } 58 59 for i, overlay := range s.Overlays { 60 if err := overlay.Validate(); err != nil { 61 return fmt.Errorf("failed to validate overlay %d: %w", i, err) 62 } 63 } 64 65 if s.Registry != nil { 66 if err := s.Registry.Validate(); err != nil { 67 return fmt.Errorf("failed to validate registry: %w", err) 68 } 69 } 70 71 _, err := s.GetOutputLocation() 72 if err != nil { 73 return fmt.Errorf("failed to get output location: %w", err) 74 } 75 76 return nil 77 } 78 79 func (s Source) GetOutputLocation() (string, error) { 80 // If we have an output location, we can just return that 81 if s.Output != nil { 82 output := *s.Output 83 84 ext := filepath.Ext(output) 85 if len(s.Inputs) > 1 && !slices.Contains([]string{".yaml", ".yml"}, ext) { 86 return "", fmt.Errorf("when merging multiple inputs, output must be a yaml file") 87 } 88 89 return output, nil 90 } 91 92 ext := ".yaml" 93 94 // If we only have a single input, no overlays and its a local path, we can just use that 95 if len(s.Inputs) == 1 && len(s.Overlays) == 0 { 96 inputFile := s.Inputs[0].Location 97 98 switch getFileStatus(inputFile) { 99 case fileStatusRegistry: 100 return filepath.Join(GetTempDir(), fmt.Sprintf("registry_%s", randStringBytes(10))), nil 101 case fileStatusLocal: 102 return inputFile, nil 103 case fileStatusNotExists: 104 return "", fmt.Errorf("input file %s does not exist", inputFile) 105 case fileStatusRemote: 106 ext = filepath.Ext(inputFile) 107 if ext == "" { 108 ext = ".yaml" 109 } 110 } 111 } 112 113 // Otherwise output will go to a temp file 114 return filepath.Join(GetTempDir(), fmt.Sprintf("output_%s%s", randStringBytes(10), ext)), nil 115 } 116 117 func GetTempDir() string { 118 wd, _ := os.Getwd() 119 120 return workspace.FindWorkspaceTempDir(wd, workspace.FindWorkspaceOptions{}) 121 } 122 123 func (s Source) GetTempMergeLocation() string { 124 return filepath.Join(GetTempDir(), fmt.Sprintf("merge_%s.yaml", randStringBytes(10))) 125 } 126 127 func (s Source) GetTempOverlayLocation() string { 128 return filepath.Join(GetTempDir(), fmt.Sprintf("overlay_%s.yaml", randStringBytes(10))) 129 } 130 131 func (d Document) Validate() error { 132 if d.Location == "" { 133 return fmt.Errorf("location is required") 134 } 135 136 if d.Auth != nil { 137 if getFileStatus(d.Location) != fileStatusRemote { 138 return fmt.Errorf("auth is only supported for remote documents") 139 } 140 141 if err := validateSecret(d.Auth.Secret); err != nil { 142 return fmt.Errorf("failed to validate authSecret: %w", err) 143 } 144 } 145 146 return nil 147 } 148 149 func (d Document) IsRemote() bool { 150 return getFileStatus(d.Location) == fileStatusRemote 151 } 152 153 func (d Document) IsSpeakeasyRegistry() bool { 154 return strings.Contains(d.Location, "registry.speakeasyapi.dev") 155 } 156 157 // Parse the location to extract the namespace ID, namespace name, and reference 158 // The location should be in the format registry.speakeasyapi.dev/org/workspace/name[:tag|@sha256:digest] 159 func ParseSpeakeasyRegistryReference(location string) *SpeakeasyRegistryDocument { 160 // Parse the location to extract the organization, workspace, namespace, and reference 161 // Examples: 162 // registry.speakeasyapi.dev/org/workspace/name (default reference: latest) 163 // registry.speakeasyapi.dev/org/workspace/name@sha256:1234567890abcdef 164 // registry.speakeasyapi.dev/org/workspace/name:tag 165 166 // Assert it starts with the registry prefix 167 if !strings.HasPrefix(location, "registry.speakeasyapi.dev/") { 168 return nil 169 } 170 171 // Extract the organization, workspace, and namespace 172 parts := strings.Split(strings.TrimPrefix(location, "registry.speakeasyapi.dev/"), "/") 173 if len(parts) != 3 { 174 return nil 175 } 176 177 organizationSlug := parts[0] 178 workspaceSlug := parts[1] 179 suffix := parts[2] 180 181 reference := "latest" 182 namespaceName := suffix 183 184 // Check if the suffix contains a reference 185 if strings.Contains(suffix, "@") { 186 // Reference is a digest 187 reference = suffix[strings.Index(suffix, "@")+1:] 188 namespaceName = suffix[:strings.Index(suffix, "@")] 189 } else if strings.Contains(suffix, ":") { 190 // Reference is a tag 191 reference = suffix[strings.Index(suffix, ":")+1:] 192 namespaceName = suffix[:strings.Index(suffix, ":")] 193 } 194 195 return &SpeakeasyRegistryDocument{ 196 OrganizationSlug: organizationSlug, 197 WorkspaceSlug: workspaceSlug, 198 NamespaceID: organizationSlug + "/" + workspaceSlug + "/" + namespaceName, 199 NamespaceName: namespaceName, 200 Reference: reference, 201 } 202 } 203 204 func (d Document) GetTempDownloadPath(tempDir string) string { 205 return filepath.Join(tempDir, fmt.Sprintf("downloaded_%s%s", randStringBytes(10), filepath.Ext(d.Location))) 206 } 207 208 func (d Document) GetTempRegistryDir(tempDir string) string { 209 return filepath.Join(tempDir, fmt.Sprintf("registry_%s", randStringBytes(10))) 210 } 211 212 const namespacePrefix = "registry.speakeasyapi.dev/" 213 214 func (p SourceRegistry) Validate() error { 215 if p.Location == "" { 216 return fmt.Errorf("location is required") 217 } 218 219 location := p.Location.String() 220 // perfectly valid for someone to add http prefixes 221 location = strings.TrimPrefix(location, "https://") 222 location = strings.TrimPrefix(location, "http://") 223 224 if !strings.HasPrefix(location, namespacePrefix) { 225 return fmt.Errorf("registry location must begin with %s", namespacePrefix) 226 } 227 228 if strings.Count(p.Location.Namespace(), "/") != 2 { 229 return fmt.Errorf("registry location should look like %s<org>/<workspace>/<image>", namespacePrefix) 230 } 231 232 return nil 233 } 234 235 func (p *SourceRegistry) SetNamespace(namespace string) error { 236 p.Location = SourceRegistryLocation(namespacePrefix + namespace) 237 return p.Validate() 238 } 239 240 func (p *SourceRegistry) ParseRegistryLocation() (string, string, string, error) { 241 if err := p.Validate(); err != nil { 242 return "", "", "", err 243 } 244 245 location := p.Location.String() 246 // perfectly valid for someone to add http prefixes 247 location = strings.TrimPrefix(location, "https://") 248 location = strings.TrimPrefix(location, "http://") 249 250 subParts := strings.Split(location, namespacePrefix) 251 components := strings.Split(strings.TrimSuffix(subParts[1], "/"), "/") 252 return components[0], components[1], components[2], nil 253 254 } 255 256 // @<org>/<workspace>/<namespace_name> => <org>/<workspace>/<namespace_name> 257 func (n SourceRegistryLocation) Namespace() string { 258 location := string(n) 259 // perfectly valid for someone to add http prefixes 260 location = strings.TrimPrefix(location, "https://") 261 location = strings.TrimPrefix(location, "http://") 262 return strings.TrimPrefix(location, namespacePrefix) 263 } 264 265 // @<org>/<workspace>/<namespace_name> => <namespace_name> 266 func (n SourceRegistryLocation) NamespaceName() string { 267 return n.Namespace()[strings.LastIndex(n.Namespace(), "/")+1:] 268 } 269 270 func (n SourceRegistryLocation) String() string { 271 return string(n) 272 } 273 274 const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 275 276 var randStringBytes = func(n int) string { 277 b := make([]byte, n) 278 for i := range b { 279 b[i] = letterBytes[rand.Intn(len(letterBytes))] 280 } 281 return string(b) 282 }