Files
ai-sample/api.go
rkmpa 20acd6d77a Init with working version
Basic demo for Tally X AutoIxpert integration to showcase setup in
Go due to impossibility of open-sourcing n8n setup.
2026-03-14 22:45:37 +01:00

250 lines
6.1 KiB
Go

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 nil
}