This is an automated email from the ASF dual-hosted git repository. DImuthuUpe pushed a commit to branch base-api-imp in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit e7e2c25d7cfb6b74dc45f10880eefe467b05d3fa Author: DImuthuUpe <[email protected]> AuthorDate: Sat May 16 20:10:11 2026 -0400 Framework implement to REST API and DB integration --- build_output.txt | 0 cmd/server/main.go | 131 +++++++++ docs/API-Docs.md | 319 +++++++++++++++++++++ go.mod | 12 + go.sum | 73 +++++ internal/db/db.go | 46 +++ internal/db/embed.go | 23 ++ internal/db/migrate.go | 55 ++++ .../db/migrations/000001_initial_schema.down.sql | 20 ++ .../db/migrations/000001_initial_schema.up.sql | 60 ++++ internal/db/tx.go | 46 +++ internal/server/server.go | 194 +++++++++++++ internal/store/organization_store.go | 82 ++++++ internal/store/project_store.go | 97 +++++++ internal/store/store.go | 71 +++++ internal/store/user_store.go | 97 +++++++ pkg/README.md | 120 ++++++++ pkg/models/project.go | 30 +- pkg/service/errors.go | 34 +++ pkg/service/organization.go | 106 +++++++ pkg/service/project.go | 127 ++++++++ pkg/service/service.go | 73 +++++ pkg/service/user.go | 122 ++++++++ 23 files changed, 1923 insertions(+), 15 deletions(-) diff --git a/build_output.txt b/build_output.txt new file mode 100644 index 000000000..e69de29bb diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 000000000..08f9dd748 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,131 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Command server starts the Custos HTTP API. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/apache/airavata-custos/internal/db" + "github.com/apache/airavata-custos/internal/server" + "github.com/apache/airavata-custos/pkg/service" +) + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + + if err := run(); err != nil { + slog.Error("server exited with error", "error", err) + os.Exit(1) + } +} + +func run() error { + dsn := os.Getenv("DATABASE_DSN") + if dsn == "" { + return errors.New("DATABASE_DSN environment variable is required " + + "(e.g. user:pass@tcp(localhost:3306)/custos?parseTime=true&charset=utf8mb4)") + } + + addr := envDefault("HTTP_ADDR", ":8080") + maxOpen := envInt("DB_MAX_OPEN_CONNS", 25) + maxIdle := envInt("DB_MAX_IDLE_CONNS", 5) + + database, err := db.Open(db.Config{ + DSN: dsn, + MaxOpenConns: maxOpen, + MaxIdleConns: maxIdle, + }) + if err != nil { + return err + } + defer database.Close() + + if err := db.MigrateEmbedded(database); err != nil { + return err + } + + svc := service.New(database) + handler := server.LoggingMiddleware(server.New(svc)) + + httpServer := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + serverErr := make(chan error, 1) + go func() { + slog.Info("http server listening", "addr", addr) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + serverErr <- err + } + close(serverErr) + }() + + select { + case <-ctx.Done(): + slog.Info("shutdown signal received") + case err := <-serverErr: + if err != nil { + return err + } + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + return err + } + slog.Info("server stopped cleanly") + return nil +} + +func envDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + slog.Warn("invalid integer env var, using default", "key", key, "value", v, "default", fallback) + return fallback + } + return n +} diff --git a/docs/API-Docs.md b/docs/API-Docs.md new file mode 100644 index 000000000..dacb5364d --- /dev/null +++ b/docs/API-Docs.md @@ -0,0 +1,319 @@ +# Custos API Documentation + +HTTP/JSON API exposed by `cmd/server`. All endpoints accept and return +`application/json` and use UTF-8. + +- **Base URL:** `http://<host>:<port>` (default port `8080`, configurable via `HTTP_ADDR`) +- **Auth:** none currently enforced (deploy behind a trusted ingress / auth proxy) +- **Content-Type:** `application/json` is required on every request that has a body +- **Unknown fields:** request bodies with unknown JSON fields are rejected with `400` + +--- + +## Conventions + +### Identifiers + +- `id` fields are server-generated UUIDs when omitted from a create request. +- `originated_id` is an optional external identifier (e.g. ACCESS Record ID) — when supplied, it must be unique within its entity type. + +### Timestamps + +All timestamps are RFC 3339 / ISO 8601 with timezone, e.g. `2026-05-16T12:34:56.789Z`. The server emits UTC. + +### Error format + +Errors are returned with an appropriate HTTP status code and a JSON body: + +```json +{ "error": "human-readable message" } +``` + +| Status | Meaning | Triggered by | +|--------|---------|--------------| +| `400 Bad Request` | Malformed JSON, unknown field, missing required field, or unknown foreign-key reference | request body validation, `service.ErrInvalidInput` | +| `404 Not Found` | Requested record does not exist | `service.ErrNotFound` | +| `409 Conflict` | Duplicate `email` or duplicate `originated_id` | `service.ErrAlreadyExists` | +| `500 Internal Server Error` | Unexpected server / database failure (driver message is logged, never returned) | any other error | + +--- + +## Health + +### `GET /healthz` + +Liveness probe. Always returns `200` when the process is accepting connections. + +**Response 200** + +```json +{ "status": "ok" } +``` + +--- + +## Organizations + +### `POST /organizations` + +Create a new organization. + +**Required fields:** `name` +**Optional fields:** `id` (auto-generated if omitted), `originated_id` + +**Request** + +```json +{ + "name": "University of Example", + "originated_id": "ACCESS-ORG-001" +} +``` + +**Response 201** + +```json +{ + "id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", + "originated_id": "ACCESS-ORG-001", + "name": "University of Example" +} +``` + +**Errors** + +- `400` — `name` is required. +- `409` — an organization with the supplied `originated_id` already exists. + +#### Example + +```bash +curl -s -X POST http://localhost:8080/organizations \ + -H 'Content-Type: application/json' \ + -d '{"name":"University of Example","originated_id":"ACCESS-ORG-001"}' +``` + +--- + +### `GET /organizations/{id}` + +Retrieve an organization by its ID. + +**Response 200** + +```json +{ + "id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", + "originated_id": "ACCESS-ORG-001", + "name": "University of Example" +} +``` + +**Errors** + +- `404` — no organization matches the supplied ID. + +--- + +## Users + +### `POST /users` + +Create a new user. + +**Required fields:** `organization_id`, `email` +**Optional fields:** `id`, `first_name`, `last_name`, `middle_name` + +The referenced `organization_id` must already exist; emails must be unique. + +**Request** + +```json +{ + "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", + "first_name": "Ada", + "last_name": "Lovelace", + "email": "[email protected]" +} +``` + +**Response 201** + +```json +{ + "id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02", + "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", + "first_name": "Ada", + "last_name": "Lovelace", + "email": "[email protected]" +} +``` + +**Errors** + +- `400` — `email`, `organization_id` missing, or `organization_id` does not exist. +- `409` — a user with this `email` already exists. + +#### Example + +```bash +curl -s -X POST http://localhost:8080/users \ + -H 'Content-Type: application/json' \ + -d '{ + "organization_id":"8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", + "first_name":"Ada", + "last_name":"Lovelace", + "email":"[email protected]" + }' +``` + +--- + +### `GET /users/{id}` + +Retrieve a user by its ID. + +**Response 200** + +```json +{ + "id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02", + "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", + "first_name": "Ada", + "last_name": "Lovelace", + "email": "[email protected]" +} +``` + +**Errors** + +- `404` — no user matches the supplied ID. + +--- + +## Projects + +### `POST /projects` + +Create a new project. + +**Required fields:** `title`, `project_pi_id` +**Optional fields:** `id`, `origination`, `originated_id`, `created_time` (defaults to current UTC time) + +The referenced `project_pi_id` must be an existing user. `originated_id`, when supplied, must be unique across projects. + +**Request** + +```json +{ + "title": "Climate Simulation 2026", + "origination": "ACCESS", + "originated_id": "ACCESS-PRJ-9000", + "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02" +} +``` + +**Response 201** + +```json +{ + "id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1", + "originated_id": "ACCESS-PRJ-9000", + "title": "Climate Simulation 2026", + "origination": "ACCESS", + "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02", + "created_time": "2026-05-16T17:21:04.512Z" +} +``` + +**Errors** + +- `400` — `title`, `project_pi_id` missing, or the PI user does not exist. +- `409` — a project with this `originated_id` already exists. + +#### Example + +```bash +curl -s -X POST http://localhost:8080/projects \ + -H 'Content-Type: application/json' \ + -d '{ + "title":"Climate Simulation 2026", + "origination":"ACCESS", + "originated_id":"ACCESS-PRJ-9000", + "project_pi_id":"f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02" + }' +``` + +--- + +### `GET /projects/{id}` + +Retrieve a project by its ID. + +**Response 200** + +```json +{ + "id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1", + "originated_id": "ACCESS-PRJ-9000", + "title": "Climate Simulation 2026", + "origination": "ACCESS", + "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02", + "created_time": "2026-05-16T17:21:04.512Z" +} +``` + +**Errors** + +- `404` — no project matches the supplied ID. + +--- + +## End-to-end example + +```bash +BASE=http://localhost:8080 + +ORG_ID=$(curl -s -X POST $BASE/organizations \ + -H 'Content-Type: application/json' \ + -d '{"name":"University of Example","originated_id":"ACCESS-ORG-001"}' \ + | jq -r .id) + +USER_ID=$(curl -s -X POST $BASE/users \ + -H 'Content-Type: application/json' \ + -d "{\"organization_id\":\"$ORG_ID\",\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"email\":\"[email protected]\"}" \ + | jq -r .id) + +PROJ_ID=$(curl -s -X POST $BASE/projects \ + -H 'Content-Type: application/json' \ + -d "{\"title\":\"Climate Simulation 2026\",\"origination\":\"ACCESS\",\"originated_id\":\"ACCESS-PRJ-9000\",\"project_pi_id\":\"$USER_ID\"}" \ + | jq -r .id) + +curl -s $BASE/projects/$PROJ_ID | jq +``` + +--- + +## Running the server + +```bash +export DATABASE_DSN='custos:secret@tcp(127.0.0.1:3306)/custos?parseTime=true&charset=utf8mb4' +# optional +export HTTP_ADDR=:8080 +export DB_MAX_OPEN_CONNS=25 +export DB_MAX_IDLE_CONNS=5 + +go run ./cmd/server +``` + +| Environment variable | Default | Purpose | +|----------------------|---------|---------| +| `DATABASE_DSN` | *(required)* | MySQL/MariaDB DSN. `parseTime=true` is mandatory. | +| `HTTP_ADDR` | `:8080` | Address the HTTP server binds to. | +| `DB_MAX_OPEN_CONNS` | `25` | Maximum open database connections. | +| `DB_MAX_IDLE_CONNS` | `5` | Maximum idle database connections. | + +Migrations from `internal/db/migrations/` are applied automatically on startup. + +The server handles `SIGINT` / `SIGTERM` gracefully, draining in-flight requests +for up to 15 seconds before exiting. diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..33f3b3641 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/apache/airavata-custos + +go 1.24.0 + +require ( + github.com/go-sql-driver/mysql v1.8.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 + github.com/jmoiron/sqlx v1.4.0 +) + +require filippo.io/edwards25519 v1.1.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..77f8f3160 --- /dev/null +++ b/go.sum @@ -0,0 +1,73 @@ +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 000000000..a662d5467 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package db + +import ( + "fmt" + + _ "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" +) + +// Config holds configuration for opening a database connection. +type Config struct { + DSN string + MaxOpenConns int + MaxIdleConns int +} + +// Open opens and validates a MySQL/MariaDB connection using the supplied config. +func Open(cfg Config) (*sqlx.DB, error) { + db, err := sqlx.Open("mysql", cfg.DSN) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ping database: %w", err) + } + return db, nil +} diff --git a/internal/db/embed.go b/internal/db/embed.go new file mode 100644 index 000000000..e8cbfaa7e --- /dev/null +++ b/internal/db/embed.go @@ -0,0 +1,23 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package db + +import "embed" + +//go:embed migrations/*.sql +var migrationFS embed.FS diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 000000000..446c26b9e --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,55 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package db + +import ( + "errors" + "fmt" + "log/slog" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/mysql" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jmoiron/sqlx" +) + +// MigrateEmbedded applies all pending migrations from the embedded +// migrations/ directory against the supplied database. +// Returns nil when there is nothing to apply. +func MigrateEmbedded(database *sqlx.DB) error { + driver, err := mysql.WithInstance(database.DB, &mysql.Config{}) + if err != nil { + return fmt.Errorf("create migration driver: %w", err) + } + + source, err := iofs.New(migrationFS, "migrations") + if err != nil { + return fmt.Errorf("create migration source: %w", err) + } + + m, err := migrate.NewWithInstance("iofs", source, "mysql", driver) + if err != nil { + return fmt.Errorf("create migrator: %w", err) + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("run migrations: %w", err) + } + slog.Info("database migrations applied successfully") + return nil +} diff --git a/internal/db/migrations/000001_initial_schema.down.sql b/internal/db/migrations/000001_initial_schema.down.sql new file mode 100644 index 000000000..05fe180a5 --- /dev/null +++ b/internal/db/migrations/000001_initial_schema.down.sql @@ -0,0 +1,20 @@ +-- Licensed to the Apache Software Foundation (ASF) under one +-- or more contributor license agreements. See the NOTICE file +-- distributed with this work for additional information +-- regarding copyright ownership. The ASF licenses this file +-- to you under the Apache License, Version 2.0 (the +-- "License"); you may not use this file except in compliance +-- with the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, +-- software distributed under the License is distributed on an +-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +-- KIND, either express or implied. See the License for the +-- specific language governing permissions and limitations +-- under the License. + +DROP TABLE IF EXISTS projects; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS organizations; diff --git a/internal/db/migrations/000001_initial_schema.up.sql b/internal/db/migrations/000001_initial_schema.up.sql new file mode 100644 index 000000000..2b8872db9 --- /dev/null +++ b/internal/db/migrations/000001_initial_schema.up.sql @@ -0,0 +1,60 @@ +-- Licensed to the Apache Software Foundation (ASF) under one +-- or more contributor license agreements. See the NOTICE file +-- distributed with this work for additional information +-- regarding copyright ownership. The ASF licenses this file +-- to you under the Apache License, Version 2.0 (the +-- "License"); you may not use this file except in compliance +-- with the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, +-- software distributed under the License is distributed on an +-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +-- KIND, either express or implied. See the License for the +-- specific language governing permissions and limitations +-- under the License. + +SET NAMES utf8mb4; +SET time_zone = '+00:00'; + +CREATE TABLE IF NOT EXISTS organizations +( + id VARCHAR(255) NOT NULL, + originated_id VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + KEY idx_organizations_originated_id (originated_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS users +( + id VARCHAR(255) NOT NULL, + organization_id VARCHAR(255) NOT NULL, + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + middle_name VARCHAR(255) NULL, + email VARCHAR(255) NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_users_email (email), + KEY idx_users_organization_id (organization_id), + CONSTRAINT fk_users_organization FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE RESTRICT +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS projects +( + id VARCHAR(255) NOT NULL, + originated_id VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + origination VARCHAR(255) NOT NULL, + project_pi_id VARCHAR(255) NOT NULL, + created_time TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + KEY idx_projects_originated_id (originated_id), + KEY idx_projects_pi (project_pi_id), + CONSTRAINT fk_projects_pi FOREIGN KEY (project_pi_id) REFERENCES users (id) ON DELETE RESTRICT +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/internal/db/tx.go b/internal/db/tx.go new file mode 100644 index 000000000..050c06da8 --- /dev/null +++ b/internal/db/tx.go @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package db + +import ( + "context" + "database/sql" + "fmt" + + "github.com/jmoiron/sqlx" +) + +// TxFn executes fn within a new database transaction. +// If fn returns an error, the transaction is rolled back. +// If fn returns nil, the transaction is committed. +func TxFn(ctx context.Context, db *sqlx.DB, fn func(tx *sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback failed: %v (original error: %w)", rbErr, err) + } + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 000000000..bc9e9746a --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,194 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package server exposes the pkg/service API over HTTP/JSON. +package server + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/apache/airavata-custos/pkg/models" + "github.com/apache/airavata-custos/pkg/service" +) + +// Server is an HTTP handler that exposes the service API. +type Server struct { + svc *service.Service + mux *http.ServeMux +} + +// New builds an HTTP handler wired to the supplied service. +func New(svc *service.Service) *Server { + s := &Server{svc: svc, mux: http.NewServeMux()} + s.routes() + return s +} + +// ServeHTTP satisfies http.Handler. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mux.ServeHTTP(w, r) +} + +func (s *Server) routes() { + s.mux.HandleFunc("GET /healthz", s.healthz) + + s.mux.HandleFunc("POST /organizations", s.createOrganization) + s.mux.HandleFunc("GET /organizations/{id}", s.getOrganization) + + s.mux.HandleFunc("POST /users", s.createUser) + s.mux.HandleFunc("GET /users/{id}", s.getUser) + + s.mux.HandleFunc("POST /projects", s.createProject) + s.mux.HandleFunc("GET /projects/{id}", s.getProject) +} + +func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) createOrganization(w http.ResponseWriter, r *http.Request) { + var org models.Organization + if err := decodeJSON(r, &org); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + created, err := s.svc.CreateOrganization(r.Context(), &org) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, created) +} + +func (s *Server) getOrganization(w http.ResponseWriter, r *http.Request) { + org, err := s.svc.GetOrganization(r.Context(), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, org) +} + +func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { + var u models.User + if err := decodeJSON(r, &u); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + created, err := s.svc.CreateUser(r.Context(), &u) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, created) +} + +func (s *Server) getUser(w http.ResponseWriter, r *http.Request) { + u, err := s.svc.GetUser(r.Context(), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, u) +} + +func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { + var p models.Project + if err := decodeJSON(r, &p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + created, err := s.svc.CreateProject(r.Context(), &p) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, created) +} + +func (s *Server) getProject(w http.ResponseWriter, r *http.Request) { + p, err := s.svc.GetProject(r.Context(), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, p) +} + +// LoggingMiddleware logs every request once it completes. +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rw, r) + slog.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "status", rw.status, + "duration", time.Since(start).String(), + ) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func decodeJSON(r *http.Request, dst any) error { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + return dec.Decode(dst) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body == nil { + return + } + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +func writeServiceError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, service.ErrNotFound): + writeError(w, http.StatusNotFound, err) + case errors.Is(err, service.ErrAlreadyExists): + writeError(w, http.StatusConflict, err) + case errors.Is(err, service.ErrInvalidInput): + writeError(w, http.StatusBadRequest, err) + default: + // Avoid leaking driver messages to clients; log the full error. + slog.Error("internal server error", "error", err.Error()) + writeError(w, http.StatusInternalServerError, errors.New(strings.TrimSpace("internal server error"))) + } +} diff --git a/internal/store/organization_store.go b/internal/store/organization_store.go new file mode 100644 index 000000000..c606dcb5c --- /dev/null +++ b/internal/store/organization_store.go @@ -0,0 +1,82 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package store + +import ( + "context" + "database/sql" + "errors" + + "github.com/jmoiron/sqlx" + + "github.com/apache/airavata-custos/pkg/models" +) + +type mysqlOrganizationStore struct { + db *sqlx.DB +} + +// NewOrganizationStore returns a MySQL-backed OrganizationStore. +func NewOrganizationStore(db *sqlx.DB) OrganizationStore { + return &mysqlOrganizationStore{db: db} +} + +func (s *mysqlOrganizationStore) FindByID(ctx context.Context, id string) (*models.Organization, error) { + var o models.Organization + err := s.db.GetContext(ctx, &o, + `SELECT id, originated_id, name FROM organizations WHERE id = ?`, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &o, nil +} + +func (s *mysqlOrganizationStore) FindByOriginatedID(ctx context.Context, originatedID string) (*models.Organization, error) { + var o models.Organization + err := s.db.GetContext(ctx, &o, + `SELECT id, originated_id, name FROM organizations WHERE originated_id = ?`, originatedID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &o, nil +} + +func (s *mysqlOrganizationStore) Create(ctx context.Context, tx *sql.Tx, o *models.Organization) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO organizations (id, originated_id, name) VALUES (?, ?, ?)`, + o.ID, o.OriginatedID, o.Name) + return err +} + +func (s *mysqlOrganizationStore) Update(ctx context.Context, tx *sql.Tx, o *models.Organization) error { + _, err := tx.ExecContext(ctx, + `UPDATE organizations SET originated_id = ?, name = ? WHERE id = ?`, + o.OriginatedID, o.Name, o.ID) + return err +} + +func (s *mysqlOrganizationStore) Delete(ctx context.Context, tx *sql.Tx, id string) error { + _, err := tx.ExecContext(ctx, `DELETE FROM organizations WHERE id = ?`, id) + return err +} diff --git a/internal/store/project_store.go b/internal/store/project_store.go new file mode 100644 index 000000000..fc447500a --- /dev/null +++ b/internal/store/project_store.go @@ -0,0 +1,97 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package store + +import ( + "context" + "database/sql" + "errors" + + "github.com/jmoiron/sqlx" + + "github.com/apache/airavata-custos/pkg/models" +) + +type mysqlProjectStore struct { + db *sqlx.DB +} + +// NewProjectStore returns a MySQL-backed ProjectStore. +func NewProjectStore(db *sqlx.DB) ProjectStore { + return &mysqlProjectStore{db: db} +} + +func (s *mysqlProjectStore) FindByID(ctx context.Context, id string) (*models.Project, error) { + var p models.Project + err := s.db.GetContext(ctx, &p, + `SELECT id, originated_id, title, origination, project_pi_id, created_time + FROM projects WHERE id = ?`, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +func (s *mysqlProjectStore) FindByOriginatedID(ctx context.Context, originatedID string) (*models.Project, error) { + var p models.Project + err := s.db.GetContext(ctx, &p, + `SELECT id, originated_id, title, origination, project_pi_id, created_time + FROM projects WHERE originated_id = ?`, originatedID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +func (s *mysqlProjectStore) FindByPI(ctx context.Context, piUserID string) ([]models.Project, error) { + var projects []models.Project + err := s.db.SelectContext(ctx, &projects, + `SELECT id, originated_id, title, origination, project_pi_id, created_time + FROM projects WHERE project_pi_id = ?`, piUserID) + if err != nil { + return nil, err + } + return projects, nil +} + +func (s *mysqlProjectStore) Create(ctx context.Context, tx *sql.Tx, p *models.Project) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO projects (id, originated_id, title, origination, project_pi_id, created_time) + VALUES (?, ?, ?, ?, ?, ?)`, + p.ID, p.OriginatedID, p.Title, p.Origination, p.ProjectPIID, p.CreatedTime) + return err +} + +func (s *mysqlProjectStore) Update(ctx context.Context, tx *sql.Tx, p *models.Project) error { + _, err := tx.ExecContext(ctx, + `UPDATE projects SET originated_id = ?, title = ?, origination = ?, project_pi_id = ? + WHERE id = ?`, + p.OriginatedID, p.Title, p.Origination, p.ProjectPIID, p.ID) + return err +} + +func (s *mysqlProjectStore) Delete(ctx context.Context, tx *sql.Tx, id string) error { + _, err := tx.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id) + return err +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 000000000..f0b1e1206 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,71 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package store + +import ( + "context" + "database/sql" + + "github.com/apache/airavata-custos/pkg/models" +) + +// UserStore defines persistence operations for users. +type UserStore interface { + // FindByID returns the user with the given ID, or nil if not found. + FindByID(ctx context.Context, id string) (*models.User, error) + // FindByEmail returns the user with the given email, or nil if not found. + FindByEmail(ctx context.Context, email string) (*models.User, error) + // FindByOrganization returns all users belonging to the given organization. + FindByOrganization(ctx context.Context, organizationID string) ([]models.User, error) + // Create inserts a new user within the provided transaction. + Create(ctx context.Context, tx *sql.Tx, u *models.User) error + // Update replaces mutable fields of an existing user within the provided transaction. + Update(ctx context.Context, tx *sql.Tx, u *models.User) error + // Delete removes a user by ID within the provided transaction. + Delete(ctx context.Context, tx *sql.Tx, id string) error +} + +// OrganizationStore defines persistence operations for organizations. +type OrganizationStore interface { + // FindByID returns the organization with the given ID, or nil if not found. + FindByID(ctx context.Context, id string) (*models.Organization, error) + // FindByOriginatedID returns the organization matching the external originated ID, or nil if not found. + FindByOriginatedID(ctx context.Context, originatedID string) (*models.Organization, error) + // Create inserts a new organization within the provided transaction. + Create(ctx context.Context, tx *sql.Tx, o *models.Organization) error + // Update replaces mutable fields of an existing organization within the provided transaction. + Update(ctx context.Context, tx *sql.Tx, o *models.Organization) error + // Delete removes an organization by ID within the provided transaction. + Delete(ctx context.Context, tx *sql.Tx, id string) error +} + +// ProjectStore defines persistence operations for projects. +type ProjectStore interface { + // FindByID returns the project with the given ID, or nil if not found. + FindByID(ctx context.Context, id string) (*models.Project, error) + // FindByOriginatedID returns the project matching the external originated ID, or nil if not found. + FindByOriginatedID(ctx context.Context, originatedID string) (*models.Project, error) + // FindByPI returns all projects whose PI matches the given user ID. + FindByPI(ctx context.Context, piUserID string) ([]models.Project, error) + // Create inserts a new project within the provided transaction. + Create(ctx context.Context, tx *sql.Tx, p *models.Project) error + // Update replaces mutable fields of an existing project within the provided transaction. + Update(ctx context.Context, tx *sql.Tx, p *models.Project) error + // Delete removes a project by ID within the provided transaction. + Delete(ctx context.Context, tx *sql.Tx, id string) error +} diff --git a/internal/store/user_store.go b/internal/store/user_store.go new file mode 100644 index 000000000..eda2b749f --- /dev/null +++ b/internal/store/user_store.go @@ -0,0 +1,97 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package store + +import ( + "context" + "database/sql" + "errors" + + "github.com/jmoiron/sqlx" + + "github.com/apache/airavata-custos/pkg/models" +) + +type mysqlUserStore struct { + db *sqlx.DB +} + +// NewUserStore returns a MySQL-backed UserStore. +func NewUserStore(db *sqlx.DB) UserStore { + return &mysqlUserStore{db: db} +} + +func (s *mysqlUserStore) FindByID(ctx context.Context, id string) (*models.User, error) { + var u models.User + err := s.db.GetContext(ctx, &u, + `SELECT id, organization_id, first_name, last_name, middle_name, email + FROM users WHERE id = ?`, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &u, nil +} + +func (s *mysqlUserStore) FindByEmail(ctx context.Context, email string) (*models.User, error) { + var u models.User + err := s.db.GetContext(ctx, &u, + `SELECT id, organization_id, first_name, last_name, middle_name, email + FROM users WHERE email = ?`, email) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &u, nil +} + +func (s *mysqlUserStore) FindByOrganization(ctx context.Context, organizationID string) ([]models.User, error) { + var users []models.User + err := s.db.SelectContext(ctx, &users, + `SELECT id, organization_id, first_name, last_name, middle_name, email + FROM users WHERE organization_id = ?`, organizationID) + if err != nil { + return nil, err + } + return users, nil +} + +func (s *mysqlUserStore) Create(ctx context.Context, tx *sql.Tx, u *models.User) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO users (id, organization_id, first_name, last_name, middle_name, email) + VALUES (?, ?, ?, ?, ?, ?)`, + u.ID, u.OrganizationID, u.FirstName, u.LastName, u.MiddleName, u.Email) + return err +} + +func (s *mysqlUserStore) Update(ctx context.Context, tx *sql.Tx, u *models.User) error { + _, err := tx.ExecContext(ctx, + `UPDATE users SET organization_id = ?, first_name = ?, last_name = ?, middle_name = ?, email = ? + WHERE id = ?`, + u.OrganizationID, u.FirstName, u.LastName, u.MiddleName, u.Email, u.ID) + return err +} + +func (s *mysqlUserStore) Delete(ctx context.Context, tx *sql.Tx, id string) error { + _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) + return err +} diff --git a/pkg/README.md b/pkg/README.md new file mode 100644 index 000000000..093ae7bb1 --- /dev/null +++ b/pkg/README.md @@ -0,0 +1,120 @@ +# pkg + +Public Go libraries shared across this repository's modules. Following the +[golang-standards/project-layout](https://github.com/golang-standards/project-layout) +convention, only externally-usable code lives here; private implementation +details live in the top-level [`/internal`](../internal/) tree. + +Module root: `github.com/apache/airavata-custos` (root `go.mod`). + +## Packages + +| Package | Import path | Purpose | +|---------|-------------|---------| +| `models` | `github.com/apache/airavata-custos/pkg/models` | Shared domain types (`User`, `Organization`, `Project`, allocations) | +| `service` | `github.com/apache/airavata-custos/pkg/service` | **High-level API** for creating, reading, updating, and deleting entities | + +The supporting `internal/db` and `internal/store` packages are private to this +module and are not importable from outside the repository. + +--- + +## Requirements + +- Go 1.24+ +- MySQL 8+ or MariaDB 10.5+ + +--- + +## Quick Start + +Every service method takes a [`context.Context`](https://pkg.go.dev/context) +as its first argument. The context carries cancellation, deadlines, and +request-scoped values down into the database driver, so an in-flight query is +aborted if the caller goes away or a deadline passes. You typically derive it +from one of: + +- `context.Background()` — root context for `main`, init, tests, scripts. +- `r.Context()` — inside an HTTP handler; cancelled when the client disconnects. +- The `ctx` argument of a gRPC handler. +- `context.WithTimeout(parent, d)` — adds a deadline; **always `defer cancel()`**. +- `signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)` — cancelled on Ctrl-C / SIGTERM. + +Never pass `nil`; use `context.Background()` (or `context.TODO()`) if you have +nothing better. + +```go +import ( + "context" + "time" + + "github.com/apache/airavata-custos/pkg/models" + "github.com/apache/airavata-custos/pkg/service" +) + +svc := service.New(database) // *sqlx.DB + +// Derive a context with a 10s budget for this call chain. +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() + +org, err := svc.CreateOrganization(ctx, &models.Organization{ + Name: "University of Example", + OriginatedID: "ACCESS-ORG-001", +}) + +user, err := svc.CreateUser(ctx, &models.User{ + OrganizationID: org.ID, + FirstName: "Ada", + LastName: "Lovelace", + Email: "[email protected]", +}) + +project, err := svc.CreateProject(ctx, &models.Project{ + Title: "Climate Simulation 2026", + Origination: "ACCESS", + OriginatedID: "ACCESS-PRJ-9000", + ProjectPIID: user.ID, +}) +``` + +- If `ID` is left empty the service generates a UUID. +- If `CreatedTime` on a project is zero, the service sets it to `time.Now().UTC()`. +- The populated entity is returned. + +## Read / Update / Delete + +```go +org, err := svc.GetOrganization(ctx, orgID) +user, err := svc.GetUserByEmail(ctx, "[email protected]") +proj, err := svc.GetProject(ctx, projID) + +users, err := svc.ListUsersByOrganization(ctx, orgID) +projects, err := svc.ListProjectsByPI(ctx, userID) + +err = svc.UpdateUser(ctx, user) +err = svc.DeleteProject(ctx, projID) +``` + +## Errors + +| Sentinel | Returned when | +|----------|---------------| +| `service.ErrNotFound` | A `GetX` call finds no matching record | +| `service.ErrAlreadyExists` | Duplicate email or duplicate `originated_id` | +| `service.ErrInvalidInput` | Missing required field, or unknown FK reference | + +Use `errors.Is(err, service.ErrNotFound)` to check. + +## Schema + +Tables created by the embedded migrations in `/internal/db/migrations/`: + +``` +organizations +users (FK → organizations.id) +projects (FK → users.id via project_pi_id) +``` + +`parseTime=true` must be set in the DSN so MySQL `TIMESTAMP` columns scan into +`time.Time` correctly. diff --git a/pkg/models/project.go b/pkg/models/project.go index 99841b6f6..d6325959d 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -3,25 +3,25 @@ package models import "time" type Project struct { - ID string `json:"id"` - OriginatedID string `json:"originated_id"` // The ID of the project in origination. For example: ACCESS Record ID. - Title string `json:"title"` - Origination string `json:"origination"` // ACCESS, NAIRR, XRASS, etc. - ProjectPIID string `json:"project_pi_id"` - CreatedTime time.Time `json:"created_time"` + ID string `json:"id" db:"id"` + OriginatedID string `json:"originated_id" db:"originated_id"` // The ID of the project in origination. For example: ACCESS Record ID. + Title string `json:"title" db:"title"` + Origination string `json:"origination" db:"origination"` // ACCESS, NAIRR, XRASS, etc. + ProjectPIID string `json:"project_pi_id" db:"project_pi_id"` + CreatedTime time.Time `json:"created_time" db:"created_time"` } type Organization struct { - ID string `json:"id"` - OriginatedID string `json:"originated_id"` // The ID of the organization in origination. For example: ACCESS Record ID. - Name string `json:"name"` + ID string `json:"id" db:"id"` + OriginatedID string `json:"originated_id" db:"originated_id"` // The ID of the organization in origination. For example: ACCESS Record ID. + Name string `json:"name" db:"name"` } type User struct { - ID string `json:"id"` - OrganizationID string `json:"organization_id"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - MiddleName string `json:"middle_name,omitempty"` - Email string `json:"email"` + ID string `json:"id" db:"id"` + OrganizationID string `json:"organization_id" db:"organization_id"` + FirstName string `json:"first_name" db:"first_name"` + LastName string `json:"last_name" db:"last_name"` + MiddleName string `json:"middle_name,omitempty" db:"middle_name"` + Email string `json:"email" db:"email"` } diff --git a/pkg/service/errors.go b/pkg/service/errors.go new file mode 100644 index 000000000..bf0011036 --- /dev/null +++ b/pkg/service/errors.go @@ -0,0 +1,34 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package service provides high-level operations on the core domain entities +// (Organization, User, Project). It hides database transactions and other +// persistence concerns from callers, so other modules can use simple +// CreateX / GetX / UpdateX / DeleteX functions. +package service + +import "errors" + +// ErrNotFound is returned when a requested record does not exist. +var ErrNotFound = errors.New("record not found") + +// ErrAlreadyExists is returned when attempting to create a record that +// conflicts with an existing one (e.g. duplicate email). +var ErrAlreadyExists = errors.New("record already exists") + +// ErrInvalidInput is returned when required fields are missing or invalid. +var ErrInvalidInput = errors.New("invalid input") diff --git a/pkg/service/organization.go b/pkg/service/organization.go new file mode 100644 index 000000000..bca060a06 --- /dev/null +++ b/pkg/service/organization.go @@ -0,0 +1,106 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package service + +import ( + "context" + "database/sql" + "fmt" + + "github.com/apache/airavata-custos/pkg/models" +) + +// CreateOrganization persists a new organization. If org.ID is empty a new +// UUID is generated. The (possibly populated) organization is returned. +func (s *Service) CreateOrganization(ctx context.Context, org *models.Organization) (*models.Organization, error) { + if org == nil { + return nil, fmt.Errorf("%w: organization is nil", ErrInvalidInput) + } + if org.Name == "" { + return nil, fmt.Errorf("%w: organization name is required", ErrInvalidInput) + } + if org.ID == "" { + org.ID = newID() + } + + if org.OriginatedID != "" { + if existing, err := s.orgs.FindByOriginatedID(ctx, org.OriginatedID); err != nil { + return nil, fmt.Errorf("lookup organization by originated id: %w", err) + } else if existing != nil { + return nil, fmt.Errorf("%w: organization with originated_id %q", ErrAlreadyExists, org.OriginatedID) + } + } + + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.orgs.Create(ctx, tx, org) + }); err != nil { + return nil, fmt.Errorf("create organization: %w", err) + } + return org, nil +} + +// GetOrganization retrieves an organization by its ID. Returns ErrNotFound +// when no organization matches. +func (s *Service) GetOrganization(ctx context.Context, id string) (*models.Organization, error) { + org, err := s.orgs.FindByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("get organization: %w", err) + } + if org == nil { + return nil, ErrNotFound + } + return org, nil +} + +// GetOrganizationByOriginatedID retrieves an organization by its external originated ID. +func (s *Service) GetOrganizationByOriginatedID(ctx context.Context, originatedID string) (*models.Organization, error) { + org, err := s.orgs.FindByOriginatedID(ctx, originatedID) + if err != nil { + return nil, fmt.Errorf("get organization by originated id: %w", err) + } + if org == nil { + return nil, ErrNotFound + } + return org, nil +} + +// UpdateOrganization persists changes to an existing organization. +func (s *Service) UpdateOrganization(ctx context.Context, org *models.Organization) error { + if org == nil || org.ID == "" { + return fmt.Errorf("%w: organization id is required", ErrInvalidInput) + } + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.orgs.Update(ctx, tx, org) + }); err != nil { + return fmt.Errorf("update organization: %w", err) + } + return nil +} + +// DeleteOrganization removes an organization by ID. +func (s *Service) DeleteOrganization(ctx context.Context, id string) error { + if id == "" { + return fmt.Errorf("%w: organization id is required", ErrInvalidInput) + } + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.orgs.Delete(ctx, tx, id) + }); err != nil { + return fmt.Errorf("delete organization: %w", err) + } + return nil +} diff --git a/pkg/service/project.go b/pkg/service/project.go new file mode 100644 index 000000000..f503a537d --- /dev/null +++ b/pkg/service/project.go @@ -0,0 +1,127 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package service + +import ( + "context" + "database/sql" + "fmt" + + "github.com/apache/airavata-custos/pkg/models" +) + +// CreateProject persists a new project. If project.ID is empty a new UUID is +// generated. The referenced PI user must already exist. +func (s *Service) CreateProject(ctx context.Context, project *models.Project) (*models.Project, error) { + if project == nil { + return nil, fmt.Errorf("%w: project is nil", ErrInvalidInput) + } + if project.Title == "" { + return nil, fmt.Errorf("%w: project title is required", ErrInvalidInput) + } + if project.ProjectPIID == "" { + return nil, fmt.Errorf("%w: project_pi_id is required", ErrInvalidInput) + } + + if pi, err := s.users.FindByID(ctx, project.ProjectPIID); err != nil { + return nil, fmt.Errorf("verify project PI: %w", err) + } else if pi == nil { + return nil, fmt.Errorf("%w: PI user %q does not exist", ErrInvalidInput, project.ProjectPIID) + } + + if project.OriginatedID != "" { + if existing, err := s.projs.FindByOriginatedID(ctx, project.OriginatedID); err != nil { + return nil, fmt.Errorf("lookup project by originated id: %w", err) + } else if existing != nil { + return nil, fmt.Errorf("%w: project with originated_id %q", ErrAlreadyExists, project.OriginatedID) + } + } + + if project.ID == "" { + project.ID = newID() + } + if project.CreatedTime.IsZero() { + project.CreatedTime = nowUTC() + } + + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.projs.Create(ctx, tx, project) + }); err != nil { + return nil, fmt.Errorf("create project: %w", err) + } + return project, nil +} + +// GetProject retrieves a project by ID. Returns ErrNotFound when no project matches. +func (s *Service) GetProject(ctx context.Context, id string) (*models.Project, error) { + p, err := s.projs.FindByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("get project: %w", err) + } + if p == nil { + return nil, ErrNotFound + } + return p, nil +} + +// GetProjectByOriginatedID retrieves a project by its external originated ID. +func (s *Service) GetProjectByOriginatedID(ctx context.Context, originatedID string) (*models.Project, error) { + p, err := s.projs.FindByOriginatedID(ctx, originatedID) + if err != nil { + return nil, fmt.Errorf("get project by originated id: %w", err) + } + if p == nil { + return nil, ErrNotFound + } + return p, nil +} + +// ListProjectsByPI returns all projects whose PI matches the given user ID. +func (s *Service) ListProjectsByPI(ctx context.Context, piUserID string) ([]models.Project, error) { + projects, err := s.projs.FindByPI(ctx, piUserID) + if err != nil { + return nil, fmt.Errorf("list projects by PI: %w", err) + } + return projects, nil +} + +// UpdateProject persists changes to an existing project. +func (s *Service) UpdateProject(ctx context.Context, project *models.Project) error { + if project == nil || project.ID == "" { + return fmt.Errorf("%w: project id is required", ErrInvalidInput) + } + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.projs.Update(ctx, tx, project) + }); err != nil { + return fmt.Errorf("update project: %w", err) + } + return nil +} + +// DeleteProject removes a project by ID. +func (s *Service) DeleteProject(ctx context.Context, id string) error { + if id == "" { + return fmt.Errorf("%w: project id is required", ErrInvalidInput) + } + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.projs.Delete(ctx, tx, id) + }); err != nil { + return fmt.Errorf("delete project: %w", err) + } + return nil +} diff --git a/pkg/service/service.go b/pkg/service/service.go new file mode 100644 index 000000000..7250e493f --- /dev/null +++ b/pkg/service/service.go @@ -0,0 +1,73 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package service + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/apache/airavata-custos/internal/db" + "github.com/apache/airavata-custos/internal/store" +) + +// Service is a high-level façade over the underlying stores. It wraps each +// mutating operation in a transaction so callers do not need to manage +// *sql.Tx themselves. +type Service struct { + db *sqlx.DB + orgs store.OrganizationStore + users store.UserStore + projs store.ProjectStore +} + +// New constructs a Service backed by the supplied database handle. +// Stores are instantiated internally using the default MySQL implementations. +func New(database *sqlx.DB) *Service { + return &Service{ + db: database, + orgs: store.NewOrganizationStore(database), + users: store.NewUserStore(database), + projs: store.NewProjectStore(database), + } +} + +// NewWithStores constructs a Service from explicit stores. Useful for tests +// within this module — stores are an internal type and cannot be supplied by +// external callers. +func NewWithStores(database *sqlx.DB, orgs store.OrganizationStore, users store.UserStore, projs store.ProjectStore) *Service { + return &Service{db: database, orgs: orgs, users: users, projs: projs} +} + +// inTx runs fn inside a database transaction managed by the Service. +func (s *Service) inTx(ctx context.Context, fn func(tx *sql.Tx) error) error { + return db.TxFn(ctx, s.db, fn) +} + +// newID returns a new identifier for a freshly created entity. +func newID() string { + return uuid.NewString() +} + +// nowUTC returns the current time in UTC. +func nowUTC() time.Time { + return time.Now().UTC() +} diff --git a/pkg/service/user.go b/pkg/service/user.go new file mode 100644 index 000000000..da809881c --- /dev/null +++ b/pkg/service/user.go @@ -0,0 +1,122 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package service + +import ( + "context" + "database/sql" + "fmt" + + "github.com/apache/airavata-custos/pkg/models" +) + +// CreateUser persists a new user. If user.ID is empty a new UUID is generated. +// The referenced organization must already exist. +func (s *Service) CreateUser(ctx context.Context, user *models.User) (*models.User, error) { + if user == nil { + return nil, fmt.Errorf("%w: user is nil", ErrInvalidInput) + } + if user.Email == "" { + return nil, fmt.Errorf("%w: user email is required", ErrInvalidInput) + } + if user.OrganizationID == "" { + return nil, fmt.Errorf("%w: user organization_id is required", ErrInvalidInput) + } + + if org, err := s.orgs.FindByID(ctx, user.OrganizationID); err != nil { + return nil, fmt.Errorf("verify organization: %w", err) + } else if org == nil { + return nil, fmt.Errorf("%w: organization %q does not exist", ErrInvalidInput, user.OrganizationID) + } + + if existing, err := s.users.FindByEmail(ctx, user.Email); err != nil { + return nil, fmt.Errorf("lookup user by email: %w", err) + } else if existing != nil { + return nil, fmt.Errorf("%w: user with email %q", ErrAlreadyExists, user.Email) + } + + if user.ID == "" { + user.ID = newID() + } + + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.users.Create(ctx, tx, user) + }); err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + return user, nil +} + +// GetUser retrieves a user by ID. Returns ErrNotFound when no user matches. +func (s *Service) GetUser(ctx context.Context, id string) (*models.User, error) { + u, err := s.users.FindByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + if u == nil { + return nil, ErrNotFound + } + return u, nil +} + +// GetUserByEmail retrieves a user by email. +func (s *Service) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { + u, err := s.users.FindByEmail(ctx, email) + if err != nil { + return nil, fmt.Errorf("get user by email: %w", err) + } + if u == nil { + return nil, ErrNotFound + } + return u, nil +} + +// ListUsersByOrganization returns all users belonging to an organization. +func (s *Service) ListUsersByOrganization(ctx context.Context, organizationID string) ([]models.User, error) { + users, err := s.users.FindByOrganization(ctx, organizationID) + if err != nil { + return nil, fmt.Errorf("list users by organization: %w", err) + } + return users, nil +} + +// UpdateUser persists changes to an existing user. +func (s *Service) UpdateUser(ctx context.Context, user *models.User) error { + if user == nil || user.ID == "" { + return fmt.Errorf("%w: user id is required", ErrInvalidInput) + } + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.users.Update(ctx, tx, user) + }); err != nil { + return fmt.Errorf("update user: %w", err) + } + return nil +} + +// DeleteUser removes a user by ID. +func (s *Service) DeleteUser(ctx context.Context, id string) error { + if id == "" { + return fmt.Errorf("%w: user id is required", ErrInvalidInput) + } + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.users.Delete(ctx, tx, id) + }); err != nil { + return fmt.Errorf("delete user: %w", err) + } + return nil +}
