Skip to content
Closed
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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,16 @@ MEDIA_AI_TAGGING=false
# CDN_ENABLED: Enable public API endpoints for CDN delivery (default: true)
# Provides /v1/public/media/* endpoints for headless frontends
CDN_ENABLED=true

# ── GraphQL API (Lighthouse) ──────────────────────
# Cache store for Lighthouse schema/query caching
LIGHTHOUSE_CACHE_STORE=file

# Subscription broadcaster: 'log' for dev, 'pusher' for production
LIGHTHOUSE_SUBSCRIPTION_BROADCASTER=log

# Query complexity limit (default: 500)
GRAPHQL_MAX_COMPLEXITY=500

# Query depth limit (default: 10)
GRAPHQL_MAX_DEPTH=10
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,35 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [0.9.0] — 2026-03-15

### Added

**GraphQL API Layer** ([Discussion #13](https://github.com/byte5digital/numen/discussions/13))

Full-featured GraphQL API powered by Lighthouse PHP, covering all Numen resources with real-time subscriptions, persisted queries, and fine-grained complexity controls.

**Features:**
- **Lighthouse PHP** — production-grade GraphQL server with SDL-first schema definition
- **20+ GraphQL types** — Content, Space, Brief, PipelineRun, PipelineStage, MediaAsset, MediaFolder, MediaCollection, User, Role, Permission, Tag, Persona, RepurposedContent, FormatTemplate, ContentRevision, Setting, AuditLog, and more
- **Cursor pagination** — Relay-spec connection types on all list fields (edges/node/pageInfo)
- **Mutations** — createBrief, createContent, updateContent, publishContent, unpublishContent, deleteContent, triggerPipeline, uploadMedia, deleteMedia, and more
- **Real-time subscriptions** — contentUpdated, contentPublished, pipelineStageCompleted via WebSocket (Pusher in production, log driver in dev)
- **Automatic Persisted Queries (APQ)** — SHA256 hash-based query caching to reduce bandwidth
- **Complexity scoring** — per-field cost weights with configurable max (GRAPHQL_MAX_COMPLEXITY=500)
- **Depth limiting** — configurable max nesting depth (GRAPHQL_MAX_DEPTH=10)
- **N+1 prevention** — Dataloader batching via Lighthouse's built-in batch loading
- **Field-level caching** — @cache directive with automatic invalidation on model events
- **Auth directives** — @auth, @can, @guest guards on fields and mutations
- **GraphiQL explorer** — interactive IDE at /graphiql (dev only)
- **22 tests** — feature and unit test coverage for all major operations

**Endpoint:**
**Docs:**

---



## [0.8.0] — 2026-03-15

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ Role-based access control (Admin, Editor, Author, Viewer) with space-scoped perm
### CLI for Automation
8 commands for content, briefs, and pipeline management — perfect for CI/CD hooks and server-side workflows.

### GraphQL API Layer
**New in v0.9.0.** A full-featured GraphQL API powered by Lighthouse PHP.
- **Endpoint:** `POST /graphql` — all Numen resources in one schema
- **20+ types** with cursor-based pagination (Relay-spec)
- **Mutations** for content, briefs, media, and pipeline operations
- **Real-time subscriptions** for content events and pipeline progress
- **Automatic Persisted Queries (APQ)**, complexity scoring, field-level caching
- **GraphiQL explorer** at `/graphiql` for interactive development
- See [docs/graphql-api.md](docs/graphql-api.md) for the full guide


## Architecture

Expand Down
31 changes: 31 additions & 0 deletions app/GraphQL/Complexity/PaginatedComplexity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\GraphQL\Complexity;

/**
* Custom complexity resolver for paginated fields.
*
* Calculates complexity as childComplexity * first (pagination count).
* This prevents abuse via deeply nested paginated queries like:
* { contents(first: 1000) { versions(first: 100) { ... } } }
*/
final class PaginatedComplexity
{
/**
* Calculate complexity for a paginated field.
*
* @param int $childComplexity The complexity of the child selection set.
* @param array<string, mixed> $args The field arguments.
*/
public function __invoke(int $childComplexity, array $args): int
{
$first = (int) ($args['first'] ?? 20);

// Clamp to a safe maximum to avoid integer overflow in complexity calculations
$first = min($first, 1000);

return $childComplexity * $first;
}
}
15 changes: 15 additions & 0 deletions app/GraphQL/Events/ContentPublishedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace App\GraphQL\Events;

use App\Models\Content;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ContentPublishedEvent
{
use Dispatchable;
use SerializesModels;

public function __construct(public readonly Content $content) {}
}
15 changes: 15 additions & 0 deletions app/GraphQL/Events/PipelineRunUpdatedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace App\GraphQL\Events;

use App\Models\PipelineRun;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class PipelineRunUpdatedEvent
{
use Dispatchable;
use SerializesModels;

public function __construct(public readonly PipelineRun $pipelineRun) {}
}
70 changes: 70 additions & 0 deletions app/GraphQL/Middleware/CostTrackingMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace App\GraphQL\Middleware;

use Illuminate\Support\Facades\Log;
use Nuwave\Lighthouse\Execution\ResolveInfo;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Support\Contracts\FieldMiddleware;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;

/**
* Field middleware that tracks query cost/complexity per request.
*
* Records query hash, execution time, and user_id for analytics
* and monitoring purposes.
*/
final class CostTrackingMiddleware extends BaseDirective implements FieldMiddleware
{
public static function definition(): string
{
return /** @lang GraphQL */ <<<'GRAPHQL'
"""
Tracks query cost and execution time for analytics.
"""
directive @costTracking on FIELD_DEFINITION
GRAPHQL;
}

public function handleField(FieldValue $fieldValue): void
{
$fieldValue->wrapResolver(
fn (callable $resolver): \Closure => function (
mixed $root,
array $args,
GraphQLContext $context,
ResolveInfo $resolveInfo,
) use ($resolver): mixed {
$startTime = microtime(true);

$result = $resolver($root, $args, $context, $resolveInfo);

$executionTimeMs = (int) round((microtime(true) - $startTime) * 1000);

$request = request();
$rawQuery = (string) ($request->input('query', ''));
$queryHash = $rawQuery !== '' ? hash('sha256', $rawQuery) : 'unknown';

/** @var \Illuminate\Contracts\Auth\Authenticatable|null $user */
$user = $context->user();
$userId = $user?->getAuthIdentifier();

// Estimate complexity score from requested subfields count
$complexityScore = count($resolveInfo->getFieldSelection(1));

Log::debug('graphql.cost_tracking', [
'field' => $resolveInfo->parentType->name.'.'.$resolveInfo->fieldName,
'query_hash' => $queryHash,
'complexity_score' => $complexityScore,
'execution_time_ms' => $executionTimeMs,
'user_id' => $userId,
]);

return $result;
}
);
}
}
49 changes: 49 additions & 0 deletions app/GraphQL/Mutations/ApprovePipelineRun.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace App\GraphQL\Mutations;

use App\GraphQL\Events\ContentPublishedEvent;
use App\GraphQL\Events\PipelineRunUpdatedEvent;
use App\Models\PipelineRun;
use App\Services\AuthorizationService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;

class ApprovePipelineRun
{
public function __construct(private readonly AuthorizationService $authz) {}

/**
* @param array{id: string} $args
*/
public function __invoke(mixed $root, array $args): PipelineRun
{
$user = Auth::user();
$run = PipelineRun::with(['pipeline', 'content'])->findOrFail($args['id']);
$this->authz->authorize($user, 'pipeline.approve', $run->pipeline->space_id);

if ($run->status !== 'paused_for_review') {
throw ValidationException::withMessages([
'id' => ['This pipeline run is not awaiting review (current status: '.$run->status.').'],
]);
}

// Publish associated content (mirrors PipelineAdminController::approveRun)
$content = $run->content;
if ($content) {
$content->publish();
ContentPublishedEvent::dispatch($content->fresh(['currentVersion', 'contentType', 'space']));
}

$run->markCompleted();

$this->authz->log($user, 'pipeline.approve', $run);

$fresh = $run->fresh(['pipeline', 'content']);

// Fire subscription broadcast for pipeline run update
PipelineRunUpdatedEvent::dispatch($fresh);

return $fresh;
}
}
58 changes: 58 additions & 0 deletions app/GraphQL/Mutations/CreateBrief.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace App\GraphQL\Mutations;

use App\Models\ContentBrief;
use App\Models\ContentPipeline;
use App\Pipelines\PipelineExecutor;
use App\Services\AuthorizationService;
use Illuminate\Support\Facades\Auth;

class CreateBrief
{
public function __construct(
private readonly AuthorizationService $authz,
private readonly PipelineExecutor $executor,
) {}

/**
* @param array{input: array<string, mixed>} $args
*/
public function __invoke(mixed $root, array $args): ContentBrief
{
$user = Auth::user();
$input = $args['input'];
$this->authz->authorize($user, 'brief.create', $input['space_id']);

$brief = ContentBrief::create([
'space_id' => $input['space_id'],
'title' => $input['title'],
'description' => $input['description'] ?? null,
'content_type_slug' => $input['content_type_slug'],
'target_locale' => $input['target_locale'] ?? 'en',
'target_keywords' => $input['target_keywords'] ?? null,
'priority' => $input['priority'] ?? 'normal',
'persona_id' => $input['persona_id'] ?? null,
'pipeline_id' => $input['pipeline_id'] ?? null,
'source' => 'api',
'status' => 'pending',
]);

// If a pipeline_id was provided, use it; otherwise look for active pipeline in the space
$pipelineId = $input['pipeline_id'] ?? null;

$pipeline = $pipelineId
? ContentPipeline::findOrFail($pipelineId)
: ContentPipeline::where('space_id', $input['space_id'])
->where('is_active', true)
->first();

if ($pipeline) {
$this->executor->start($brief, $pipeline);
}

$this->authz->log($user, 'brief.create', $brief);

return $brief->load(['space']);
}
}
58 changes: 58 additions & 0 deletions app/GraphQL/Mutations/CreateContent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace App\GraphQL\Mutations;

use App\Models\Content;
use App\Models\ContentVersion;
use App\Services\AuthorizationService;
use Illuminate\Support\Facades\Auth;

class CreateContent
{
public function __construct(private readonly AuthorizationService $authz) {}

/**
* @param array{input: array<string, mixed>} $args
*/
public function __invoke(mixed $root, array $args): Content
{
$user = Auth::user();
$this->authz->authorize($user, 'content.create', $args['input']['space_id'] ?? null);

$input = $args['input'];
$termIds = $input['taxonomy_term_ids'] ?? [];
unset($input['taxonomy_term_ids']);

$content = Content::create([
'space_id' => $input['space_id'],
'content_type_id' => $input['content_type_id'],
'slug' => $input['slug'],
'locale' => $input['locale'] ?? 'en',
'status' => $input['status'] ?? 'draft',
'hero_image_id' => $input['hero_image_id'] ?? null,
]);

// Create initial version with title/body
$version = ContentVersion::create([
'content_id' => $content->id,
'version_number' => 1,
'title' => $input['title'],
'body' => $input['body'] ?? null,
'body_format' => 'html',
'status' => 'draft',
'author_type' => 'user',
'author_id' => (string) $user->id,
]);

$content->update(['current_version_id' => $version->id]);

// Attach taxonomy terms if provided
if (! empty($termIds)) {
$content->taxonomyTerms()->sync($termIds);
}

$this->authz->log($user, 'content.create', $content);

return $content->load(['currentVersion', 'contentType', 'space']);
}
}
Loading
Loading