Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4d301ea
chore: fix up documentation
superlinkx Jun 18, 2026
d09e2f4
feat: scaffold appcfg module directory structure
superlinkx Jun 18, 2026
642a44d
test: add e2e test for GET /api/v2/datapipe/status
superlinkx Jun 18, 2026
0620259
feat: implement persistence layer for datapipe status
superlinkx Jun 18, 2026
41dadf9
feat: add service layer with unit tests
superlinkx Jun 18, 2026
4632a3b
feat: add service layer with unit tests using mockery
superlinkx Jun 18, 2026
8a6955b
test: enhance e2e test to cover more contracts
superlinkx Jun 18, 2026
3f55c8d
test: add full routing e2e tests with authentication middleware
superlinkx Jun 18, 2026
b35adb3
test: use production route registration in e2e tests
superlinkx Jun 18, 2026
41e503d
feat: define JSON views for datapipe status handler
superlinkx Jun 18, 2026
9d7b2c9
feat: implement handler layer for datapipe status
superlinkx Jun 18, 2026
a89a2b3
feat: register routes for datapipe status endpoint
superlinkx Jun 18, 2026
307c59b
feat: register appcfg module in modules registry
superlinkx Jun 18, 2026
353b69b
feat: swap e2e test to use new handler stack
superlinkx Jun 18, 2026
de0fb72
feat: remove old datapipe status handler
superlinkx Jun 18, 2026
8589c5a
chore: add license headers to generated mocks and format code
superlinkx Jun 18, 2026
9cd6f53
docs: consolidate implementation steps into canonical checklist
superlinkx Jun 18, 2026
ab27296
docs: move code patterns to reference section in checklist
superlinkx Jun 18, 2026
5534c12
docs: align code patterns with real implementations
superlinkx Jun 18, 2026
d836216
docs: move directory structure before checklist in Step 1
superlinkx Jun 18, 2026
ae3c51d
feat: port improvements from demo branch to BED-8715
superlinkx Jun 18, 2026
91bc504
docs: fix step ordering - services before appdb
superlinkx Jun 18, 2026
99c0050
feat: add comprehensive authentication tests to analysis E2E suite
superlinkx Jun 18, 2026
4945ea3
wip: debug permission loading in analysis E2E tests
superlinkx Jun 18, 2026
49bbb13
fix: set EULAAccepted=true for E2E test users to enable permission ch…
superlinkx Jun 18, 2026
9050a55
refactor: remove unused test helper functions and imports from analys…
superlinkx Jun 18, 2026
2e33982
chore: CR fixes
superlinkx Jun 18, 2026
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
3 changes: 0 additions & 3 deletions cmd/api/src/api/registration/v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,6 @@ func NewV2API(resources v2.Resources, routerInst *router.Router) {
routerInst.GET(fmt.Sprintf("/api/v2/azure-tenants/{%s}/data-quality-stats", api.URIPathVariableTenantID), resources.GetAzureDataQualityStats).RequirePermissions(permissions.GraphDBRead).SupportsETAC(resources.DB, resources.DogTags),
routerInst.GET(fmt.Sprintf("/api/v2/platform/{%s}/data-quality-stats", api.URIPathVariablePlatformID), resources.GetPlatformAggregateStats).RequirePermissions(permissions.GraphDBRead),

// Datapipe API
routerInst.GET("/api/v2/datapipe/status", resources.GetDatapipeStatus).RequireAuth(),

// Custom Node Management
routerInst.GET("/api/v2/custom-nodes", resources.GetCustomNodeKinds).RequireAuth(),
routerInst.GET(fmt.Sprintf("/api/v2/custom-nodes/{%s}", v2.CustomNodeKindParameter), resources.GetCustomNodeKind).RequireAuth(),
Expand Down
123 changes: 78 additions & 45 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ server/
│ └── architecture/ # LikeC4 (C4 model) source for the diagrams above
└── <feature>/ # One directory per vertical feature slice
├── <feature>.go # Register entry point
├── appdb/ # Persistence layer (SQL via go-sqlbuilder + pgx)
├── handlers/ # HTTP layer (handlers, routes, JSON views)
└── services/ # Business-logic layer (domain types, interfaces)
└── internal/ # Internal implementation packages
├── appdb/ # Persistence layer (SQL via go-sqlbuilder + pgx)
├── handlers/ # HTTP layer (handlers, JSON views)
├── routes/ # Route registration
└── services/ # Business-logic layer (domain types, interfaces)
```

Each feature is a self-contained vertical slice. It owns every layer from HTTP to SQL; nothing bleeds across feature boundaries.
Expand Down Expand Up @@ -132,35 +134,59 @@ func Register(routerInst *router.Router, pool *pgxpool.Pool) {
handlerSet = handlers.NewHandlersContainer(svc)
)

handlers.Register(routerInst, handlerSet)
routes.Register(routerInst, handlerSet)
}
```

Each layer receives only the layer below it. Layers never reach across or skip a boundary.

## Adding a new feature module

Follow these steps to add a new feature that fits the same pattern as `analysis`.
To add a new endpoint or migrate an existing one to this architecture, follow the **[Implementation Checklist](implementation_checklist.md)**.

### 1. Create the package tree
The checklist covers:

1. Scaffolding the directory structure
2. Writing e2e tests with production routing
3. Implementing the persistence layer (appdb)
4. Defining domain types and interfaces (services)
5. Defining JSON views (handlers)
6. Implementing HTTP handlers
7. Registering routes
8. Wiring all layers together
9. Adding to the module registry
10. Swapping e2e tests to the new handler
11. Removing old code (for migrations)
12. Preparing for code review

**The sections below provide detailed code examples and architectural context for each layer. Refer to them while following the checklist.**

---

### Package tree structure

```
server/myfeature/
├── myfeature.go # Register entry point
├── appdb/
│ ├── appdb.go # Store struct + methods
│ └── appdb_test.go # Unit tests (pgxmock)
├── handlers/
│ ├── handlers.go # Handlers struct + MyFeature interface
│ ├── handlers_test.go # Unit tests (httptest)
│ ├── routes.go # Register(router, handlers)
│ └── views.go # JSON view types
└── services/
├── services.go # Service struct + domain types + Database interface
└── services_test.go # Unit tests (mock)
└── internal/ # Internal implementation packages
├── appdb/
│ ├── appdb.go # Store struct + methods
│ └── appdb_test.go # Unit tests (pgxmock)
├── handlers/
│ ├── handlers.go # Handlers struct + MyFeature interface
│ ├── handlers_test.go # Unit tests (httptest)
│ └── views.go # JSON view types
├── routes/
│ ├── routes.go # Register(router, handlers)
│ └── routes_test.go # Route registration tests
└── services/
├── services.go # Service struct + domain types + Database interface
└── services_test.go # Unit tests (mock)
```

### 2. Define domain types and interfaces in `services/services.go`
---

### Domain types and interfaces (`internal/services/services.go`)

The services package owns domain types and sentinel errors. The `Database` interface lives here so the persistence layer depends on the consumer (Dependency Inversion). Add `//go:generate go tool mockery` so the mock is regenerated by `just generate`:

Expand All @@ -183,7 +209,9 @@ type Service struct{ db Database }
func NewService(databaseInterface Database) *Service { return &Service{db: databaseInterface} }
```

### 3. Implement persistence in `appdb/appdb.go`
---

### Persistence layer (`internal/appdb/appdb.go`)

Define the minimal `pgxQuerier` interface using only the pgx methods this store actually calls (each appdb package defines its own copy so the abstraction stays scoped to what is exercised here). Always use `sqlbuilder.PostgreSQL` to build queries, `db:` struct tags to map column names, and `pgx.CollectOneRow`/`pgx.RowToStructByName` to scan results. Return services-layer sentinels (not appdb-specific ones) so callers can use `errors.Is` without importing appdb:

Expand Down Expand Up @@ -238,7 +266,9 @@ func (s *Store) GetMyRecord(ctx context.Context, id string) (services.MyRecord,
}
```

### 4. Define JSON views in `handlers/views.go`
---

### JSON views (`internal/handlers/views.go`)

View types decouple the wire format from the domain model — the public API shape can evolve independently of internal domain changes, and vice versa. Each view struct has `json:` tags, a standalone `BuildXxxView` builder function that projects from the domain type, and a `JSONView()` method to satisfy `responses.JSONViewer`:

Expand All @@ -247,7 +277,7 @@ package handlers

import (
"encoding/json"
"github.com/specterops/bloodhound/server/myfeature/services"
"github.com/specterops/bloodhound/server/myfeature/internal/services"
)

// MyRecordView is the JSON shape returned by the handler. It is separate from
Expand All @@ -268,7 +298,9 @@ func (s MyRecordView) JSONView() ([]byte, error) {
}
```

### 5. Define the handler interface and methods in `handlers/handlers.go`
---

### HTTP handlers (`internal/handlers/handlers.go`)

The `MyFeature` interface is defined here (consumer side) to enable independent mock substitution in tests. Add `//go:generate go tool mockery` so the mock is regenerated by `just generate`. Each handler method reads from the request, calls the service, maps known sentinel errors to appropriate HTTP status codes, and uses the `responses` package to write the JSON envelope:

Expand All @@ -281,7 +313,7 @@ import (
"context"
"errors"
"net/http"
"github.com/specterops/bloodhound/server/myfeature/services"
"github.com/specterops/bloodhound/server/myfeature/internal/services"
"github.com/specterops/bloodhound/server/responses"
)

Expand All @@ -307,17 +339,29 @@ func (s Handlers) GetMyRecord(response http.ResponseWriter, request *http.Reques
}
```

### 6. Register routes in `handlers/routes.go`
---

### Route registration (`internal/routes/routes.go`)

```go
func Register(routerInst *router.Router, handlers *Handlers) {
package routes

import (
"github.com/specterops/bloodhound/cmd/api/src/api/router"
"github.com/specterops/bloodhound/cmd/api/src/auth"
"github.com/specterops/bloodhound/server/myfeature/internal/handlers"
)

func Register(routerInst *router.Router, handlers *handlers.Handlers) {
permissions := auth.Permissions()
routerInst.GET("/api/v2/myfeature/:id", handlers.GetMyRecord).
RequirePermissions(permissions.AppReadApplicationConfiguration)
}
```

### 7. Wire all layers in `myfeature/myfeature.go`
---

### Layer wireup (`myfeature/myfeature.go`)

The feature's `Register` accepts only the infrastructure it actually uses (here, the router and pgx pool):

Expand All @@ -327,9 +371,10 @@ package myfeature
import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/specterops/bloodhound/cmd/api/src/api/router"
"github.com/specterops/bloodhound/server/myfeature/appdb"
"github.com/specterops/bloodhound/server/myfeature/handlers"
"github.com/specterops/bloodhound/server/myfeature/services"
"github.com/specterops/bloodhound/server/myfeature/internal/appdb"
"github.com/specterops/bloodhound/server/myfeature/internal/handlers"
"github.com/specterops/bloodhound/server/myfeature/internal/routes"
"github.com/specterops/bloodhound/server/myfeature/internal/services"
)

func Register(routerInst *router.Router, pool *pgxpool.Pool) {
Expand All @@ -339,11 +384,13 @@ func Register(routerInst *router.Router, pool *pgxpool.Pool) {
handlerSet = handlers.NewHandlersContainer(svc)
)

handlers.Register(routerInst, handlerSet)
routes.Register(routerInst, handlerSet)
}
```

### 8. Add to the module registry
---

### Module registry (`server/modules/modules.go`)

In `server/modules/modules.go`, import the new package and call its `Register` from `modules.Register`:

Expand All @@ -361,20 +408,6 @@ func Register(deps Deps) {

If the new feature needs infrastructure that isn't on `Deps` yet (graph database, filesystem, caches, etc.), add the field to the `Deps` struct in `modules.go` and populate it from each entrypoint that calls `modules.Register`.

### 9. Add mock targets to `.mockery.yml` and regenerate

```yaml
packages:
github.com/specterops/bloodhound/server/myfeature/services:
interfaces:
Database:
github.com/specterops/bloodhound/server/myfeature/handlers:
interfaces:
MyFeature:
```

Then run `just generate` to produce the mock files.

## Interface design

Interfaces are **always defined by the consumer**, not the producer:
Expand Down
Loading
Loading