diff --git a/internal/goemit/client.go b/internal/goemit/client.go index f03b848..f05e147 100644 --- a/internal/goemit/client.go +++ b/internal/goemit/client.go @@ -9,17 +9,19 @@ import ( ) type goClientData struct { - PackageName string - HasJSONBody bool - HasResumableUpload bool - HasSSE bool - Operations []goOperationData + PackageName string + DefaultServerURL string + HasJSONBody bool + HasMultipartBody bool + HasSSE bool + Operations []goOperationData } type goOperationData struct { ID string Method string Path string + ServerURL string HasParams bool HasQueryParams bool ParamsType string @@ -33,14 +35,31 @@ type goOperationData struct { HasRawBody bool RequiredRawBody bool RawBodyMediaType string - ResumableUpload bool + MultipartBody *goMultipartBodyData ReconnectableSSE bool } +type goMultipartBodyData struct { + MediaType string + Parts []goMultipartPartData +} + +type goMultipartPartData struct { + FieldName string + Type string + JSON bool + Binary bool + ContentType string + ContentTypeField string + AllowedContentType string +} + type goOperationParamData struct { FieldName string WireName string Type string + Const string + HasConst bool Required bool Path bool Query bool @@ -66,10 +85,13 @@ func goClientTemplateData(doc *openapi.Document, sourcePath string) goClientData data := goClientData{ PackageName: packageName(doc, sourcePath), } + if len(doc.Servers) > 0 { + data.DefaultServerURL = strings.TrimRight(doc.Servers[0].URL, "/") + } for _, route := range doc.Operations() { operation := e.operationTemplateData(doc, route) data.HasJSONBody = data.HasJSONBody || operation.HasJSONBody - data.HasResumableUpload = data.HasResumableUpload || operation.ResumableUpload + data.HasMultipartBody = data.HasMultipartBody || operation.MultipartBody != nil for _, response := range operation.Responses { data.HasSSE = data.HasSSE || response.ReconnectableSSE } @@ -81,16 +103,22 @@ func goClientTemplateData(doc *openapi.Document, sourcePath string) goClientData func (e *emitter) operationTemplateData(doc *openapi.Document, route openapi.OperationRoute) goOperationData { op := route.Operation data := goOperationData{ - ID: openapi.ExportName(op.OperationID), - Method: route.Method, - Path: route.Path, - ParamsType: openapi.ExportName(op.OperationID) + "Params", - ResponsesType: openapi.ExportName(op.OperationID) + "Response", - Accept: operationAccept(doc, route.Method, op), - ResumableUpload: op.OperationID == "youtube.videos.insert", + ID: openapi.ExportName(op.OperationID), + Method: route.Method, + Path: route.Path, + ParamsType: openapi.ExportName(op.OperationID) + "Params", + ResponsesType: openapi.ExportName(op.OperationID) + "Response", + Accept: operationAccept(doc, route.Method, op), + } + if len(op.Servers) > 0 { + data.ServerURL = strings.TrimRight(op.Servers[0].URL, "/") } for _, param := range op.Parameters { paramType := e.goType(param.Schema) + constant, hasConstant := "", false + if param.Schema != nil { + constant, hasConstant = param.Schema.Const.(string) + } if !param.Required { paramType = optionalType(paramType) } @@ -98,6 +126,8 @@ func (e *emitter) operationTemplateData(doc *openapi.Document, route openapi.Ope FieldName: openapi.ExportName(param.Name), WireName: param.Name, Type: paramType, + Const: constant, + HasConst: hasConstant, Required: param.Required, Path: param.In == "path", Query: param.In == "query", @@ -107,7 +137,11 @@ func (e *emitter) operationTemplateData(doc *openapi.Document, route openapi.Ope }) data.HasQueryParams = data.HasQueryParams || param.In == "query" } - if schema := op.JSONRequestSchema(); schema != nil { + if multipartBody, ok := sequentialMultipartBody(e, op); ok { + data.MultipartBody = multipartBody + data.HasRequestBody = true + data.HasParams = true + } else if schema := op.JSONRequestSchema(); schema != nil { paramType := e.goType(schema) required := op.RequestBody != nil && op.RequestBody.Required if !required { @@ -130,6 +164,9 @@ func (e *emitter) operationTemplateData(doc *openapi.Document, route openapi.Ope data.HasRequestBody = true } data.HasParams = len(data.Params) > 0 || data.HasRawBody + if data.MultipartBody != nil { + data.HasParams = true + } data.Responses = e.operationResponses(doc, route.Method, op) for _, response := range data.Responses { data.ReconnectableSSE = data.ReconnectableSSE || response.ReconnectableSSE @@ -137,6 +174,58 @@ func (e *emitter) operationTemplateData(doc *openapi.Document, route openapi.Ope return data } +func sequentialMultipartBody(e *emitter, operation *openapi.Operation) (*goMultipartBodyData, bool) { + if operation.RequestBody == nil { + return nil, false + } + mediaTypes := make([]string, 0, len(operation.RequestBody.Content)) + for mediaType := range operation.RequestBody.Content { + mediaTypes = append(mediaTypes, mediaType) + } + sort.Strings(mediaTypes) + for _, mediaType := range mediaTypes { + media := operation.RequestBody.Content[mediaType] + if !strings.HasPrefix(mediaType, "multipart/") || media.Schema == nil || + !media.Schema.Type.Has("array") || len(media.Schema.PrefixItems) != 2 || + len(media.PrefixEncoding) != 2 { + continue + } + minimum, maximum := 0, 0 + if media.Schema.MinItems != nil { + minimum = *media.Schema.MinItems + } + if media.Schema.MaxItems != nil { + maximum = *media.Schema.MaxItems + } + if minimum != 2 || maximum != 2 { + continue + } + body := &goMultipartBodyData{MediaType: mediaType} + for index, schema := range media.Schema.PrefixItems { + fieldName := openapi.ExportName(schema.Title) + if fieldName == "" { + fieldName = fmt.Sprintf("Part%d", index+1) + } + part := goMultipartPartData{ + FieldName: fieldName, + ContentType: strings.TrimSpace(media.PrefixEncoding[index].ContentType), + } + if schema.Type.Has("string") && schema.Format == "binary" { + part.Binary = true + part.Type = "io.Reader" + part.ContentTypeField = fieldName + "ContentType" + part.AllowedContentType = part.ContentType + } else { + part.JSON = true + part.Type = e.goType(schema) + } + body.Parts = append(body.Parts, part) + } + return body, true + } + return nil, false +} + func schemaIsString(doc *openapi.Document, schema *openapi.Schema) bool { if schema == nil { return false diff --git a/internal/goemit/templates/client.go.gotmpl b/internal/goemit/templates/client.go.gotmpl index b7f9a88..7293297 100644 --- a/internal/goemit/templates/client.go.gotmpl +++ b/internal/goemit/templates/client.go.gotmpl @@ -2,14 +2,17 @@ import ( "bufio" -{{if .HasJSONBody}} "bytes" +{{if or .HasJSONBody .HasMultipartBody}} "bytes" {{end}} "context" "encoding/json" "fmt" "io" -{{if .HasSSE}} "math/rand" +{{if .HasMultipartBody}} "mime" + "mime/multipart" +{{end}}{{if .HasSSE}} "math/rand" {{end}} "net/http" - "net/url" +{{if .HasMultipartBody}} "net/textproto" +{{end}} "net/url" "strings" "sync" "time" @@ -24,6 +27,8 @@ const ( defaultSSEMaxRetries = 5 defaultSSEReconnectBaseDelay = 200 * time.Millisecond maxSSERetries = 100 +{{if .DefaultServerURL}} DefaultServerURL = {{printf "%q" .DefaultServerURL}} +{{end}} ) type HTTPClient interface { @@ -44,7 +49,6 @@ type Option func(*ClientOptions) type ClientOptions struct { BaseURL string - UploadBaseURL string httpClient HTTPClient requestEditors []RequestEditorFn responseTimeout time.Duration @@ -54,14 +58,6 @@ type ClientOptions struct { sseReconnectOnStreamEnd bool } -// WithUploadBaseURL configures the origin used by generated resumable upload -// initiation methods. An empty value uses BaseURL. -func WithUploadBaseURL(baseURL string) Option { - return func(options *ClientOptions) { - options.UploadBaseURL = baseURL - } -} - // WithHTTPClient replaces the default http.DefaultClient without mutating a // caller-owned http.Client. func WithHTTPClient(client HTTPClient) Option { @@ -121,7 +117,7 @@ func WithSSEReconnectOnStreamEnd(reconnect bool) Option { type Client struct { baseURL string - uploadBaseURL string + baseURLOverride bool httpClient HTTPClient requestEditors []RequestEditorFn responseTimeout time.Duration @@ -132,7 +128,12 @@ type Client struct { } func NewClient(config ClientOptions, options ...Option) (*Client, error) { - parsed, err := url.Parse(strings.TrimSpace(config.BaseURL)) + configuredBaseURL := strings.TrimSpace(config.BaseURL) + baseURLOverride := configuredBaseURL != "" + if configuredBaseURL == "" { + configuredBaseURL = {{if .DefaultServerURL}}DefaultServerURL{{else}}config.BaseURL{{end}} + } + parsed, err := url.Parse(configuredBaseURL) if err != nil { return nil, fmt.Errorf("parse base URL %q: %w", config.BaseURL, err) } @@ -140,16 +141,6 @@ func NewClient(config ClientOptions, options ...Option) (*Client, error) { return nil, fmt.Errorf("base URL %q must include scheme and host", config.BaseURL) } config.httpClient = http.DefaultClient - if strings.TrimSpace(config.UploadBaseURL) == "" { - config.UploadBaseURL = parsed.String() - } - uploadParsed, err := url.Parse(strings.TrimSpace(config.UploadBaseURL)) - if err != nil { - return nil, fmt.Errorf("parse upload base URL %q: %w", config.UploadBaseURL, err) - } - if uploadParsed.Scheme == "" || uploadParsed.Host == "" { - return nil, fmt.Errorf("upload base URL %q must include scheme and host", config.UploadBaseURL) - } config.responseTimeout = defaultResponseTimeout config.sseIdleTimeout = defaultSSEIdleTimeout config.sseMaxRetries = defaultSSEMaxRetries @@ -183,7 +174,7 @@ func NewClient(config ClientOptions, options ...Option) (*Client, error) { } return &Client{ baseURL: strings.TrimRight(parsed.String(), "/"), - uploadBaseURL: strings.TrimRight(uploadParsed.String(), "/"), + baseURLOverride: baseURLOverride, httpClient: config.httpClient, requestEditors: append([]RequestEditorFn(nil), config.requestEditors...), responseTimeout: config.responseTimeout, @@ -615,12 +606,58 @@ func sseRetryValue(frame []byte) (time.Duration, bool) { return 0, false } +{{end}}{{if .HasMultipartBody}}func multipartContentTypeAllowed(value string, allowedValues string) bool { + mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(value)) + if err != nil { + return false + } + for _, allowedValue := range strings.Split(allowedValues, ",") { + allowedType, _, parseErr := mime.ParseMediaType(strings.TrimSpace(allowedValue)) + if parseErr != nil { + continue + } + allowedMajor, allowedMinor, found := strings.Cut(allowedType, "/") + major, minor, valueFound := strings.Cut(mediaType, "/") + if found && valueFound && major == allowedMajor && (allowedMinor == "*" || minor == allowedMinor) { + return true + } + } + return false +} + {{end}}{{range .Operations}}{{$operation := .}}{{if .HasParams}}type {{.ParamsType}} struct { {{range .Params}} {{.FieldName}} {{.Type}} {{end}}{{if .HasRawBody}} Body io.Reader ContentType string +{{end}}{{with .MultipartBody}}{{range .Parts}} {{.FieldName}} {{.Type}} +{{if .Binary}} {{.ContentTypeField}} string +{{end}}{{end}} {{end}}} +{{with .MultipartBody}}func new{{$operation.ID}}MultipartBody(params {{$operation.ParamsType}}) (io.Reader, string, error) { + var prefix bytes.Buffer + writer := multipart.NewWriter(&prefix) +{{range .Parts}}{{if .Binary}} media := params.{{.FieldName}} +{{end}}{{end}}{{range .Parts}} { + header := make(textproto.MIMEHeader) +{{if .Binary}} header.Set("Content-Type", strings.TrimSpace(params.{{.ContentTypeField}})) +{{else}} header.Set("Content-Type", {{printf "%q" .ContentType}}) +{{end}} part, err := writer.CreatePart(header) + if err != nil { + return nil, "", fmt.Errorf("create {{.FieldName}} part: %w", err) + } +{{if .JSON}} if err := json.NewEncoder(part).Encode(params.{{.FieldName}}); err != nil { + return nil, "", fmt.Errorf("encode {{.FieldName}} part: %w", err) + } +{{else}} _ = part +{{end}} } +{{end}} contentType := mime.FormatMediaType({{printf "%q" .MediaType}}, map[string]string{"boundary": writer.Boundary()}) + suffix := strings.NewReader("\r\n--" + writer.Boundary() + "--\r\n") + return io.MultiReader(bytes.NewReader(prefix.Bytes()), media, suffix), contentType, nil +} + +{{end}} + {{end}}type {{.ResponsesType}} struct { StatusCode int Raw *http.Response @@ -636,16 +673,25 @@ func (c *Client) New{{.ID}}Request(ctx context.Context{{if .HasParams}}, params {{range .Params}}{{if .NonEmpty}} if params.{{.FieldName}} == "" { return nil, fmt.Errorf("build {{$operation.ID}} request: required parameter {{.WireName}} is empty") } +{{end}}{{if .HasConst}} if fmt.Sprint(params.{{.FieldName}}) != {{printf "%q" .Const}} { + return nil, fmt.Errorf("build {{$operation.ID}} request: parameter {{.WireName}} must be {{.Const}}") + } {{end}}{{end}} path := {{printf "%q" .Path}} +{{with .MultipartBody}}{{range .Parts}}{{if .Binary}} if params.{{.FieldName}} == nil { + return nil, fmt.Errorf("build {{$operation.ID}} request: required multipart part {{.FieldName}} is nil") + } + if !multipartContentTypeAllowed(params.{{.ContentTypeField}}, {{printf "%q" .AllowedContentType}}) { + return nil, fmt.Errorf("build {{$operation.ID}} request: multipart part {{.FieldName}} content type %q is not allowed", params.{{.ContentTypeField}}) + } +{{end}}{{end}}{{end}} {{if and .HasRawBody .RequiredRawBody}} if params.Body == nil { return nil, fmt.Errorf("build {{.ID}} request: required request body is nil") } {{end}} {{range .Params}}{{if .Path}} path = strings.ReplaceAll(path, {{printf "%q" (printf "{%s}" .WireName)}}, url.PathEscape(fmt.Sprint({{if not .Required}}*{{end}}params.{{.FieldName}}))) {{end}}{{end}} baseURL := c.baseURL -{{if .ResumableUpload}} if params.UploadType != nil && *params.UploadType == "resumable" { - baseURL = c.uploadBaseURL - path = "/upload/youtube/v3/videos" +{{if .ServerURL}} if !c.baseURLOverride { + baseURL = {{printf "%q" .ServerURL}} } {{end}} endpoint, err := url.Parse(baseURL + path) if err != nil { @@ -663,7 +709,11 @@ func (c *Client) New{{.ID}}Request(ctx context.Context{{if .HasParams}}, params {{else}} query.Set({{printf "%q" .WireName}}, fmt.Sprint(*params.{{.FieldName}})) {{end}} } {{end}}{{end}}{{end}} endpoint.RawQuery = query.Encode() -{{end}}{{if .HasJSONBody}} var requestBody io.Reader +{{end}}{{if .MultipartBody}} requestBody, contentType, err := new{{.ID}}MultipartBody(params) + if err != nil { + return nil, fmt.Errorf("build {{.ID}} multipart body: %w", err) + } +{{else if .HasJSONBody}} var requestBody io.Reader {{range .Params}}{{if .Body}}{{if .Required}} encodedBody, err := json.Marshal(params.{{.FieldName}}) if err != nil { return nil, fmt.Errorf("encode {{$operation.ID}} JSON body: %w", err) @@ -685,6 +735,7 @@ func (c *Client) New{{.ID}}Request(ctx context.Context{{if .HasParams}}, params {{if .HasJSONBody}} if requestBody != nil { req.Header.Set("Content-Type", "application/json") } +{{else if .MultipartBody}} req.Header.Set("Content-Type", contentType) {{else if .HasRawBody}} if requestBody != nil { contentType := strings.TrimSpace(params.ContentType) if contentType == "" { @@ -819,181 +870,6 @@ func (c *Client) {{.ID}}(ctx context.Context{{if .HasParams}}, params {{.ParamsT } } -{{end}}{{if .HasResumableUpload}}// YoutubeVideosInsertResumableStart initiates a resumable upload and returns -// the provider session URL without treating the intentionally empty response as JSON. -func (c *Client) YoutubeVideosInsertResumableStart( - ctx context.Context, - metadata *Video, - mediaContentType string, - mediaContentLength int64, - notifySubscribers bool, -) (string, error) { - if metadata == nil || strings.TrimSpace(mediaContentType) == "" || mediaContentLength <= 0 { - return "", fmt.Errorf("initiate YoutubeVideosInsert resumable upload: metadata, media content type, and positive media content length are required") - } - encoded, err := json.Marshal(metadata) - if err != nil { - return "", fmt.Errorf("encode YoutubeVideosInsert resumable metadata: %w", err) - } - uploadType := "resumable" - request, err := c.NewYoutubeVideosInsertRequest(ctx, YoutubeVideosInsertParams{ - Part: []string{"snippet", "status"}, - NotifySubscribers: ¬ifySubscribers, - UploadType: &uploadType, - Body: bytes.NewReader(encoded), - ContentType: "application/json", - }) - if err != nil { - return "", err - } - request.Header.Set("X-Upload-Content-Type", strings.TrimSpace(mediaContentType)) - request.Header.Set("X-Upload-Content-Length", fmt.Sprint(mediaContentLength)) - response, err := c.do(ctx, request) - if err != nil { - return "", fmt.Errorf("execute YoutubeVideosInsert resumable initiation: %w", err) - } - if response == nil { - return "", fmt.Errorf("execute YoutubeVideosInsert resumable initiation: HTTP client returned nil response") - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated { - rawBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxDiagnosticBodyBytes)) - if readErr != nil { - return "", fmt.Errorf("read unexpected resumable initiation response status %d: %w", response.StatusCode, readErr) - } - return "", &UnexpectedStatusError{Method: request.Method, URL: request.URL.String(), StatusCode: response.StatusCode, Body: strings.TrimSpace(string(rawBody))} - } - sessionURL := strings.TrimSpace(response.Header.Get("Location")) - if sessionURL == "" { - return "", fmt.Errorf("YoutubeVideosInsert resumable initiation returned no Location header") - } - return sessionURL, nil -} - -// YoutubeVideosInsertResumableStatus queries a persisted resumable session. -// A completed session returns its original video response; an incomplete -// session returns the next byte offset to upload. -func (c *Client) YoutubeVideosInsertResumableStatus( - ctx context.Context, - sessionURL string, - totalLength int64, -) (*Video, int64, error) { - if ctx == nil || totalLength <= 0 { - return nil, 0, fmt.Errorf("query YoutubeVideosInsert resumable status: context and positive total length are required") - } - parsed, err := url.Parse(strings.TrimSpace(sessionURL)) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return nil, 0, fmt.Errorf("query YoutubeVideosInsert resumable status: invalid session URL %q", sessionURL) - } - request, err := http.NewRequestWithContext(ctx, http.MethodPut, parsed.String(), http.NoBody) - if err != nil { - return nil, 0, fmt.Errorf("build YoutubeVideosInsert resumable status request: %w", err) - } - request.ContentLength = 0 - request.Header.Set("Content-Range", fmt.Sprintf("bytes */%d", totalLength)) - response, err := c.do(ctx, request) - if err != nil { - return nil, 0, fmt.Errorf("execute YoutubeVideosInsert resumable status request: %w", err) - } - if response == nil { - return nil, 0, fmt.Errorf("execute YoutubeVideosInsert resumable status request: HTTP client returned nil response") - } - defer response.Body.Close() - if response.StatusCode == 308 { - rangeHeader := strings.TrimSpace(response.Header.Get("Range")) - if rangeHeader == "" { - return nil, 0, nil - } - var lastByte int64 - if _, err := fmt.Sscanf(rangeHeader, "bytes=0-%d", &lastByte); err != nil || lastByte < 0 || lastByte >= totalLength { - return nil, 0, fmt.Errorf("query YoutubeVideosInsert resumable status: invalid Range header %q", rangeHeader) - } - return nil, lastByte + 1, nil - } - if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated { - rawBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxDiagnosticBodyBytes)) - if readErr != nil { - return nil, 0, fmt.Errorf("read unexpected resumable status response status %d: %w", response.StatusCode, readErr) - } - return nil, 0, &UnexpectedStatusError{Method: request.Method, URL: request.URL.String(), StatusCode: response.StatusCode, Body: strings.TrimSpace(string(rawBody))} - } - var video Video - if err := json.NewDecoder(io.LimitReader(response.Body, maxDecodedBodyBytes)).Decode(&video); err != nil { - return nil, 0, fmt.Errorf("decode YoutubeVideosInsert resumable status response: %w", err) - } - return &video, totalLength, nil -} - -// YoutubeVideosInsertResumableMedia uploads media to a resumable session URL -// returned by YoutubeVideosInsert. It uses the same editors and HTTP transport, -// preserving authentication and OpenTelemetry propagation. -func (c *Client) YoutubeVideosInsertResumableMedia( - ctx context.Context, - sessionURL string, - body io.Reader, - contentType string, - contentLength int64, -) (*Video, error) { - return c.YoutubeVideosInsertResumableMediaFrom( - ctx, sessionURL, body, contentType, contentLength, contentLength, 0, - ) -} - -// YoutubeVideosInsertResumableMediaFrom resumes an incomplete upload from the -// exact byte offset returned by YoutubeVideosInsertResumableStatus. -func (c *Client) YoutubeVideosInsertResumableMediaFrom( - ctx context.Context, - sessionURL string, - body io.Reader, - contentType string, - contentLength int64, - totalLength int64, - offset int64, -) (*Video, error) { - if ctx == nil { - return nil, fmt.Errorf("upload YoutubeVideosInsert resumable media: context must not be nil") - } - if body == nil || contentLength <= 0 || totalLength <= 0 || offset < 0 || offset + contentLength != totalLength { - return nil, fmt.Errorf("upload YoutubeVideosInsert resumable media: body, valid lengths, and a contiguous offset are required") - } - parsed, err := url.Parse(strings.TrimSpace(sessionURL)) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return nil, fmt.Errorf("upload YoutubeVideosInsert resumable media: invalid session URL %q", sessionURL) - } - request, err := http.NewRequestWithContext(ctx, http.MethodPut, parsed.String(), body) - if err != nil { - return nil, fmt.Errorf("build YoutubeVideosInsert resumable media request: %w", err) - } - request.ContentLength = contentLength - request.Header.Set("Content-Type", strings.TrimSpace(contentType)) - request.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, totalLength-1, totalLength)) - response, err := c.do(ctx, request) - if err != nil { - return nil, fmt.Errorf("execute YoutubeVideosInsert resumable media request: %w", err) - } - if response == nil { - return nil, fmt.Errorf("execute YoutubeVideosInsert resumable media request: HTTP client returned nil response") - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated { - rawBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxDiagnosticBodyBytes)) - if readErr != nil { - return nil, fmt.Errorf("read unexpected resumable media response status %d: %w", response.StatusCode, readErr) - } - return nil, &UnexpectedStatusError{ - Method: request.Method, - URL: request.URL.String(), - StatusCode: response.StatusCode, - Body: strings.TrimSpace(string(rawBody)), - } - } - var video Video - if err := json.NewDecoder(io.LimitReader(response.Body, maxDecodedBodyBytes)).Decode(&video); err != nil { - return nil, fmt.Errorf("decode YoutubeVideosInsert resumable media response: %w", err) - } - return &video, nil -} - {{end}}type SSEIdleTimeoutError struct { Duration time.Duration } diff --git a/internal/openapi/openapi.go b/internal/openapi/openapi.go index 9ea9f99..cbc8b72 100644 --- a/internal/openapi/openapi.go +++ b/internal/openapi/openapi.go @@ -14,10 +14,16 @@ import ( type Document struct { OpenAPI string `yaml:"openapi"` Info Info `yaml:"info"` + Servers []Server `yaml:"servers"` Paths map[string]PathItem `yaml:"paths"` Components Components `yaml:"components"` } +// Server describes one OpenAPI server URL. +type Server struct { + URL string `yaml:"url"` +} + // Info describes OpenAPI document metadata. type Info struct { Title string `yaml:"title"` @@ -53,6 +59,7 @@ type PathItem struct { type Operation struct { OperationID string `yaml:"operationId"` Deprecated bool `yaml:"deprecated"` + Servers []Server `yaml:"servers"` Parameters []Parameter `yaml:"parameters"` RequestBody *RequestBody `yaml:"requestBody"` Responses map[string]Response `yaml:"responses"` @@ -83,13 +90,20 @@ type Response struct { // MediaType describes schema metadata for a response or request media type. type MediaType struct { - Schema *Schema `yaml:"schema"` - ItemSchema *Schema `yaml:"itemSchema"` + Schema *Schema `yaml:"schema"` + ItemSchema *Schema `yaml:"itemSchema"` + PrefixEncoding []Encoding `yaml:"prefixEncoding"` +} + +// Encoding describes the wire encoding for one multipart position. +type Encoding struct { + ContentType string `yaml:"contentType"` } // Schema describes the OpenAPI schema subset supported by the generator. type Schema struct { Ref string `yaml:"$ref"` + Title string `yaml:"title"` Type Type `yaml:"type"` Format string `yaml:"format"` Description string `yaml:"description"` @@ -98,6 +112,9 @@ type Schema struct { Properties map[string]*Schema `yaml:"properties"` Required []string `yaml:"required"` Items *Schema `yaml:"items"` + PrefixItems []*Schema `yaml:"prefixItems"` + MinItems *int `yaml:"minItems"` + MaxItems *int `yaml:"maxItems"` OneOf []*Schema `yaml:"oneOf"` Discriminator *Discriminator `yaml:"discriminator"` ContentMediaType string `yaml:"contentMediaType"` diff --git a/oasmith_test.go b/oasmith_test.go index eeab160..73ae524 100644 --- a/oasmith_test.go +++ b/oasmith_test.go @@ -155,7 +155,7 @@ func TestTypeScriptAPIInterceptors(t *testing.T) { if err := os.WriteFile(testPath, []byte(apiBehaviorTest), 0o644); err != nil { t.Fatalf("write api test: %v", err) } - cmd := exec.Command("nubx", "-y", "vitest@4.1.10", "run", "--globals", "--root", outDir, "api.test.ts") + cmd := exec.Command("nubx", "-y", "vitest@4.0.18", "run", "--globals", "--root", outDir, "api.test.ts") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("typescript api test failed: %v\n%s", err, string(output)) @@ -192,7 +192,7 @@ func TestTypeScriptClientQueries(t *testing.T) { if err := os.WriteFile(testPath, []byte(typescriptQueryBehaviorTest), 0o644); err != nil { t.Fatalf("write TypeScript query test: %v", err) } - cmd := exec.Command("nubx", "-y", "vitest@4.1.10", "run", "--globals", "--root", outDir, "query.test.ts") + cmd := exec.Command("nubx", "-y", "vitest@4.0.18", "run", "--globals", "--root", outDir, "query.test.ts") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("typescript query test failed: %v\n%s", err, string(output)) @@ -244,6 +244,171 @@ func TestGoPackageNameFallsBackToSourceBasename(t *testing.T) { } } +func TestOperationIDCannotInventWireBehavior(t *testing.T) { + t.Parallel() + + doc, err := openapi.Parse([]byte(`openapi: 3.2.0 +info: + title: Operation identity fixture + version: "1" +paths: + /declared-upload: + post: + operationId: youtube.videos.insert + requestBody: + required: true + content: + application/octet-stream: {} + responses: + "204": + description: Accepted +`)) + if err != nil { + t.Fatalf("parse operation identity fixture: %v", err) + } + outDir := t.TempDir() + if err := goemit.EmitClient(doc, goemit.Options{OutDir: outDir, SourcePath: "identity.yaml"}); err != nil { + t.Fatalf("emit operation identity fixture: %v", err) + } + raw, err := os.ReadFile(filepath.Join(outDir, "client.go")) + if err != nil { + t.Fatalf("read generated operation identity client: %v", err) + } + source := string(raw) + if !strings.Contains(source, `path := "/declared-upload"`) { + t.Fatalf("generated client omitted declared path:\n%s", source) + } + for _, forbidden := range []string{ + "/upload/youtube/v3/videos", + "UploadBaseURL", + "Resumable", + "Content-Range", + "Range header", + "StatusCode == 308", + } { + if strings.Contains(source, forbidden) { + t.Fatalf("operationId invented %q behavior:\n%s", forbidden, source) + } + } +} + +func TestTypeScriptOperationIDCannotInventWireBehavior(t *testing.T) { + t.Parallel() + + for _, operationID := range []string{"uploadMedia", "youtubeVideosInsert"} { + doc, err := openapi.Parse([]byte(strings.ReplaceAll(`openapi: 3.2.0 +info: + title: TypeScript operation identity fixture + version: "1" +paths: + /declared-upload: + post: + operationId: OPERATION_ID + responses: + "204": + description: Accepted +`, "OPERATION_ID", operationID))) + if err != nil { + t.Fatalf("parse %s fixture: %v", operationID, err) + } + outDir := t.TempDir() + if err := tsemit.Emit(doc, tsemit.Options{OutDir: outDir}); err != nil { + t.Fatalf("emit %s fixture: %v", operationID, err) + } + raw, err := os.ReadFile(filepath.Join(outDir, "api.ts")) + if err != nil { + t.Fatalf("read %s generated API: %v", operationID, err) + } + source := string(raw) + if !strings.Contains(source, `"/declared-upload"`) { + t.Fatalf("%s omitted declared path:\n%s", operationID, source) + } + for _, forbidden := range []string{"/upload/youtube/v3/videos", "Content-Range", "resumable"} { + if strings.Contains(source, forbidden) { + t.Fatalf("%s invented %q behavior:\n%s", operationID, forbidden, source) + } + } + } +} + +func TestGoClientGeneratesDeclaredSequentialMultipartAndOperationServer(t *testing.T) { + t.Parallel() + + doc, err := openapi.Parse([]byte(`openapi: 3.2.0 +info: + title: Ordered upload fixture + version: "1" +servers: + - url: https://api.example.test +paths: + /declared-upload: + post: + operationId: uploadMedia + servers: + - url: https://upload.example.test + parameters: + - name: uploadType + in: query + required: true + schema: + type: string + const: multipart + requestBody: + required: true + content: + multipart/related: + schema: + type: array + minItems: 2 + maxItems: 2 + prefixItems: + - title: metadata + $ref: "#/components/schemas/Video" + - title: media + type: string + format: binary + prefixEncoding: + - contentType: application/json + - contentType: video/*,application/octet-stream + responses: + "201": + description: Uploaded + content: + application/json: + schema: + $ref: "#/components/schemas/Video" +components: + schemas: + Video: + type: object + required: [id] + properties: + id: + type: string +`)) + if err != nil { + t.Fatalf("parse ordered multipart fixture: %v", err) + } + outDir := t.TempDir() + if err := goemit.EmitClient(doc, goemit.Options{OutDir: outDir, SourcePath: "ordered.yaml"}); err != nil { + t.Fatalf("emit ordered multipart fixture: %v", err) + } + for name, source := range map[string]string{ + "go.mod": "module orderedclient\n\ngo 1.27.0\n", + "client_test.go": sequentialMultipartBehaviorTest, + } { + if err := os.WriteFile(filepath.Join(outDir, name), []byte(source), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + cmd := exec.Command("go", "test", "./...") + cmd.Dir = outDir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("generated ordered multipart client test failed: %v\n%s", err, output) + } +} + func compareDirs(t *testing.T, goldenDir string, outDir string) { t.Helper() @@ -1216,3 +1381,85 @@ func TestCallerContextRemainsAuthoritative(t *testing.T) { } ` + +const sequentialMultipartBehaviorTest = `package ordered + +import ( + "encoding/json" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + "testing" +) + +func TestDeclaredServerAndOrderedParts(t *testing.T) { + request, err := NewClient(ClientOptions{}) + if err != nil { + t.Fatal(err) + } + built, err := request.NewUploadMediaRequest(t.Context(), UploadMediaParams{ + UploadType: "multipart", + Metadata: Video{Id: "metadata"}, + Media: strings.NewReader("complete-media"), + MediaContentType: "video/mp4", + }) + if err != nil { + t.Fatal(err) + } + if built.URL.String() != "https://upload.example.test/declared-upload?uploadType=multipart" { + t.Fatalf("request URL = %q", built.URL) + } + mediaType, parameters, err := mime.ParseMediaType(built.Header.Get("Content-Type")) + if err != nil || mediaType != "multipart/related" || parameters["boundary"] == "" { + t.Fatalf("content type = %q, %v", built.Header.Get("Content-Type"), err) + } + reader := multipart.NewReader(built.Body, parameters["boundary"]) + metadataPart, err := reader.NextPart() + if err != nil || metadataPart.Header.Get("Content-Type") != "application/json" { + t.Fatalf("metadata part = %#v, %v", metadataPart, err) + } + var metadata Video + if err := json.NewDecoder(metadataPart).Decode(&metadata); err != nil || metadata.Id != "metadata" { + t.Fatalf("metadata = %#v, %v", metadata, err) + } + mediaPart, err := reader.NextPart() + if err != nil || mediaPart.Header.Get("Content-Type") != "video/mp4" { + t.Fatalf("media part = %#v, %v", mediaPart, err) + } + mediaBody, err := io.ReadAll(mediaPart) + if err != nil || string(mediaBody) != "complete-media" { + t.Fatalf("media = %q, %v", mediaBody, err) + } + if _, err := reader.NextPart(); err != io.EOF { + t.Fatalf("extra multipart part: %v", err) + } + _, err = request.NewUploadMediaRequest(t.Context(), UploadMediaParams{ + UploadType: "resumable", + Metadata: Video{Id: "metadata"}, + Media: http.NoBody, + MediaContentType: "video/mp4", + }) + if err == nil || !strings.Contains(err.Error(), "uploadType must be multipart") { + t.Fatalf("invalid constant error = %v", err) + } + + override, err := NewClient(ClientOptions{BaseURL: "https://fixture.example.test"}) + if err != nil { + t.Fatal(err) + } + overridden, err := override.NewUploadMediaRequest(t.Context(), UploadMediaParams{ + UploadType: "multipart", + Metadata: Video{Id: "metadata"}, + Media: http.NoBody, + MediaContentType: "application/octet-stream", + }) + if err != nil { + t.Fatal(err) + } + if overridden.URL.Host != "fixture.example.test" { + t.Fatalf("override host = %q", overridden.URL.Host) + } +} +` diff --git a/testdata/golden/public-client-go/client.go b/testdata/golden/public-client-go/client.go index 7848423..21fa053 100644 --- a/testdata/golden/public-client-go/client.go +++ b/testdata/golden/public-client-go/client.go @@ -44,7 +44,6 @@ type Option func(*ClientOptions) type ClientOptions struct { BaseURL string - UploadBaseURL string httpClient HTTPClient requestEditors []RequestEditorFn responseTimeout time.Duration @@ -54,14 +53,6 @@ type ClientOptions struct { sseReconnectOnStreamEnd bool } -// WithUploadBaseURL configures the origin used by generated resumable upload -// initiation methods. An empty value uses BaseURL. -func WithUploadBaseURL(baseURL string) Option { - return func(options *ClientOptions) { - options.UploadBaseURL = baseURL - } -} - // WithHTTPClient replaces the default http.DefaultClient without mutating a // caller-owned http.Client. func WithHTTPClient(client HTTPClient) Option { @@ -121,7 +112,7 @@ func WithSSEReconnectOnStreamEnd(reconnect bool) Option { type Client struct { baseURL string - uploadBaseURL string + baseURLOverride bool httpClient HTTPClient requestEditors []RequestEditorFn responseTimeout time.Duration @@ -132,7 +123,12 @@ type Client struct { } func NewClient(config ClientOptions, options ...Option) (*Client, error) { - parsed, err := url.Parse(strings.TrimSpace(config.BaseURL)) + configuredBaseURL := strings.TrimSpace(config.BaseURL) + baseURLOverride := configuredBaseURL != "" + if configuredBaseURL == "" { + configuredBaseURL = config.BaseURL + } + parsed, err := url.Parse(configuredBaseURL) if err != nil { return nil, fmt.Errorf("parse base URL %q: %w", config.BaseURL, err) } @@ -140,16 +136,6 @@ func NewClient(config ClientOptions, options ...Option) (*Client, error) { return nil, fmt.Errorf("base URL %q must include scheme and host", config.BaseURL) } config.httpClient = http.DefaultClient - if strings.TrimSpace(config.UploadBaseURL) == "" { - config.UploadBaseURL = parsed.String() - } - uploadParsed, err := url.Parse(strings.TrimSpace(config.UploadBaseURL)) - if err != nil { - return nil, fmt.Errorf("parse upload base URL %q: %w", config.UploadBaseURL, err) - } - if uploadParsed.Scheme == "" || uploadParsed.Host == "" { - return nil, fmt.Errorf("upload base URL %q must include scheme and host", config.UploadBaseURL) - } config.responseTimeout = defaultResponseTimeout config.sseIdleTimeout = defaultSSEIdleTimeout config.sseMaxRetries = defaultSSEMaxRetries @@ -183,7 +169,7 @@ func NewClient(config ClientOptions, options ...Option) (*Client, error) { } return &Client{ baseURL: strings.TrimRight(parsed.String(), "/"), - uploadBaseURL: strings.TrimRight(uploadParsed.String(), "/"), + baseURLOverride: baseURLOverride, httpClient: config.httpClient, requestEditors: append([]RequestEditorFn(nil), config.requestEditors...), responseTimeout: config.responseTimeout, @@ -855,6 +841,7 @@ func (c *Client) NewUploadMediaRequest(ctx context.Context, params UploadMediaPa return nil, fmt.Errorf("build UploadMedia request: required parameter uploadType is empty") } path := "/uploads/{owner}" + if params.Body == nil { return nil, fmt.Errorf("build UploadMedia request: required request body is nil") }