Skip to content
Open
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
38 changes: 38 additions & 0 deletions .github/workflows/pnpm-audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: pnpm audit

on:
workflow_dispatch:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- package.json
- pnpm-lock.yaml

permissions:
contents: read

jobs:
audit:
name: audit
runs-on: self-hosted
steps:
- name: Fetch Repository
uses: actions/checkout@v6

- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10.32.1
run_install: false

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "lts/*"

- name: Run pnpm audit
run: pnpm audit --audit-level low
21 changes: 20 additions & 1 deletion api-spec/v0/dist/openapi.bundled.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@
"responses": {
"200": {
"description": "Education enrollment status retrieved successfully.",
"headers": {
"X-Request-ID": {
"$ref": "#/components/headers/X-Request-ID"
}
},
"content": {
"application/json": {
"schema": {
Expand Down Expand Up @@ -104,6 +109,11 @@
"responses": {
"200": {
"description": "Veteran disability status retrieved successfully.",
"headers": {
"X-Request-ID": {
"$ref": "#/components/headers/X-Request-ID"
}
},
"content": {
"application/json": {
"schema": {
Expand Down Expand Up @@ -134,6 +144,15 @@
}
}
},
"headers": {
"X-Request-ID": {
"description": "Unique identifier for the request, used for tracing and debugging. This ID is generated by the server and returned in the response.\n",
"schema": {
"type": "string",
"format": "uuid"
}
}
},
"schemas": {
"GetEducationEnrollmentRequest": {
"$ref": "#/components/schemas/identity.schema"
Expand Down Expand Up @@ -359,4 +378,4 @@
}
}
}
}
}
14 changes: 14 additions & 0 deletions api-spec/v0/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ paths:
responses:
"200":
description: Education enrollment status retrieved successfully.
headers:
X-Request-ID:
$ref: "#/components/headers/X-Request-ID"
content:
application/json:
schema:
Expand Down Expand Up @@ -89,6 +92,9 @@ paths:
responses:
"200":
description: Veteran disability status retrieved successfully.
headers:
X-Request-ID:
$ref: "#/components/headers/X-Request-ID"
content:
application/json:
schema:
Expand All @@ -108,6 +114,14 @@ components:
clientCredentials:
tokenUrl: https://api.dev.emmy.cms.gov/oauth2/token
scopes: {}
headers:
X-Request-ID:
description: >
Unique identifier for the request, used for tracing and debugging.
This ID is generated by the server and returned in the response.
schema:
type: string
format: uuid
schemas:
GetEducationEnrollmentRequest:
$ref: ../../schema/v0/identity.schema.json
Expand Down
41 changes: 30 additions & 11 deletions api/app.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"context"
"errors"
"log/slog"
"runtime/debug"
Expand All @@ -17,17 +18,32 @@ import (
"github.com/gofiber/fiber/v2/middleware/recover"
slogfiber "github.com/samber/slog-fiber"

"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)

func RequestIDToUserContext() fiber.Handler {
return func(c *fiber.Ctx) error {
rid := c.Get(fiber.HeaderXRequestID)
if rid == "" {
rid = uuid.NewString()
}

ctx := context.WithValue(c.UserContext(), core.RequestContextKey, rid)
c.SetUserContext(ctx)

return c.Next()
}
}

func errorHandler(logger *slog.Logger, otel core.OtelService) fiber.ErrorHandler {
handleFiberError := func(ctx *fiber.Ctx, err *fiber.Error) error {
span := otel.SpanFromContext(ctx.Context())
span := otel.SpanFromContext(ctx.UserContext())
span.RecordError(err)
span.SetStatus(codes.Error, err.Message)

logger.ErrorContext(
ctx.Context(),
ctx.UserContext(),
"fiber error",
"code", err.Code,
"message", err.Message,
Expand All @@ -51,7 +67,7 @@ func stackTraceHandler(logger *slog.Logger) func(*fiber.Ctx, any) {
return func(c *fiber.Ctx, e any) {
stack := debug.Stack()
logger.ErrorContext(
c.Context(),
c.UserContext(),
"panic!",
"stack", stack,
"err", e,
Expand Down Expand Up @@ -80,6 +96,17 @@ func New(cfg *Config) (*fiber.App, error) {

app := fiber.New(fiberConfig)

app.Use(RequestIDToUserContext())

app.Use(slogfiber.NewWithConfig(
cfg.Logger,
slogfiber.Config{
WithRequestID: true,
WithSpanID: true,
WithTraceID: true,
},
))

app.Use(recover.New(recover.Config{
EnableStackTrace: true,
StackTraceHandler: stackTraceHandler(cfg.Logger),
Expand All @@ -93,14 +120,6 @@ func New(cfg *Config) (*fiber.App, error) {

app.Use(otelfiber.Middleware())

app.Use(slogfiber.NewWithConfig(
cfg.Logger,
slogfiber.Config{
WithRequestID: true,
WithSpanID: true,
WithTraceID: true,
},
))

routes.StatusRouter(app, cfg.Core, cfg.Redis, logger)

Expand Down
10 changes: 5 additions & 5 deletions docs/guides/01-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ The Emmy API is a CMS-provided secure service that enables you to connect to and

You must be able to:

| Requirement | Details |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Obtain Credentials | You must have worked with the CMS Emmy team to get onboarded and receive your API client ID and secret. |
| Access Emmy API Endpoints | The onboarding process will have provided you the endpoint URL. You must have outbound network/firewall access to this host. |
| Make HTTP POST (REST) Calls | Your system (or testing tool) must be capable of making HTTP POST calls where you can supply specific headers in the request. |
| Requirement | Details |
| --------------------------- |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Obtain Credentials | Reach out to [emmy@cms.hhs.gov](emmy@cms.hhs.gov), requesting a sandbox credential and working with the CMS emmy team to get onboarded and receive your client ID and secret via encrypted channel. |
| Access Emmy API Endpoints | The onboarding process will have provided you the endpoint URL. You must have outbound network/firewall access to this host. |
| Make HTTP POST (REST) Calls | Your system (or testing tool) must be capable of making HTTP POST calls where you can supply specific headers in the request. |

When you onboard with the CMS Emmy team, you will obtain credentials. You will use the values you received in this guide:

Expand Down
9 changes: 0 additions & 9 deletions docs/guides/02-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,3 @@ curl --location --request POST '<API_BASE>/api/v0/education-enrollments' \
"dateOfBirth": "1988-10-24"
}'
```

## VA Upstream Token Flow

The Emmy API examples above describe Emmy's own bearer-token usage. If you are
working with VA's Veteran Service History and Eligibility sandbox credentials,
VA uses a different client-credentials pattern: you sign a JWT with your RSA
private PEM and send it as `client_assertion`.

See [Getting a VA Access Token with a Client ID and PEM Key](04-va-veteran-verification-token.md).
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ depends = ["bundle-api-spec", "validate-api-spec", "lint-api-spec"]
description = "Build the Go server"
run = "go build -o bin/apiserver ."

[tasks.run]
[tasks.serve]
description = "Run the Go server locally"
depends = ["build"]
run = "bin/apiserver"
Expand Down
13 changes: 10 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,22 @@
"@cspell/dict-software-terms": "^5.2.2",
"@cspell/dict-sql": "^2.2.1",
"@cspell/dict-typescript": "^3.2.3",
"@redocly/cli": "^1.34.5",
"@redocly/cli": "^2.25.4",
"@stoplight/spectral-cli": "^6.15.0",
"ajv": "^8.18.0",
"ajv-cli": "^5.0.0",
"ajv-formats": "^3.0.1",
"cspell": "^9.7.0",
"glob": "^13.0.6",
"markdownlint-cli2": "^0.22.0",
"prettier": "^3.6.2",
"prettier": "^3.8.1",
"typescript": "^6.0.2"
},
"pnpm": {
"overrides": {
"markdownlint-cli2>smol-toml": "1.6.1",
"@stoplight/spectral-ruleset-bundler>rollup": "2.80.0",
"@stoplight/spectral-core>minimatch": "3.1.5",
"lodash": "4.18.0"
}
}
}
27 changes: 25 additions & 2 deletions pkg/core/logger.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,41 @@
package core

import (
"context"
"log/slog"
"os"

slogmulti "github.com/samber/slog-multi"
"go.opentelemetry.io/contrib/bridges/otelslog"
)

type contextHandler struct {
slog.Handler
}

func (h *contextHandler) Handle(ctx context.Context, r slog.Record) error {
if rid, ok := ctx.Value(RequestContextKey).(string); ok {
r.AddAttrs(slog.String("request_id", rid))
}
return h.Handler.Handle(ctx, r)
}

func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &contextHandler{Handler: h.Handler.WithAttrs(attrs)}
}

func (h *contextHandler) WithGroup(name string) slog.Handler {
return &contextHandler{Handler: h.Handler.WithGroup(name)}
}

func newStdoutHandler(cfg *Config) slog.Handler {
var handler slog.Handler
if cfg.IsProd() {
return slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{})
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{})
} else {
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})
}
return slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})
return &contextHandler{Handler: handler}
}

func NewLogger(cfg *Config) *slog.Logger {
Expand Down
69 changes: 69 additions & 0 deletions pkg/core/logger_context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package core

import (
"bytes"
"context"
"encoding/json"
"log/slog"
"testing"
)

func TestContextHandler(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, nil)
ctxHandler := &contextHandler{Handler: handler}
logger := slog.New(ctxHandler)

t.Run("with request id", func(t *testing.T) {
buf.Reset()
rid := "test-request-id"
ctx := context.WithValue(context.Background(), RequestContextKey, rid)
logger.InfoContext(ctx, "test message")

var logRecord map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &logRecord); err != nil {
t.Fatalf("failed to unmarshal log record: %v", err)
}

if logRecord["request_id"] != rid {
t.Errorf("expected request_id %s, got %v", rid, logRecord["request_id"])
}
if logRecord["msg"] != "test message" {
t.Errorf("expected msg 'test message', got %v", logRecord["msg"])
}
})

t.Run("with request id and WithAttrs", func(t *testing.T) {
buf.Reset()
rid := "test-request-id-with-attrs"
ctx := context.WithValue(context.Background(), RequestContextKey, rid)
loggerWith := logger.With(slog.String("foo", "bar"))
loggerWith.InfoContext(ctx, "test message")

var logRecord map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &logRecord); err != nil {
t.Fatalf("failed to unmarshal log record: %v", err)
}

if logRecord["request_id"] != rid {
t.Errorf("expected request_id %s, got %v", rid, logRecord["request_id"])
}
if logRecord["foo"] != "bar" {
t.Errorf("expected foo 'bar', got %v", logRecord["foo"])
}
})

t.Run("without request id", func(t *testing.T) {
buf.Reset()
logger.InfoContext(context.Background(), "test message")

var logRecord map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &logRecord); err != nil {
t.Fatalf("failed to unmarshal log record: %v", err)
}

if _, ok := logRecord["request_id"]; ok {
t.Error("expected no request_id in log record")
}
})
}
6 changes: 6 additions & 0 deletions pkg/core/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,9 @@ type Config struct {
NSC NSCConfig
VA VAConfig
}

type ctxKey int

const (
RequestContextKey ctxKey = iota
)
Loading
Loading