commit 12b840e26e1e7437fea2415dc2794eb9a1c932cd
parent 4550d9d7168992906b8f7ccc7b0a4ab286f44a8f
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date: Wed, 8 Oct 2025 11:03:58 +0200
main.go: add HTTP endpoint and project metadata parsing
Diffstat:
| M | cmd/main.go | | | 173 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
1 file changed, 166 insertions(+), 7 deletions(-)
diff --git a/cmd/main.go b/cmd/main.go
@@ -1,9 +1,15 @@
package main
import (
+ "bytes"
+ "encoding/csv"
"fmt"
+ "io"
"log"
+ "net/http"
"os"
+ "strings"
+ "time"
"github.com/gin-gonic/gin"
"gorm.io/driver/postgres"
@@ -11,9 +17,99 @@ import (
"gorm.io/gorm/schema"
)
-type Cpt struct {
- ID uint `gorm:"primaryKey"`
- LocaId string
+type CptInfo struct {
+ ID uint `gorm:"primaryKey"`
+ SourceId string // PROJ_ID
+ Name string // PROJ_NAME
+ Location string // PROJ_LOC
+ Client string // PROJ_CLNT
+ Contractor string // PROJ_CONT
+}
+
+func ParseAGS(r io.Reader) (*CptInfo, error) {
+
+ norm, err := dos2unix(r)
+ if err != nil {
+ return nil, fmt.Errorf("read: %w", err)
+ }
+
+ cr := csv.NewReader(norm)
+ cr.FieldsPerRecord = -1
+ cr.LazyQuotes = true
+
+ var (
+ inPROJ bool
+ headerIndex map[string]int
+ )
+
+ for {
+ rec, err := cr.Read()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("csv: %w", err)
+ }
+ if len(rec) == 0 {
+ continue
+ }
+
+ for i := range rec {
+ rec[i] = strings.TrimSpace(rec[i])
+ }
+
+ switch strings.ToUpper(rec[0]) {
+ case "GROUP":
+ inPROJ = len(rec) > 1 && strings.EqualFold(rec[1], "PROJ")
+ headerIndex = nil
+ case "HEADING":
+ if !inPROJ {
+ continue
+ }
+ headerIndex = make(map[string]int)
+ for i := 1; i < len(rec); i++ {
+ key := strings.ToUpper(strings.TrimSpace(rec[i]))
+ headerIndex[key] = i - 1 // positions in the "DATA" slice after skipping the first token
+ }
+ case "DATA":
+ if !inPROJ || headerIndex == nil {
+ continue
+ }
+ data := rec[1:] // align with headerIndex positions
+
+ get := func(h string) string {
+ if idx, ok := headerIndex[strings.ToUpper(h)]; ok && idx >= 0 && idx < len(data) {
+ return data[idx]
+ }
+ return ""
+ }
+
+ p := &CptInfo{
+ SourceId: get("PROJ_ID"),
+ Name: get("PROJ_NAME"),
+ Location: get("PROJ_LOC"),
+ Client: get("PROJ_CLNT"),
+ Contractor: get("PROJ_CONT"),
+ }
+ return p, nil
+
+ default:
+ continue
+ }
+ }
+
+ return nil, fmt.Errorf("no data found")
+}
+
+func dos2unix(r io.Reader) (io.Reader, error) {
+ all, err := io.ReadAll(r)
+ if err != nil {
+ return nil, err
+ }
+ all = bytes.ReplaceAll(all, []byte("\r\n"), []byte("\n"))
+ all = bytes.ReplaceAll(all, []byte("\r"), []byte("\n"))
+
+ return bytes.NewReader(all), nil
}
func main() {
@@ -35,16 +131,79 @@ func main() {
log.Fatal(err)
}
- if err := db.AutoMigrate(&Cpt{}); err != nil {
+ if err := db.AutoMigrate(&CptInfo{}); err != nil {
log.Fatal(err)
}
- db.Create(&Cpt{LocaId: "asdf"})
-
r := gin.Default()
- r.POST("/ingest/:ags", func(c *gin.Context) {
+
+ // ~32 MB file cap for multipart
+ r.MaxMultipartMemory = 32 << 20
+
+ r.POST("/ingest/ags", func(c *gin.Context) {
+ reader, cleanup, err := getAGSReader(c.Request)
+ if err != nil {
+ c.String(http.StatusBadRequest, "upload error: %v", err)
+ return
+ }
+ if cleanup != nil {
+ defer cleanup()
+ }
+
+ p, err := ParseAGS(reader)
+ if err != nil {
+ c.String(http.StatusBadRequest, "parse error: %v", err)
+ return
+ }
+
+ err = db.
+ Where("source_id = ?", p.SourceId).
+ Assign(p).
+ FirstOrCreate(p).Error
+ if err != nil {
+ c.String(http.StatusInternalServerError, "db error: %v", err)
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{
+ "id": p.ID,
+ "sourceId": p.SourceId,
+ "name": p.Name,
+ "location": p.Location,
+ "client": p.Client,
+ "contractor": p.Contractor,
+ "savedAt": time.Now().Format(time.RFC3339),
+ })
})
_ = r.Run(":8080")
+}
+func getAGSReader(req *http.Request) (io.Reader, func(), error) {
+ ct := req.Header.Get("Content-Type")
+
+ // Multipart form upload
+ if strings.HasPrefix(ct, "multipart/form-data") {
+ file, _, err := req.FormFile("file")
+ if err != nil {
+ return nil, nil, err
+ }
+ return file, func() { _ = file.Close() }, nil
+ }
+
+ // Raw body upload
+ switch {
+ case strings.HasPrefix(ct, "text/plain"),
+ strings.HasPrefix(ct, "text/csv"),
+ strings.HasPrefix(ct, "application/octet-stream"),
+ ct == "":
+ bodyBytes, err := io.ReadAll(req.Body)
+ if err != nil {
+ return nil, nil, err
+ }
+ _ = req.Body.Close()
+ return strings.NewReader(string(bodyBytes)), nil, nil
+ default:
+ return nil, nil, http.ErrNotSupported
+ }
}