From e528a3c30851c047201463560e2a39979b219e29 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 5 Mar 2026 20:08:25 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=209=20libraries=20+=20documents?= =?UTF-8?q?=20=E2=80=94=20CRUD,=20upload,=20sharing,=20signed=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library management with 17 service methods covering full CRUD for libraries and documents, multipart upload, text content extraction, processing status, signed URLs, reprocessing, and sharing management. --- libraries.go | 203 ++++++++++++++++++++++++++++++++++++ libraries_test.go | 252 +++++++++++++++++++++++++++++++++++++++++++++ library/library.go | 136 ++++++++++++++++++++++++ 3 files changed, 591 insertions(+) create mode 100644 libraries.go create mode 100644 libraries_test.go create mode 100644 library/library.go diff --git a/libraries.go b/libraries.go new file mode 100644 index 0000000..6480b7a --- /dev/null +++ b/libraries.go @@ -0,0 +1,203 @@ +package mistral + +import ( + "context" + "fmt" + "io" + "net/url" + "strconv" + + "somegit.dev/vikingowl/mistral-go-sdk/library" +) + +// CreateLibrary creates a new document library. +func (c *Client) CreateLibrary(ctx context.Context, req *library.CreateRequest) (*library.Library, error) { + var resp library.Library + if err := c.doJSON(ctx, "POST", "/v1/libraries", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// ListLibraries lists all accessible libraries. +func (c *Client) ListLibraries(ctx context.Context) (*library.ListLibraryOut, error) { + var resp library.ListLibraryOut + if err := c.doJSON(ctx, "GET", "/v1/libraries", nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// GetLibrary retrieves a library by ID. +func (c *Client) GetLibrary(ctx context.Context, libraryID string) (*library.Library, error) { + var resp library.Library + if err := c.doJSON(ctx, "GET", fmt.Sprintf("/v1/libraries/%s", libraryID), nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// UpdateLibrary updates a library's name and description. +func (c *Client) UpdateLibrary(ctx context.Context, libraryID string, req *library.UpdateRequest) (*library.Library, error) { + var resp library.Library + if err := c.doJSON(ctx, "PUT", fmt.Sprintf("/v1/libraries/%s", libraryID), req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// DeleteLibrary deletes a library and all its documents. +func (c *Client) DeleteLibrary(ctx context.Context, libraryID string) (*library.Library, error) { + var resp library.Library + if err := c.doJSON(ctx, "DELETE", fmt.Sprintf("/v1/libraries/%s", libraryID), nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// UploadDocument uploads a document to a library. +func (c *Client) UploadDocument(ctx context.Context, libraryID string, filename string, file io.Reader) (*library.Document, error) { + var resp library.Document + if err := c.doMultipart(ctx, fmt.Sprintf("/v1/libraries/%s/documents", libraryID), filename, file, nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// ListDocuments lists documents in a library. +func (c *Client) ListDocuments(ctx context.Context, libraryID string, params *library.ListDocumentParams) (*library.ListDocumentOut, error) { + path := fmt.Sprintf("/v1/libraries/%s/documents", libraryID) + if params != nil { + q := url.Values{} + if params.Search != nil { + q.Set("search", *params.Search) + } + if params.PageSize != nil { + q.Set("page_size", strconv.Itoa(*params.PageSize)) + } + if params.Page != nil { + q.Set("page", strconv.Itoa(*params.Page)) + } + if params.SortBy != nil { + q.Set("sort_by", *params.SortBy) + } + if params.SortOrder != nil { + q.Set("sort_order", *params.SortOrder) + } + if encoded := q.Encode(); encoded != "" { + path += "?" + encoded + } + } + var resp library.ListDocumentOut + if err := c.doJSON(ctx, "GET", path, nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// GetDocument retrieves a document's metadata. +func (c *Client) GetDocument(ctx context.Context, libraryID, documentID string) (*library.Document, error) { + var resp library.Document + if err := c.doJSON(ctx, "GET", fmt.Sprintf("/v1/libraries/%s/documents/%s", libraryID, documentID), nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// UpdateDocument updates a document's metadata. +func (c *Client) UpdateDocument(ctx context.Context, libraryID, documentID string, req *library.DocumentUpdateRequest) (*library.Document, error) { + var resp library.Document + if err := c.doJSON(ctx, "PUT", fmt.Sprintf("/v1/libraries/%s/documents/%s", libraryID, documentID), req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// DeleteDocument deletes a document from a library. +func (c *Client) DeleteDocument(ctx context.Context, libraryID, documentID string) error { + resp, err := c.do(ctx, "DELETE", fmt.Sprintf("/v1/libraries/%s/documents/%s", libraryID, documentID), nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return parseAPIError(resp) + } + return nil +} + +// GetDocumentTextContent retrieves the extracted text of a document. +func (c *Client) GetDocumentTextContent(ctx context.Context, libraryID, documentID string) (*library.DocumentTextContent, error) { + var resp library.DocumentTextContent + if err := c.doJSON(ctx, "GET", fmt.Sprintf("/v1/libraries/%s/documents/%s/text_content", libraryID, documentID), nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// GetDocumentStatus retrieves the processing status of a document. +func (c *Client) GetDocumentStatus(ctx context.Context, libraryID, documentID string) (*library.ProcessingStatusOut, error) { + var resp library.ProcessingStatusOut + if err := c.doJSON(ctx, "GET", fmt.Sprintf("/v1/libraries/%s/documents/%s/status", libraryID, documentID), nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// GetDocumentSignedURL retrieves a signed URL for downloading a document. +func (c *Client) GetDocumentSignedURL(ctx context.Context, libraryID, documentID string) (string, error) { + var resp string + if err := c.doJSON(ctx, "GET", fmt.Sprintf("/v1/libraries/%s/documents/%s/signed-url", libraryID, documentID), nil, &resp); err != nil { + return "", err + } + return resp, nil +} + +// GetDocumentExtractedTextSignedURL retrieves a signed URL for the extracted text. +func (c *Client) GetDocumentExtractedTextSignedURL(ctx context.Context, libraryID, documentID string) (string, error) { + var resp string + if err := c.doJSON(ctx, "GET", fmt.Sprintf("/v1/libraries/%s/documents/%s/extracted-text-signed-url", libraryID, documentID), nil, &resp); err != nil { + return "", err + } + return resp, nil +} + +// ReprocessDocument triggers reprocessing of a document. +func (c *Client) ReprocessDocument(ctx context.Context, libraryID, documentID string) error { + resp, err := c.do(ctx, "POST", fmt.Sprintf("/v1/libraries/%s/documents/%s/reprocess", libraryID, documentID), nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return parseAPIError(resp) + } + return nil +} + +// ListLibrarySharing lists all sharing entries for a library. +func (c *Client) ListLibrarySharing(ctx context.Context, libraryID string) (*library.ListSharingOut, error) { + var resp library.ListSharingOut + if err := c.doJSON(ctx, "GET", fmt.Sprintf("/v1/libraries/%s/share", libraryID), nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// ShareLibrary creates or updates a sharing entry. +func (c *Client) ShareLibrary(ctx context.Context, libraryID string, req *library.SharingRequest) (*library.SharingOut, error) { + var resp library.SharingOut + if err := c.doJSON(ctx, "PUT", fmt.Sprintf("/v1/libraries/%s/share", libraryID), req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// UnshareLibrary deletes a sharing entry. +func (c *Client) UnshareLibrary(ctx context.Context, libraryID string, req *library.SharingDeleteRequest) (*library.SharingOut, error) { + var resp library.SharingOut + if err := c.doJSON(ctx, "DELETE", fmt.Sprintf("/v1/libraries/%s/share", libraryID), req, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/libraries_test.go b/libraries_test.go new file mode 100644 index 0000000..0e0f16a --- /dev/null +++ b/libraries_test.go @@ -0,0 +1,252 @@ +package mistral + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "somegit.dev/vikingowl/mistral-go-sdk/library" +) + +func newLibraryJSON() map[string]any { + return map[string]any{ + "id": "lib-123", "name": "TestLib", + "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", + "owner_id": "user-1", "owner_type": "user", + "total_size": 1024, "nb_documents": 5, "chunk_size": 512, + } +} + +func TestCreateLibrary_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("got method %s", r.Method) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(newLibraryJSON()) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + lib, err := client.CreateLibrary(context.Background(), &library.CreateRequest{ + Name: "TestLib", + }) + if err != nil { + t.Fatal(err) + } + if lib.ID != "lib-123" { + t.Errorf("got id %q", lib.ID) + } +} + +func TestListLibraries_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{newLibraryJSON()}, + }) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + resp, err := client.ListLibraries(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(resp.Data) != 1 { + t.Fatalf("got %d libraries", len(resp.Data)) + } +} + +func TestGetLibrary_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/libraries/lib-123" { + t.Errorf("got path %s", r.URL.Path) + } + json.NewEncoder(w).Encode(newLibraryJSON()) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + lib, err := client.GetLibrary(context.Background(), "lib-123") + if err != nil { + t.Fatal(err) + } + if lib.Name != "TestLib" { + t.Errorf("got name %q", lib.Name) + } +} + +func TestDeleteLibrary_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("got method %s", r.Method) + } + json.NewEncoder(w).Encode(newLibraryJSON()) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + lib, err := client.DeleteLibrary(context.Background(), "lib-123") + if err != nil { + t.Fatal(err) + } + if lib.ID != "lib-123" { + t.Errorf("got id %q", lib.ID) + } +} + +func TestUploadDocument_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("got method %s", r.Method) + } + ct := r.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "multipart/form-data") { + t.Errorf("expected multipart, got %q", ct) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "id": "doc-1", "library_id": "lib-123", "name": "test.pdf", + "hash": "abc", "mime_type": "application/pdf", "extension": ".pdf", "size": 1024, + "created_at": "2024-01-01T00:00:00Z", "process_status": "todo", + "uploaded_by_id": "user-1", "uploaded_by_type": "user", + "processing_status": "todo", "tokens_processing_total": 0, + }) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + doc, err := client.UploadDocument(context.Background(), "lib-123", "test.pdf", strings.NewReader("fake pdf")) + if err != nil { + t.Fatal(err) + } + if doc.ID != "doc-1" { + t.Errorf("got id %q", doc.ID) + } +} + +func TestListDocuments_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/libraries/lib-123/documents" { + t.Errorf("got path %s", r.URL.Path) + } + json.NewEncoder(w).Encode(map[string]any{ + "pagination": map[string]any{ + "total_items": 1, "total_pages": 1, "current_page": 0, "page_size": 100, "has_more": false, + }, + "data": []map[string]any{{ + "id": "doc-1", "library_id": "lib-123", "name": "test.pdf", + "hash": "abc", "mime_type": "application/pdf", "extension": ".pdf", "size": 1024, + "created_at": "2024-01-01T00:00:00Z", "process_status": "done", + "uploaded_by_id": "user-1", "uploaded_by_type": "user", + "processing_status": "done", "tokens_processing_total": 500, + }}, + }) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + resp, err := client.ListDocuments(context.Background(), "lib-123", nil) + if err != nil { + t.Fatal(err) + } + if len(resp.Data) != 1 { + t.Fatalf("got %d documents", len(resp.Data)) + } + if resp.Pagination.TotalItems != 1 { + t.Errorf("got total_items %d", resp.Pagination.TotalItems) + } +} + +func TestGetDocumentTextContent_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"text": "Hello world"}) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + content, err := client.GetDocumentTextContent(context.Background(), "lib-123", "doc-1") + if err != nil { + t.Fatal(err) + } + if content.Text != "Hello world" { + t.Errorf("got text %q", content.Text) + } +} + +func TestGetDocumentStatus_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "document_id": "doc-1", "process_status": "done", "processing_status": "done", + }) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + status, err := client.GetDocumentStatus(context.Background(), "lib-123", "doc-1") + if err != nil { + t.Fatal(err) + } + if status.ProcessStatus != "done" { + t.Errorf("got status %q", status.ProcessStatus) + } +} + +func TestDeleteDocument_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("got method %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + err := client.DeleteDocument(context.Background(), "lib-123", "doc-1") + if err != nil { + t.Fatal(err) + } +} + +func TestReprocessDocument_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("got method %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + err := client.ReprocessDocument(context.Background(), "lib-123", "doc-1") + if err != nil { + t.Fatal(err) + } +} + +func TestListLibrarySharing_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{{ + "library_id": "lib-123", "org_id": "org-1", + "role": "Viewer", "share_with_type": "User", "share_with_uuid": "user-2", + }}, + }) + })) + defer server.Close() + + client := NewClient("key", WithBaseURL(server.URL)) + resp, err := client.ListLibrarySharing(context.Background(), "lib-123") + if err != nil { + t.Fatal(err) + } + if len(resp.Data) != 1 { + t.Fatalf("got %d sharing entries", len(resp.Data)) + } + if resp.Data[0].Role != "Viewer" { + t.Errorf("got role %q", resp.Data[0].Role) + } +} diff --git a/library/library.go b/library/library.go new file mode 100644 index 0000000..8d5143a --- /dev/null +++ b/library/library.go @@ -0,0 +1,136 @@ +package library + +// CreateRequest creates a new library. +type CreateRequest struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + ChunkSize *int `json:"chunk_size,omitempty"` +} + +// UpdateRequest updates a library. +type UpdateRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +// Library represents a document library. +type Library struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + OwnerID *string `json:"owner_id"` + OwnerType string `json:"owner_type"` + TotalSize int `json:"total_size"` + NbDocuments int `json:"nb_documents"` + ChunkSize *int `json:"chunk_size"` + Emoji *string `json:"emoji,omitempty"` + Description *string `json:"description,omitempty"` + GeneratedDescription *string `json:"generated_description,omitempty"` + ExplicitUserMembersCount *int `json:"explicit_user_members_count,omitempty"` + ExplicitWorkspaceMembersCount *int `json:"explicit_workspace_members_count,omitempty"` + OrgSharingRole *string `json:"org_sharing_role,omitempty"` + GeneratedName *string `json:"generated_name,omitempty"` +} + +// ListLibraryOut is the response from listing libraries. +type ListLibraryOut struct { + Data []Library `json:"data"` +} + +// Document represents a document in a library. +type Document struct { + ID string `json:"id"` + LibraryID string `json:"library_id"` + Hash *string `json:"hash"` + MimeType *string `json:"mime_type"` + Extension *string `json:"extension"` + Size *int `json:"size"` + Name string `json:"name"` + Summary *string `json:"summary,omitempty"` + CreatedAt string `json:"created_at"` + LastProcessedAt *string `json:"last_processed_at,omitempty"` + NumberOfPages *int `json:"number_of_pages,omitempty"` + ProcessStatus string `json:"process_status"` + UploadedByID *string `json:"uploaded_by_id"` + UploadedByType string `json:"uploaded_by_type"` + TokensProcessingMainContent *int `json:"tokens_processing_main_content,omitempty"` + TokensProcessingSummary *int `json:"tokens_processing_summary,omitempty"` + URL *string `json:"url,omitempty"` + Attributes map[string]any `json:"attributes,omitempty"` + ProcessingStatus string `json:"processing_status"` + TokensProcessingTotal int `json:"tokens_processing_total"` +} + +// DocumentUpdateRequest updates a document's metadata. +type DocumentUpdateRequest struct { + Name *string `json:"name,omitempty"` + Attributes map[string]any `json:"attributes,omitempty"` +} + +// DocumentTextContent holds the extracted text of a document. +type DocumentTextContent struct { + Text string `json:"text"` +} + +// ListDocumentOut is a paginated list of documents. +type ListDocumentOut struct { + Pagination PaginationInfo `json:"pagination"` + Data []Document `json:"data"` +} + +// PaginationInfo holds pagination metadata. +type PaginationInfo struct { + TotalItems int `json:"total_items"` + TotalPages int `json:"total_pages"` + CurrentPage int `json:"current_page"` + PageSize int `json:"page_size"` + HasMore bool `json:"has_more"` +} + +// ProcessingStatusOut holds document processing status. +type ProcessingStatusOut struct { + DocumentID string `json:"document_id"` + ProcessStatus string `json:"process_status"` + ProcessingStatus string `json:"processing_status"` +} + +// ListDocumentParams holds query parameters for listing documents. +type ListDocumentParams struct { + Search *string + PageSize *int + Page *int + FiltersAttributes *string + SortBy *string + SortOrder *string +} + +// SharingRequest creates or updates library sharing. +type SharingRequest struct { + OrgID *string `json:"org_id,omitempty"` + Level string `json:"level"` + ShareWithUUID string `json:"share_with_uuid"` + ShareWithType string `json:"share_with_type"` +} + +// SharingDeleteRequest removes library sharing. +type SharingDeleteRequest struct { + OrgID *string `json:"org_id,omitempty"` + ShareWithUUID string `json:"share_with_uuid"` + ShareWithType string `json:"share_with_type"` +} + +// SharingOut represents a sharing entry. +type SharingOut struct { + LibraryID string `json:"library_id"` + UserID *string `json:"user_id,omitempty"` + OrgID string `json:"org_id"` + Role string `json:"role"` + ShareWithType string `json:"share_with_type"` + ShareWithUUID *string `json:"share_with_uuid"` +} + +// ListSharingOut is the response from listing sharing entries. +type ListSharingOut struct { + Data []SharingOut `json:"data"` +}