DevUtilityHub — Technical Reference
Architecture, API contracts, frontend structure, testing strategy, and deployment pipeline.
This is the technical companion to the Functional Reference. It covers the architecture, every API contract, the frontend structure, testing approach, and the CI/CD pipeline that ships both services.
Architecture
Controllerroute · parse · delegateServiceorchestrate · validate · build responseCorepure algorithms · no HTTP · no DIThe backend follows a strict three-layer pattern. Each layer has one job and no knowledge of the layers above it.
| Layer | Location | Responsibility |
|---|---|---|
| Controller | Controllers/ | Parse the HTTP request, delegate to a service, return the HTTP response. No logic. |
| Service | Services/ | Orchestrate Core calls, validate input, construct the response DTO. Registered as Scoped via DI. |
| Core | Core/ | Pure algorithm implementations. No HTTP context, no DI, no external state. Freely unit-testable. |
| Models | Models/ | Request and response DTOs only. No domain logic or validation attributes. |
| Middleware | Middleware/ | Cross-cutting concerns. ExceptionMiddleware catches all unhandled exceptions and returns a consistent JSON error. |
Technology Stack
Backend
ASP.NET Core Web API — .NET 10
| Concern | Library / approach |
|---|---|
| Framework | .NET 10 · ASP.NET Core Web API |
| Language | C# 13 with nullable reference types and implicit usings enabled |
| Text diffing | DiffPlex 1.9.0 |
| Date parsing | Microsoft.Recognizers.Text.DateTime 1.8.13 |
| API docs | Swashbuckle.AspNetCore 10.1.7 + Microsoft.AspNetCore.OpenApi 10.0.5 |
| Testing | xUnit 2.9.3 · Coverlet 6.0.4 |
| JSON | System.Text.Json (in-box, no Newtonsoft) |
| XML | .NET XmlDocument / XDocument |
| Persistence | None — fully stateless |
Frontend
React 19 · TypeScript · Vite
| Concern | Library / version |
|---|---|
| UI framework | React 19.2.4 |
| Language | TypeScript 6.0.2 · strict mode |
| Build tool | Vite 8.0.4 |
| Routing | React Router 7.14.0 |
| HTTP client | Axios 1.15.0 |
| CSS | Tailwind CSS v4 · custom CSS property design system |
| Font | JetBrains Mono (Google Fonts) |
| Linting | ESLint 9.39.4 · typescript-eslint 8.58.0 |
API Reference
All endpoints accept and return application/json. Every response body includes two
common fields:
isValid: boolean— whether the operation succeedederrorMessage: string | null— parse or validation error detail;nullon success
A 500 response always returns { "error": "An unexpected error occurred." }.
The detail field is only appended in the Development environment.
The max request body size is 1 MB. Larger payloads receive a 413.
Health check
/healthLiveness probeNo request body. Used by the frontend’s ApiStatusContext on mount and by Azure App
Service health probes.
{ "status": "ok" }
Format / Minify / Validate
/api/format/processJSON · XML formatter▸ Request
{
"input": "string — text to process",
"action": "\"format\" | \"minify\" | \"validate\"",
"overrideType": "\"json\" | \"xml\" | \"plain\" (optional)"
}▸ Response
{
"output": "string — transformed text (same as input for validate)",
"detectedType": "\"json\" | \"xml\" | \"plain\"",
"isValid": "boolean",
"errorMessage": "string | null"
}Detection order: JSON parse → XML parse → plain text fallback. overrideType skips
this sequence entirely.
Text Transform
/api/text/transformPipeline of text operations▸ Request
{
"input": "string",
"operations": ["trim", "uppercase", "sort"]
}▸ Response
{
"output": "string",
"appliedOperations": ["trim", "uppercase", "sort"],
"isValid": "boolean",
"errorMessage": "string | null"
}Operations run sequentially in the order provided. appliedOperations in the response
reflects exactly what was executed — the UI displays this as a pipeline tag list.
Supported operation values
| Value | Scope | Notes |
|---|---|---|
| uppercase | full text | |
| lowercase | full text | |
| titlecase | full text | |
| camelcase | full text | |
| snakecase | full text | |
| kebabcase | full text | |
| trim | per line | |
| sort | per line | Case-sensitive |
| dedup | per line | Preserves order of first occurrence |
| reverse | per line | Reverses line order, not character order |
| count | per line | Returns a bare integer string, e.g. "42" |
Diff
/api/diff/compareLine-level text diff▸ Request
{
"textA": "string — original",
"textB": "string — modified"
}▸ Response
{
"lines": [
{
"type": "\"added\" | \"removed\" | \"unchanged\"",
"content": "string",
"lineNumber": 1
}
],
"addedCount": "integer",
"removedCount": "integer",
"isValid": "boolean",
"errorMessage": "string | null"
}Powered by DiffPlex (InlineDiffBuilder in word-diff mode, line granularity).
lineNumber is 1-indexed and represents position in the diff output, not in either
source text. Empty inputs return an empty lines array with counts of 0.
Timestamp Convert
/api/time/convertUnix ↔ human-readable▸ Request
{
"direction": "\"toHuman\" | \"toUnix\"",
"unixValue": "integer | null — required when direction = \"toHuman\"",
"isMilliseconds": "boolean — true if unixValue is in ms (default: false)",
"humanValue": "string | null — required when direction = \"toUnix\"",
"timeZoneId": "string | null — IANA timezone ID, e.g. \"America/New_York\""
}▸ Response
{
"seconds": "integer",
"ms": "integer",
"utc": "string — RFC 2822, e.g. \"Wed, 15 Nov 2023 06:13:20 GMT\"",
"iso": "string — ISO 8601, e.g. \"2023-11-15T06:13:20.0000000Z\"",
"local": "string | null — offset-aware, null if no timeZoneId given",
"isValid": "boolean",
"errorMessage": "string | null"
}humanValue accepts: ISO 8601, YYYY-MM-DD HH:mm, YYYY-MM-DD, MM/DD/YYYY,
the shorthands now / today / yesterday / tomorrow, and natural-language
expressions via Microsoft.Recognizers.Text.DateTime (e.g. "next Monday", "3 days ago").
Encode / Decode
/api/encode/processBase64 · URL · HTML (stub)▸ Request (planned)
{
"input": "string",
"operation": "\"encode\" | \"decode\"",
"encoding": "\"base64\" | \"url\" | \"html\""
}Frontend Architecture
Pages and routes
| Route | Component | API call |
|---|---|---|
| / | HomePage | GET /health (via ApiStatusContext) |
| /formatter | FormatterPage | POST /api/format/process |
| /text | TextToolsPage | POST /api/text/transform |
| /diff | DiffPage | POST /api/diff/compare |
| /time | TimePage | POST /api/time/convert |
| /encode | EncodingPage | POST /api/encode/process |
useApi<T> — shared async state hook
Every page goes through this single hook:
const { data, loading, error, call, reset } = useApi<DiffResponse>();
// Trigger a call:
await call(() => diffApi.compare({ textA, textB }));
call sets loading: true, awaits the promise, then sets either data or error.
It also notifies ApiStatusContext of the outcome so the connection indicator in the
header stays current.
ApiStatusContext
Global API connection state machine
| State | Meaning |
|---|---|
| idle | No call has been made yet |
| checking | Health probe in flight (on mount) |
| ok | Last request returned a 2xx response |
| network-error | Request received no response (API unreachable) |
| server-error | Request returned a 5xx response |
API client modules
Each tool has a dedicated module in src/api/. All share one Axios instance configured
in src/api/client.ts:
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
headers: { "Content-Type": "application/json" },
});
VITE_API_BASE_URL is the only environment variable the frontend needs.
Data Flow
A worked example — user formats a JSON string:
User types JSON → clicks PRETTIFY
│
▼
FormatterPage.tsx
collects { input, action: "format" }
calls formatApi.process(request)
│
▼
src/api/client.ts [Axios]
POST /api/format/process → FormatController.cs
│
▼
FormatController.cs
[HttpPost("process")]
delegates to _formatService.Process(request)
│
▼
FormatService.cs
InputTypeDetector.Detect(input) → "json"
JsonFormatter.Format(input) → pretty-printed string
returns FormatResponse { output, detectedType: "json", isValid: true }
│
▼
HTTP 200 { output: "...", detectedType: "json", isValid: true }
│
▼
useApi sets data → FormatterPage renders output in the right pane
Configuration & Environment
Backend
No secrets in config files. All configuration is through environment variables set on the host or in Azure App Service application settings.
| Variable | Where set | Effect |
|---|---|---|
| ASPNETCORE_ENVIRONMENT | Host / Azure config | "Development" enables Swagger UI and includes exception detail in 500 responses |
CORS allowed origins are hardcoded in Program.cs:
https://devtoolkit.nikkapaola.com(production frontend)http://localhost:5173(local Vite dev server)
Frontend
| Variable | Required | Where set |
|---|---|---|
| VITE_API_BASE_URL | Yes | Local: .env file · Production: Cloudflare Pages environment variable injected at build time |
Testing
Unit tests target the Core layer directly. No HTTP context, no DI container, no mocks — just function calls.
The test project at backend/DevUtilityHub.Tests/ mirrors the Core module structure.
Services are also tested where orchestration logic warrants it.
cd backend
dotnet test
Test coverage by file
| File | Target | Scope |
|---|---|---|
| FormatServiceTests.cs | FormatService | 30+ cases — JSON/XML format, minify, validate, malformed input, detection override |
| TextTransformServiceTests.cs | TextTransformService | All 11 operations individually and in pipeline combinations |
| DiffServiceTests.cs | DiffService | Empty inputs, identical texts, single-change diffs, multi-line diffs |
| TimestampServiceTests.cs | TimestampService | Unix → human and human → Unix in both directions, timezone offsets |
| TimestampConverterTests.cs | TimestampConverter | Shorthands (now/today), natural language strings, IANA timezone resolution |
CI/CD & Deployment
Two independent GitHub Actions workflows, each scoped to its own directory path. Neither workflow runs if only the other service changed.
Frontend → Cloudflare Pages
Workflow: .github/workflows/deploy-frontend.yml
Trigger: Push to main with changes in frontend/**, or manual dispatch.
1. Checkout
2. Node 20 setup (npm cache)
3. npm ci
4. npm run build ← VITE_API_BASE_URL injected from GitHub secret
5. cloudflare/pages-action@v1 → deploys dist/
Required GitHub secrets
| Secret | Purpose |
|---|---|
| VITE_API_BASE_URL | Production backend URL baked into the JS bundle at build time |
| CLOUDFLARE_API_TOKEN | Cloudflare API access |
| CLOUDFLARE_ACCOUNT_ID | Cloudflare account identifier |
| CF_PAGES_PROJECT_NAME | Cloudflare Pages project name |
Backend → Azure App Service
Workflow: .github/workflows/deploy-backend.yml
Trigger: Push to main with changes in backend/**, or manual dispatch.
Two jobs run sequentially:
Job 1: build-and-test
dotnet restore
dotnet build --configuration Release
dotnet test
Job 2: build-and-deploy (only runs if tests pass)
dotnet publish --configuration Release --runtime linux-x64 --self-contained
azure/login@v2
azure/webapps-deploy@v3 → App Service: dev-utility-hub-api
The publish is --self-contained so no .NET runtime is required on the host. The
App Service plan is Azure Free F1 — this is why Docker is not used in production;
F1 does not support container deployment.
Required GitHub secrets
| Secret | Purpose |
|---|---|
| AZURE_CREDENTIALS | Service principal JSON used by azure/login@v2 |
Docker
Docker is available for local development only. The Dockerfile lives at
backend/DevUtilityHub.Api/Dockerfile.
Dockerfile build stages
| Stage | Base image | Purpose |
|---|---|---|
| base | mcr.microsoft.com/dotnet/aspnet:10.0 | Runtime-only base; exposes ports 8080 and 8081 |
| build | mcr.microsoft.com/dotnet/sdk:10.0 | Restore NuGet packages and build in Release config |
| publish | build | dotnet publish with UseAppHost=false |
| final | base | Production image — copies published output, sets entrypoint |
| debug | base | Installs curl + vsdbg; builds in Debug; exposes port 4024 for VS Code remote debugger |
# Build and run production image locally
docker build -t devutilityhub-api ./backend/DevUtilityHub.Api
docker run -p 5294:8080 -e ASPNETCORE_ENVIRONMENT=Development devutilityhub-api
A docker-compose.debug.yml at the repo root maps localhost:5294 → 8080 (API) and
localhost:4024 → 4024 (VS Code remote debugger) for integrated debugging:
docker compose -f docker-compose.debug.yml up