package main import ( "bytes" "context" "encoding/json" "fmt" "image" "io" "net/http" "strconv" "strings" "git.kornbrand.biz/plankalkul/autoixpert-sample/pkg/models" ) type apiError struct { StatusCode int `json:"status_code"` Endpoint string `json:"endpoint"` ErrorCode string `json:"error_code"` ErrorMessage string `json:"error_message"` StackTrace string `json:"stack_trace"` } // could be more elaborate depending on the users needs, for simplicity just the API error messageu // depending on usecase, might be useful to include all fields of this in the slog as parsebable output func (e *apiError) Error() string { if e.ErrorMessage == "" { return fmt.Sprintf("api request: %s failed with status %d", e.Endpoint, e.StatusCode) } return e.ErrorMessage } func decodeAPIError(resp *http.Response) error { body, readErr := io.ReadAll(resp.Body) if readErr != nil { return fmt.Errorf("api request failed with status %d (failed to read error response: %w)", resp.StatusCode, readErr) } var apiErr apiError if err := json.Unmarshal(body, &apiErr); err != nil { return fmt.Errorf("api request failed with status %d (failed to decode error response: %w)", resp.StatusCode, err) } return &apiErr } func createReport(report models.CrashReport) (string, error) { var body = report.ToClaimRequest() data, err := json.Marshal(body) if err != nil { return "", err } req, err := http.NewRequest("POST", cfg.APIBase+"/reports", bytes.NewBuffer(data)) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+cfg.APIKey) req.Header.Set("Content-Type", "application/json") resp, err := DoWithRateLimitReset(context.Background(), req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode >= 400 { return "", decodeAPIError(resp) } var claimResp models.ClaimResponse if err := json.NewDecoder(resp.Body).Decode(&claimResp); err != nil { return "", err } return claimResp.Document.ID, nil } func uploadImages(imageURLs []string, id string, eventID string) error { images, err := collectImages(imageURLs) if err != nil { return err } metaResp, err := requestPhotoMetadata(id, eventID, images) if err != nil { return err } uploadResp, err := requestUploadURLs(id, eventID, metaResp) if err != nil { return err } return uploadPhotoBinaries(images, metaResp, uploadResp) } func collectImages(imageURLs []string) ([]imageWithMeta, error) { images := make([]imageWithMeta, 0, len(imageURLs)) for idx, url := range imageURLs { resp, err := http.Get(url) if err != nil { return nil, err } data, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { return nil, err } mime := http.DetectContentType(data) size := len(data) cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return nil, err } images = append(images, imageWithMeta{ Data: data, Meta: models.ImageMetaRequest{ Title: strconv.Itoa(idx), Description: "", OriginalName: strconv.Itoa(idx), Mimetype: mime, Size: int64(size), Width: cfg.Width, Height: cfg.Height, IncludedInReport: true, }, }) } return images, nil } func requestPhotoMetadata(reportID string, eventID string, images []imageWithMeta) (models.ImageMetaResponse, error) { metas := make([]models.ImageMetaRequest, 0, len(images)) for _, img := range images { metas = append(metas, img.Meta) } body, err := json.Marshal(models.ImageMetaRequestWrapper{Photos: metas}) if err != nil { return models.ImageMetaResponse{}, err } req, err := http.NewRequest("POST", cfg.APIBase+"/reports/"+reportID+"/photos/batch", bytes.NewBuffer(body)) if err != nil { return models.ImageMetaResponse{}, err } req.Header.Set("Authorization", "Bearer "+cfg.APIKey) req.Header.Set("Content-Type", "application/json") resp, err := DoWithRateLimitReset(context.Background(), req) if err != nil { return models.ImageMetaResponse{}, err } defer resp.Body.Close() if resp.StatusCode >= 400 { return models.ImageMetaResponse{}, decodeAPIError(resp) } var metaResp models.ImageMetaResponse if err := json.NewDecoder(resp.Body).Decode(&metaResp); err != nil { return models.ImageMetaResponse{}, err } return metaResp, nil } func requestUploadURLs(reportID string, eventID string, metaResp models.ImageMetaResponse) (models.UploadURLResponse, error) { ids := make([]string, 0, len(metaResp.Photos)) for _, photo := range metaResp.Photos { ids = append(ids, photo.ID) } req, err := http.NewRequest("GET", cfg.APIBase+"/reports/"+reportID+"/photos/batch/upload?photo_ids="+strings.Join(ids, ","), nil) if err != nil { return models.UploadURLResponse{}, err } req.Header.Set("Authorization", "Bearer "+cfg.APIKey) req.Header.Set("Content-Type", "application/json") resp, err := DoWithRateLimitReset(context.Background(), req) if err != nil { return models.UploadURLResponse{}, err } defer resp.Body.Close() if resp.StatusCode >= 400 { return models.UploadURLResponse{}, decodeAPIError(resp) } var uploadResp models.UploadURLResponse if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { return models.UploadURLResponse{}, err } return uploadResp, nil } func uploadPhotoBinaries(images []imageWithMeta, metaResp models.ImageMetaResponse, uploadResp models.UploadURLResponse) error { imageByTitle := make(map[string]imageWithMeta, len(images)) for _, img := range images { imageByTitle[img.Meta.Title] = img } titleByPhotoID := make(map[string]string, len(metaResp.Photos)) for _, metaImg := range metaResp.Photos { titleByPhotoID[metaImg.ID] = metaImg.Title } for _, upload := range uploadResp.UploadURLs { title, ok := titleByPhotoID[upload.PhotoID] if !ok { continue } img, ok := imageByTitle[title] if !ok { continue } putReq, err := http.NewRequest("PUT", upload.UploadURL, bytes.NewReader(img.Data)) if err != nil { return err } putReq.Header.Set("Content-Type", img.Meta.Mimetype) resp, err := Do(context.Background(), putReq) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("failed to upload photo %s: status %d", title, resp.StatusCode) } } return nil }