Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ debug-test-db:
watchgen:
@echo "Watching for .sql file changes. Press ctrl+c *twice* to exit, or once to rebuild."
@while true; do \
find . -type f -name '*.sql' | entr -d make gen ; \
find . -type f \( -name '*.sql' -o -name 'openapi.yaml' -o -name 'oapi-codegen.yaml' \) | entr -d make gen ; \
sleep 0.5 ; \
done

Expand Down
1 change: 1 addition & 0 deletions gen.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
package main

//go:generate go tool github.com/sqlc-dev/sqlc/cmd/sqlc generate
//go:generate go tool oapi-codegen -config oapi-codegen.yaml openapi.yaml
20 changes: 20 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@ require (
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/cubicdaiya/gonp v1.0.4 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/getkin/kin-openapi v0.132.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/google/cel-go v0.24.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
Expand All @@ -36,11 +41,19 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/lmittmann/tint v1.1.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
github.com/oapi-codegen/runtime v1.1.2 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
Expand All @@ -54,6 +67,8 @@ require (
github.com/riverqueue/river/rivertype v0.23.1 // indirect
github.com/riza-io/grpc-go v0.2.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
Expand All @@ -65,6 +80,7 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
go.uber.org/atomic v1.11.0 // indirect
Expand All @@ -73,15 +89,18 @@ require (
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
google.golang.org/grpc v1.71.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
Expand All @@ -103,6 +122,7 @@ require (

tool (
github.com/jackc/tern/v2
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
github.com/riverqueue/river/cmd/river
github.com/sqlc-dev/sqlc/cmd/sqlc
)
121 changes: 121 additions & 0 deletions go.sum

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions internal/api/impl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package api

import (
"context"
"fmt"
"log/slog"
"time"

"github.com/cloud-gov/billing/internal/db"
"github.com/cloud-gov/billing/internal/jobs"
"github.com/cloudfoundry/go-cfclient/v3/client"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/riverqueue/river"
)

// Server contains all business logic for the HTTP API endpoints. It implements [StrictServerInterface] and is input to which handles JSON marshalling writing to the response stream. Use [NewServer] to create a Server.
type Server struct {
Logger *slog.Logger
Querier db.Querier
River *river.Client[pgx.Tx]
CF *client.Client
TokenVerifier *oidc.IDTokenVerifier
}

// Compile-time validation that Server implements [StrictServerInterface].
var _ StrictServerInterface = (*Server)(nil)

// NewServer initializes and returns a [Server].
func NewServer(logger *slog.Logger, q db.Querier, riverc *river.Client[pgx.Tx], cf *client.Client, verifier *oidc.IDTokenVerifier) Server {
return Server{
Logger: logger,
Querier: q,
River: riverc,
CF: cf,
TokenVerifier: verifier,
}
}

func (s *Server) CreateTier(ctx context.Context, request CreateTierRequestObject) (CreateTierResponseObject, error) {
tier, err := s.Querier.CreateTier(ctx, db.CreateTierParams{
Name: request.Body.Name,
TierCredits: int64(request.Body.CreditsPerYear),
})
if err != nil {
return nil, err
}
return CreateTier201JSONResponse{
Id: string(tier.ID),
Name: tier.Name,
CreditsPerYear: int(tier.TierCredits),
}, nil
}

func (s *Server) CreateAppUsageJob(ctx context.Context, request CreateAppUsageJobRequestObject) (CreateAppUsageJobResponseObject, error) {
s.Logger.Debug("api: getting app")
app, err := s.CF.Applications.Get(ctx, request.Guid)
if err != nil {
return nil, fmt.Errorf("getting app: %w", err)
}
s.Logger.Debug("api: getting space")
space, err := s.CF.Spaces.Get(ctx, app.Relationships.Space.Data.GUID)
if err != nil {
return nil, fmt.Errorf("getting space: %w", err)
}

s.Logger.Debug("api: creating reading")
reading, err := s.Querier.CreateUniqueReading(ctx, db.CreateUniqueReadingParams{
CreatedAt: pgtype.Timestamp{Time: time.Now().UTC(), Valid: true},
Periodic: false,
})
if err != nil {
return nil, fmt.Errorf("creating reading: %w", err)
}

s.Logger.Debug("api: upserting resource")
resource, err := s.Querier.UpsertResource(ctx, db.UpsertResourceParams{
NaturalID: app.GUID,
Meter: "oneoff",
KindNaturalID: "",
CFOrgID: pgxUUID(space.Relationships.Organization.Data.GUID),
})
if err != nil {
return nil, fmt.Errorf("upserting resource: %w", err)
}
s.Logger.Debug("api: creating measurement")
_, err = s.Querier.CreateMeasurements(ctx, []db.CreateMeasurementsParams{
{
ReadingID: reading.ID,
Meter: resource.Meter,
ResourceNaturalID: resource.NaturalID,
Value: 1,
},
})
if err != nil {
return nil, fmt.Errorf("creating measurement: %w", err)
}
return CreateAppUsageJob202Response{}, nil
}

func (s *Server) CreateUsageJob(ctx context.Context, request CreateUsageJobRequestObject) (CreateUsageJobResponseObject, error) {

result, err := s.River.Insert(ctx, jobs.MeasureUsageArgs{}, nil)
if err != nil {
return nil, fmt.Errorf("inserting MeasureUsage job to River: %w", err)
}

return CreateUsageJob202JSONResponse{
Id: int(result.Job.ID),
}, nil
}

func pgxUUID(s string) pgtype.UUID {
u := pgtype.UUID{}
u.Scan(s)
return u
}
100 changes: 3 additions & 97 deletions internal/api/routes.go
Original file line number Diff line number Diff line change
@@ -1,132 +1,38 @@
package api

import (
"fmt"
"io"
"log/slog"
"net/http"
"time"

"github.com/cloudfoundry/go-cfclient/v3/client"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httplog/v3"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/riverqueue/river"

"github.com/cloud-gov/billing/internal/api/middleware"
"github.com/cloud-gov/billing/internal/config"
"github.com/cloud-gov/billing/internal/db"
"github.com/cloud-gov/billing/internal/jobs"
)

// Routes registers all customer-facing HTTP routes for the server.
// Routes registers all public HTTP routes for the server.
func Routes(logger *slog.Logger, cf *client.Client, q db.Querier, riverc *river.Client[pgx.Tx], verifier *oidc.IDTokenVerifier, config config.Config) http.Handler {
mux := chi.NewMux()
mux.Use(httplog.RequestLogger(logger, &httplog.Options{
Level: slog.LevelInfo,
}))

mux.Mount("/admin", adminMux(logger, cf, q, riverc, verifier, config))
mux.Mount("/admin", adminMux(logger, cf, q, riverc, verifier))
return mux
}

// adminMux returns a Handler for admin routes with access restricted to authorized subjects.
func adminMux(logger *slog.Logger, cf *client.Client, q db.Querier, riverc *river.Client[pgx.Tx], verifier *oidc.IDTokenVerifier, config config.Config) http.Handler {
func adminMux(logger *slog.Logger, cf *client.Client, q db.Querier, riverc *river.Client[pgx.Tx], verifier *oidc.IDTokenVerifier) http.Handler {
mux := chi.NewMux()

hasAdminScope := middleware.NewHasScope(logger, verifier, "usage.admin")
mux.Use(hasAdminScope)

mux.Post("/tier", handleCreateTier(q))
mux.Post("/usage/job", handleCreateUsageJob(riverc))
mux.Post("/usage/app/{guid}", handleCreateAppUsageJob(logger, cf, q))

return mux
}

func handleCreateTier(q db.Querier) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tier, err := q.CreateTier(r.Context(), db.CreateTierParams{
Name: "",
TierCredits: 0,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.WriteString(w, fmt.Sprintf("%v", tier.ID))
}
}

func handleCreateUsageJob(riverc *river.Client[pgx.Tx]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
result, err := riverc.Insert(r.Context(), jobs.MeasureUsageArgs{}, nil)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to insert River job: %v\n", err), http.StatusInternalServerError)
return
}
io.WriteString(w, fmt.Sprintf("Inserted job with ID: %v\nUniqueSkippedAsDuplicate: %v", result.Job.ID, result.UniqueSkippedAsDuplicate))
}
}

func handleCreateAppUsageJob(logger *slog.Logger, cf *client.Client, q db.Querier) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logger.Debug("api: getting app")
app, err := cf.Applications.Get(ctx, chi.URLParam(r, "guid"))
if err != nil {
http.Error(w, "getting app: "+err.Error(), http.StatusInternalServerError)
return
}
logger.Debug("api: getting space")
space, err := cf.Spaces.Get(ctx, app.Relationships.Space.Data.GUID)
if err != nil {
http.Error(w, "getting space: "+err.Error(), http.StatusInternalServerError)
return
}

logger.Debug("api: creating reading")
reading, err := q.CreateUniqueReading(ctx, db.CreateUniqueReadingParams{
CreatedAt: pgtype.Timestamp{Time: time.Now().UTC(), Valid: true},
Periodic: false,
})
if err != nil {
http.Error(w, "creating reading: "+err.Error(), http.StatusInternalServerError)
return
}

logger.Debug("api: upserting resource")
resource, err := q.UpsertResource(ctx, db.UpsertResourceParams{
NaturalID: app.GUID,
Meter: "oneoff",
KindNaturalID: "",
CFOrgID: pgxUUID(space.Relationships.Organization.Data.GUID),
})
if err != nil {
http.Error(w, "upserting resource: "+err.Error(), http.StatusInternalServerError)
return
}
logger.Debug("api: creating measurement")
_, err = q.CreateMeasurements(ctx, []db.CreateMeasurementsParams{
{
ReadingID: reading.ID,
Meter: resource.Meter,
ResourceNaturalID: resource.NaturalID,
Value: 1,
},
})
if err != nil {
http.Error(w, "creating measurement: "+err.Error(), http.StatusInternalServerError)
return
}
_, _ = io.WriteString(w, "Created reading.\n")
})
}

func pgxUUID(s string) pgtype.UUID {
u := pgtype.UUID{}
u.Scan(s)
return u
}
Loading