Skip to content

Commit 76d64af

Browse files
authored
Merge pull request #57 from Prescott-Data/refactor/issue-48-service-layer
Refactor/issue 48 service layer
2 parents 23a2c91 + 92cc32e commit 76d64af

17 files changed

Lines changed: 1799 additions & 1341 deletions

File tree

nexus-broker/cmd/nexus-broker/main.go

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"time"
88

99
"github.com/Prescott-Data/nexus-framework/nexus-broker/internal/audit"
10+
"github.com/Prescott-Data/nexus-framework/nexus-broker/internal/repository/postgres"
11+
"github.com/Prescott-Data/nexus-framework/nexus-broker/internal/service"
1012
"github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/caching"
1113
"github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/config"
1214
"github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/handlers"
@@ -62,25 +64,30 @@ func main() {
6264
auditSvc := audit.NewService(db)
6365

6466
providersHandler := handlers.NewProvidersHandler(store, auditSvc)
67+
68+
connRepo := postgres.NewConnectionRepository(db)
69+
tokenRepo := postgres.NewTokenRepository(db)
70+
71+
connSvc := service.NewConnectionService(
72+
connRepo,
73+
tokenRepo,
74+
store,
75+
auditSvc,
76+
cfg.BaseURL,
77+
cfg.RedirectPath,
78+
cfg.EncryptionKey,
79+
cfg.StateKey,
80+
cachingClient,
81+
cfg.EnforceReturnURL,
82+
cfg.AllowedReturnDomains,
83+
)
84+
6585
consentHandler := handlers.NewConsentHandler(handlers.ConsentHandlerConfig{
66-
DB: db,
67-
BaseURL: cfg.BaseURL,
68-
RedirectPath: cfg.RedirectPath,
69-
StateKey: cfg.StateKey,
70-
HTTPClient: cachingClient,
71-
EnforceReturnURL: cfg.EnforceReturnURL,
72-
AllowedReturnDomains: cfg.AllowedReturnDomains,
86+
Service: connSvc,
7387
})
7488
callbackHandler := handlers.NewCallbackHandler(handlers.CallbackHandlerConfig{
75-
DB: db,
76-
Audit: auditSvc,
77-
BaseURL: cfg.BaseURL,
78-
RedirectPath: cfg.RedirectPath,
79-
EncryptionKey: cfg.EncryptionKey,
80-
StateKey: cfg.StateKey,
81-
HTTPClient: cachingClient,
82-
EnforceReturnURL: cfg.EnforceReturnURL,
83-
AllowedReturnDomains: cfg.AllowedReturnDomains,
89+
Service: connSvc,
90+
Audit: auditSvc,
8491
})
8592
auditHandler := handlers.NewAuditHandler(db)
8693

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package architecture_test
2+
3+
import (
4+
"go/parser"
5+
"go/token"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// Rule defines an architectural constraint
13+
type Rule struct {
14+
Package string
15+
ShouldNot []string // Substrings of import paths that are forbidden
16+
Description string
17+
}
18+
19+
func TestSeparationOfConcerns(t *testing.T) {
20+
modulePrefix := "github.com/Prescott-Data/nexus-framework/nexus-broker"
21+
22+
rules := []Rule{
23+
{
24+
Package: "internal/domain",
25+
ShouldNot: []string{
26+
"net/http",
27+
"github.com/jmoiron/sqlx",
28+
"github.com/lib/pq",
29+
modulePrefix + "/pkg/handlers",
30+
modulePrefix + "/internal/service",
31+
modulePrefix + "/internal/repository",
32+
},
33+
Description: "Domain models must be pure and ignorant of HTTP, database drivers, and other layers.",
34+
},
35+
{
36+
Package: "internal/repository",
37+
ShouldNot: []string{
38+
"net/http",
39+
modulePrefix + "/pkg/handlers",
40+
modulePrefix + "/internal/service",
41+
},
42+
Description: "Repositories must not depend on HTTP handlers or business logic services.",
43+
},
44+
{
45+
Package: "internal/service",
46+
ShouldNot: []string{
47+
modulePrefix + "/pkg/handlers",
48+
"github.com/jmoiron/sqlx",
49+
},
50+
Description: "Services must not depend on HTTP handlers or raw SQL drivers (use repositories instead).",
51+
},
52+
{
53+
Package: "pkg/handlers",
54+
ShouldNot: []string{
55+
modulePrefix + "/internal/repository",
56+
},
57+
Description: "HTTP Handlers must not bypass the Service layer to talk directly to Repositories.",
58+
},
59+
}
60+
61+
basePath := ".." // We are inside internal, so .. is the broker root
62+
63+
for _, rule := range rules {
64+
t.Run(rule.Package, func(t *testing.T) {
65+
targetDir := filepath.Join(basePath, rule.Package)
66+
67+
// Walk the target directory
68+
err := filepath.Walk(targetDir, func(path string, info os.FileInfo, err error) error {
69+
if err != nil {
70+
return err
71+
}
72+
73+
// Only analyze Go files, skip tests
74+
if info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
75+
return nil
76+
}
77+
78+
fset := token.NewFileSet()
79+
node, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
80+
if err != nil {
81+
t.Fatalf("failed to parse %s: %v", path, err)
82+
}
83+
84+
for _, imp := range node.Imports {
85+
importPath := strings.Trim(imp.Path.Value, `"`)
86+
87+
for _, forbidden := range rule.ShouldNot {
88+
if strings.Contains(importPath, forbidden) {
89+
t.Errorf("\nViolation in %s\nRule: %s\nForbidden Import Found: %s",
90+
path, rule.Description, importPath)
91+
}
92+
}
93+
}
94+
return nil
95+
})
96+
97+
if err != nil {
98+
// If the directory doesn't exist yet, we just skip (e.g. if we are running from a different root)
99+
if os.IsNotExist(err) {
100+
t.Logf("Directory %s does not exist, skipping.", targetDir)
101+
} else {
102+
t.Fatalf("error walking directory %s: %v", targetDir, err)
103+
}
104+
}
105+
})
106+
}
107+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package domain
2+
3+
import (
4+
"database/sql"
5+
"encoding/json"
6+
"time"
7+
8+
"github.com/google/uuid"
9+
)
10+
11+
// Connection represents a user's connection to a provider
12+
type Connection struct {
13+
ID uuid.UUID
14+
WorkspaceID string
15+
ProviderID uuid.UUID
16+
CodeVerifier sql.NullString
17+
Scopes []string
18+
ReturnURL string
19+
Status string
20+
ExpiresAt time.Time
21+
}
22+
23+
// ConnectionWithProvider joins connection and basic provider info
24+
type ConnectionWithProvider struct {
25+
Connection
26+
AuthType string
27+
AuthHeader string
28+
APIBaseURL string
29+
UserInfoEndpoint string
30+
ProviderParams *json.RawMessage
31+
}
32+
33+
// Token represents an encrypted token at rest
34+
type Token struct {
35+
ConnectionID uuid.UUID
36+
EncryptedData string
37+
ExpiresAt *time.Time
38+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package repository
2+
3+
import (
4+
"context"
5+
6+
"github.com/google/uuid"
7+
"github.com/Prescott-Data/nexus-framework/nexus-broker/internal/domain"
8+
)
9+
10+
// ConnectionRepository handles database operations for connections
11+
type ConnectionRepository interface {
12+
Create(ctx context.Context, conn *domain.Connection) error
13+
GetPending(ctx context.Context, id uuid.UUID) (*domain.Connection, error)
14+
GetWithProvider(ctx context.Context, id uuid.UUID) (*domain.ConnectionWithProvider, error)
15+
GetReturnURL(ctx context.Context, id uuid.UUID) (string, error)
16+
UpdateStatus(ctx context.Context, id uuid.UUID, status string) error
17+
}
18+
19+
// TokenRepository handles database operations for tokens
20+
type TokenRepository interface {
21+
Upsert(ctx context.Context, token *domain.Token) error
22+
Get(ctx context.Context, connectionID uuid.UUID) (*domain.Token, error)
23+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package postgres
2+
3+
import (
4+
"context"
5+
6+
"github.com/google/uuid"
7+
"github.com/jmoiron/sqlx"
8+
"github.com/lib/pq"
9+
"github.com/Prescott-Data/nexus-framework/nexus-broker/internal/domain"
10+
"github.com/Prescott-Data/nexus-framework/nexus-broker/internal/repository"
11+
)
12+
13+
type connectionRepository struct {
14+
db *sqlx.DB
15+
}
16+
17+
// NewConnectionRepository creates a new Postgres ConnectionRepository
18+
func NewConnectionRepository(db *sqlx.DB) repository.ConnectionRepository {
19+
return &connectionRepository{db: db}
20+
}
21+
22+
func (r *connectionRepository) Create(ctx context.Context, conn *domain.Connection) error {
23+
_, err := r.db.ExecContext(ctx, `
24+
INSERT INTO connections (id, workspace_id, provider_id, code_verifier, scopes, return_url, expires_at)
25+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
26+
conn.ID, conn.WorkspaceID, conn.ProviderID, conn.CodeVerifier, pq.Array(conn.Scopes), conn.ReturnURL, conn.ExpiresAt)
27+
return err
28+
}
29+
30+
func (r *connectionRepository) GetPending(ctx context.Context, id uuid.UUID) (*domain.Connection, error) {
31+
var conn domain.Connection
32+
err := r.db.QueryRowContext(ctx, `
33+
SELECT id, code_verifier, return_url, provider_id, scopes
34+
FROM connections
35+
WHERE id = $1 AND status = 'pending' AND expires_at > NOW()`, id).
36+
Scan(&conn.ID, &conn.CodeVerifier, &conn.ReturnURL, &conn.ProviderID, pq.Array(&conn.Scopes))
37+
if err != nil {
38+
return nil, err
39+
}
40+
return &conn, nil
41+
}
42+
43+
func (r *connectionRepository) GetWithProvider(ctx context.Context, id uuid.UUID) (*domain.ConnectionWithProvider, error) {
44+
var conn domain.ConnectionWithProvider
45+
err := r.db.QueryRowContext(ctx, `
46+
SELECT c.id, c.provider_id, c.status, c.scopes, c.return_url,
47+
p.auth_type, COALESCE(p.auth_header, ''), COALESCE(p.api_base_url, ''), COALESCE(p.user_info_endpoint, ''), p.params
48+
FROM connections c
49+
JOIN provider_profiles p ON p.id = c.provider_id
50+
WHERE c.id = $1`, id).
51+
Scan(&conn.ID, &conn.ProviderID, &conn.Status, pq.Array(&conn.Scopes), &conn.ReturnURL,
52+
&conn.AuthType, &conn.AuthHeader, &conn.APIBaseURL, &conn.UserInfoEndpoint, &conn.ProviderParams)
53+
if err != nil {
54+
return nil, err
55+
}
56+
return &conn, nil
57+
}
58+
59+
func (r *connectionRepository) GetReturnURL(ctx context.Context, id uuid.UUID) (string, error) {
60+
var returnURL string
61+
err := r.db.QueryRowContext(ctx, "SELECT return_url FROM connections WHERE id = $1", id).Scan(&returnURL)
62+
return returnURL, err
63+
}
64+
65+
func (r *connectionRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status string) error {
66+
_, err := r.db.ExecContext(ctx, "UPDATE connections SET status = $1, updated_at = NOW() WHERE id = $2", status, id)
67+
return err
68+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package postgres
2+
3+
import (
4+
"context"
5+
6+
"github.com/google/uuid"
7+
"github.com/jmoiron/sqlx"
8+
"github.com/Prescott-Data/nexus-framework/nexus-broker/internal/domain"
9+
"github.com/Prescott-Data/nexus-framework/nexus-broker/internal/repository"
10+
)
11+
12+
type tokenRepository struct {
13+
db *sqlx.DB
14+
}
15+
16+
// NewTokenRepository creates a new Postgres TokenRepository
17+
func NewTokenRepository(db *sqlx.DB) repository.TokenRepository {
18+
return &tokenRepository{db: db}
19+
}
20+
21+
func (r *tokenRepository) Upsert(ctx context.Context, token *domain.Token) error {
22+
_, err := r.db.ExecContext(ctx, `
23+
INSERT INTO tokens (connection_id, encrypted_data, expires_at)
24+
VALUES ($1, $2, $3)
25+
ON CONFLICT (connection_id)
26+
DO UPDATE SET
27+
encrypted_data = EXCLUDED.encrypted_data,
28+
expires_at = EXCLUDED.expires_at,
29+
created_at = NOW()`,
30+
token.ConnectionID, token.EncryptedData, token.ExpiresAt)
31+
return err
32+
}
33+
34+
func (r *tokenRepository) Get(ctx context.Context, connectionID uuid.UUID) (*domain.Token, error) {
35+
var token domain.Token
36+
err := r.db.QueryRowContext(ctx, "SELECT encrypted_data, expires_at FROM tokens WHERE connection_id = $1", connectionID).
37+
Scan(&token.EncryptedData, &token.ExpiresAt)
38+
if err != nil {
39+
return nil, err
40+
}
41+
token.ConnectionID = connectionID
42+
return &token, nil
43+
}

0 commit comments

Comments
 (0)