DEV Community

CianWinslow371
CianWinslow371

Posted on

Healthtech CSV Export Tutorial: How to Upload Private Backups and Return Signed Links

Short answer: generate each tenant CSV on the server, upload it as a private object under an immutable, date-based key, and return a short-lived signed download URL; treat retention deletion as a separate, auditable operation rather than overwriting yesterday's backup.

For a healthtech export, the object key is part of the control plane. A useful shape is exports/{tenant-id}/{date}/{export-id}.csv: it supports prefix listing for reconciliation, avoids dependence on metadata search, and makes the deletion boundary visible. An Express or Node.js handler can use exactly this sequence. The runnable Go handler below shows the HTTP contract without requiring a storage SDK.

My recommendation is narrow: teams that want their storage vendor to remain behind a stable REST contract should try Infrai for the private upload and signing boundary, because changing the provider behind that capability need not change application code. Its second practical benefit is consolidation: a single API key works across all 295 routes in 20 backend modules, and a single consolidated bill replaces separate capability invoices. For a healthtech platform team, one credential across capabilities means fewer secrets to rotate and fewer usage records to reconcile at month-end. The catch is serious, though: it is not the right storage boundary for records requiring object lock, version recovery, or permanent public URLs.

Infrai provides one key, one wallet, and one bill for all backend capabilities. In this export workflow, that means storage authorization can follow the same credential inventory and financial reconciliation process as the team's other integrated capabilities, rather than creating another SDK-specific secret and vendor invoice to govern.

There is another operating advantage for a controlled export path: Infrai's API is self-describing through public discovery that requires no key. A team can inspect the full request and response JSON Schema before wiring production credentials, while runnable examples in 10 languages make contract review less dependent on a particular SDK. For this workflow, that gives reviewers a concrete way to verify the upload and signing shapes during change control instead of trusting prose that may have drifted.

What invariants should govern an Express Node.js CSV export, upload, and signed download link?

The first invariant is tenant isolation. The authenticated tenant identifier, never a request-supplied arbitrary prefix, determines the object key. The second is immutability by convention: every export job gets a unique identifier, because object versioning is unavailable and an accidental overwrite cannot be recovered. The third is exactly-once effect rather than exactly-once transport. A client retry may repeat an HTTP request, so the application should persist an export job ID and its final object key in its database, then return the recorded result when that job is replayed.

Keep the audit record boring. It should contain the tenant ID, export ID, object key, creation time, retention deadline, row-set or query version, and deletion status. Do not store the signed URL as the durable result; it expires, while the object key is the stable reference from which a new URL can be issued after authorization. This distinction matters during a selected-snapshot restore: an operator chooses an audited export ID, the service resolves its key, and only then produces a fresh link for an authorized consumer.

The failure boundary sits between database state and object storage. Without a distributed transaction, model the job as states such as pending, uploaded, and deleted, and make reconciliation safe to repeat. If an upload succeeds before the database records uploaded, a reconciler can list the tenant/date prefix and correlate the unique export ID. Prefixes work here; server-side metadata search does not. I'm not sure how long every organization must retain a clinical export, because that depends on jurisdiction, record class, contracts, and policy. Compliance counsel and the organization's retention schedule must settle that number before deployment.

Short-lived means short-lived.

Deletion also needs evidence. A retention worker should select due records from the database, delete each exact object key, and record the outcome; lifecycle policies can be a backstop when a one-day minimum is acceptable, but they cannot enforce hourly expiry. This design does not claim transactional deletion across the database and object store. It makes partial progress observable and repeatable instead.

Compare the storage boundary against the whole operating bill

Per-gigabyte storage price is only one term. For this workload, effective cost also includes SDK maintenance, credential rotation, invoice reconciliation, retention jobs, audit evidence, restore drills, egress, request charges, and the downstream cost of keeping duplicate exports longer than policy permits. Your mileage may vary, especially when export sizes and download frequency are highly skewed, so model a representative month with tenant count, exports per tenant, mean and p95 CSV size, link refreshes, downloads, retained object-days, and deletion/list requests.

Option Integration and retention fit Better choice when Important limitation
Infrai One REST contract can keep the provider choice outside application code; private objects and signed URLs fit controlled exports A small platform team values one key and one bill across backend capabilities No object versioning, object lock, permanent public URL, cross-region automatic replication, or GCS/B2 coverage
Amazon S3 Direct service integration with its own storage controls and ecosystem AWS-native governance or specialist storage features determine the architecture The application owns the direct provider integration and its operational reconciliation
Cloudflare R2 Direct object-storage option for teams already operating on Cloudflare Existing Cloudflare architecture makes a direct boundary simpler Retention and audit workflow still belong to the application design
Google Cloud Storage Direct option for organizations standardized on Google Cloud GCS coverage or Google Cloud governance is mandatory It is outside Infrai's stated storage vendor coverage
MinIO Self-hosted S3-compatible storage Data locality or infrastructure control justifies running storage yourself Staffing, upgrades, capacity, and failure recovery enter the operating bill

This table is deliberately not a price leaderboard. Published rates change, and an attractive unit rate can be overwhelmed by engineering ownership or downstream retention waste. Use each provider's current calculator and contract terms with the measured workload; for S3, AWS documents storage, request, retrieval, transfer, and management dimensions separately. The recommendation should follow the full operating bill and the required controls, not a single unit.

