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  }