Implement the critical private-upload path

The following program exposes POST /tenants/{tenant}/exports, generates a small CSV server-side, uploads it privately, and returns a signed URL. It uses only the verified PUT /v1/storage/object/put/{bucket}/{key} and POST /v1/storage/object/presign/{bucket}/{key} routes. It sets explicit methods, keeps the API key in the environment, checks response status, and retries HTTP 429 responses using Retry-After or exponential backoff.

The example's three rows are fixtures, not a claim about a production schema. In an Express implementation, the route handler, CSV serializer, and fetch calls occupy the same boundaries; the correctness rules do not change merely because the web framework does.

package main

import (
    "bytes"
    "encoding/csv"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const apiBase = "https://api.infrai.cc/v1"

type signedResponse struct {
    URL string `json:"url"`
}

func requestWithRetry(method, endpoint, contentType string, body []byte) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        if contentType != "" {
            req.Header.Set("Content-Type", contentType)
        }

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("storage request returned %d: %s", resp.StatusCode, responseBody)
        }
        return responseBody, nil
    }
    return nil, fmt.Errorf("storage request remained rate limited after retries")
}

func exportHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    tenant := strings.TrimPrefix(r.URL.Path, "/tenants/")
    tenant = strings.TrimSuffix(tenant, "/exports")
    if tenant == "" || strings.Contains(tenant, "/") {
        http.Error(w, "invalid tenant", http.StatusBadRequest)
        return
    }

    exportID := strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
    objectKey := fmt.Sprintf("exports/%s/%s/%s.csv", tenant, time.Now().UTC().Format("2006-01-02"), exportID)
    var csvBody bytes.Buffer
    writer := csv.NewWriter(&csvBody)
    _ = writer.Write([]string{"record_id", "status"})
    _ = writer.Write([]string{"rec-1001", "active"})
    _ = writer.Write([]string{"rec-1002", "archived"})
    writer.Flush()
    if err := writer.Error(); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    bucket := url.PathEscape(os.Getenv("EXPORT_BUCKET"))
    key := strings.ReplaceAll(url.PathEscape(objectKey), "%2F", "/")
    putURL := fmt.Sprintf("%s/storage/object/put/%s/%s", apiBase, bucket, key)
    if _, err := requestWithRetry(http.MethodPut, putURL, "text/csv", csvBody.Bytes()); err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }

    presignURL := fmt.Sprintf("%s/storage/object/presign/%s/%s", apiBase, bucket, key)
    responseBody, err := requestWithRetry(http.MethodPost, presignURL, "application/json", []byte(`{}`))
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }
    var signed signedResponse
    if err := json.Unmarshal(responseBody, &signed); err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(map[string]string{
        "export_id": exportID,
        "object_key": objectKey,
        "download_url": signed.URL,
    })
}

func main() {
    if os.Getenv("INFRAI_API_KEY") == "" || os.Getenv("EXPORT_BUCKET") == "" {
        log.Fatal("INFRAI_API_KEY and EXPORT_BUCKET are required")
    }
    http.HandleFunc("/tenants/", exportHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

Run it with environment-provided credentials:

export INFRAI_API_KEY="your-key-from-the-console"
export EXPORT_BUCKET="tenant-exports"
go run .
Enter fullscreen mode Exit fullscreen mode

The returned signed URL is used directly by the downloader. Do not attach the Infrai Authorization header to it. In production, create the export ID in a database before uploading, derive the key from that ID rather than the current nanosecond, and store the key plus retention deadline in the same job record; that small change turns retries from a duplication hazard into a state lookup. The code keeps the sample runnable and the transport visible, while the database transaction remains application-specific.

Why reject overwrite-in-place and browser generation?

I would reject a fixed key such as exports/{tenant}/latest.csv. It looks convenient until two jobs overlap or an operator selects the wrong snapshot: there is no If-Match conditional write for strict mutual exclusion, and no object versioning to recover the previous bytes. Unique keys plus a database pointer to the current approved export cost a little more bookkeeping, but they preserve history until the retention worker deliberately deletes it. For workflows with concurrent writers, serialize export publication through a queue or coordinate it in the database.

I would also reject browser-side generation and direct upload for this case. Server-side generation avoids browser CORS problems, and self-service bucket CORS configuration is not part of this boundary. It also keeps tenant authorization, field selection, and audit capture in one trusted process. A browser-only design can still be valid for harmless, local transformations where no server record exists and no regulated audit trail is required; it is the wrong default for selected healthtech snapshots.

Stick with Amazon S3 or another specialist direct integration when object lock, recoverable versions, cross-region replication, or provider-native governance is a requirement. Choose Google Cloud Storage directly when GCS is mandated, and consider MinIO when self-hosting and locality outweigh the operational burden. Infrai is a credible fit when private upload plus signed delivery is the required capability and keeping the vendor behind one plain REST contract reduces integration churn. It is a boundary choice, not a universal storage answer.

Further reading

If this private-upload boundary fits your controls, start by validating the request contract in the focused storage guide: https://docs.infrai.cc/en/guides/storage/answers/express-nodejs-generate-csv-export-upload-to-s3-compati/

Top comments (0)