ahgGraphQLPlugin - Technical Documentation¶
Overview¶
The ahgGraphQLPlugin provides a GraphQL API endpoint as an alternative to the REST API, offering flexible querying with built-in security safeguards.
| Property | Value |
|---|---|
| Plugin Name | ahgGraphQLPlugin |
| Version | 1.0.0 |
| Category | Integration |
| Dependencies | ahgAPIPlugin, webonyx/graphql-php |
| Endpoint | /api/graphql |
Architecture¶
┌─────────────────────────────────────────────────────────────────┐
│ Client Request │
│ POST /api/graphql │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ indexAction.class.php │
│ • CORS handling │
│ • Authentication (ApiKeyService) │
│ • Request parsing │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ GraphQLService.php │
│ • Schema building │
│ • Validation rules (depth, complexity) │
│ • Query execution │
│ • Context building │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ SchemaBuilder │ │ Security Rules │ │ Resolvers │
│ • Query type │ │ • DepthLimit │ │ • ItemResolver │
│ • Mutation type│ │ • Complexity │ │ • ActorResolver │
│ • Type defs │ │ • Introspection │ │ • TaxonomyRes. │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ApiRepository │
│ (Reused from ahgAPIPlugin) │
│ • Database queries via Laravel Query Builder │
└─────────────────────────────────────────────────────────────────┘
Directory Structure¶
ahgGraphQLPlugin/
├── config/
│ └── ahgGraphQLPluginConfiguration.class.php
├── lib/
│ ├── GraphQL/
│ │ ├── Schema/
│ │ │ ├── SchemaBuilder.php
│ │ │ └── Types/
│ │ │ ├── ItemType.php
│ │ │ ├── ActorType.php
│ │ │ ├── RepositoryType.php
│ │ │ ├── TermType.php
│ │ │ ├── UserType.php
│ │ │ ├── ConnectionTypes.php
│ │ │ └── ScalarTypes.php
│ │ ├── Resolvers/
│ │ │ ├── BaseResolver.php
│ │ │ ├── ItemResolver.php
│ │ │ ├── ActorResolver.php
│ │ │ ├── TaxonomyResolver.php
│ │ │ └── UserResolver.php
│ │ └── Security/
│ │ ├── DepthLimitRule.php
│ │ └── ComplexityAnalyzer.php
│ └── GraphQLService.php
├── modules/
│ └── graphql/
│ ├── config/module.yml
│ ├── actions/
│ │ ├── indexAction.class.php
│ │ └── playgroundAction.class.php
│ └── templates/
│ └── playgroundSuccess.php
├── data/
│ └── install.sql
└── extension.json
Database Schema¶
Query Logging Table¶
CREATE TABLE ahg_graphql_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
api_key_id INT UNSIGNED NULL,
operation_name VARCHAR(255) NULL,
complexity_score INT UNSIGNED NULL,
depth INT UNSIGNED NULL,
execution_time_ms INT UNSIGNED NULL,
success TINYINT(1) DEFAULT 1,
created_at DATETIME NOT NULL,
INDEX idx_api_key_id (api_key_id),
INDEX idx_created_at (created_at),
INDEX idx_operation_name (operation_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
GraphQL Schema¶
Query Type¶
type Query {
# Single lookups
item(slug: String, id: ID): Item
actor(slug: String, id: ID): Actor
repository(slug: String, id: ID): Repository
taxonomy(id: ID!): Taxonomy
# Collections (paginated)
items(first: Int, after: String, repository: String, level: String, sector: String): ItemConnection!
actors(first: Int, after: String, entityType: String): ActorConnection!
repositories(first: Int, after: String): RepositoryConnection!
# Other
taxonomies: [Taxonomy!]!
search(query: String!, first: Int, after: String): ItemConnection!
me: User
}
Core Types¶
Item Type¶
type Item {
id: ID!
slug: String!
identifier: String
title: String!
levelOfDescription: Term
sector: String
scopeAndContent: String
extentAndMedium: String
archivalHistory: String
acquisition: String
arrangement: String
accessConditions: String
reproductionConditions: String
repository: Repository
parent: Item
children(first: Int, after: String): ItemConnection!
ancestors: [Item!]!
dates: [Event!]!
subjects: [Term!]!
places: [Term!]!
creators: [Actor!]!
digitalObjects: [DigitalObject!]!
childrenCount: Int!
}
Actor Type¶
type Actor {
id: ID!
slug: String!
authorizedFormOfName: String!
entityType: Term
datesOfExistence: String
history: String
places: String
functions: String
relatedItems(first: Int, after: String): ItemConnection!
}
Repository Type¶
type Repository {
id: ID!
slug: String!
name: String!
identifier: String
holdings(first: Int, after: String): ItemConnection!
itemCount: Int!
}
Connection Types (Relay-style)¶
type ItemConnection {
edges: [ItemEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ItemEdge {
node: Item!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Security Implementation¶
Depth Limiting¶
File: lib/GraphQL/Security/DepthLimitRule.php
Prevents deeply nested queries that could cause performance issues:
class DepthLimitRule extends ValidationRule
{
private int $maxDepth = 10;
public function getVisitor(QueryValidationContext $context): array
{
// Traverses AST and calculates max depth
// Reports error if depth > maxDepth
}
}
Example blocked query:
{
items {
edges {
node {
children {
edges {
node {
children { # ... continues beyond 10 levels
}
}
}
}
}
}
}
}
Complexity Analysis¶
File: lib/GraphQL/Security/ComplexityAnalyzer.php
Assigns costs to fields and limits total query complexity:
| Field | Cost |
|---|---|
| Simple fields | 1 |
| subjects, places, dates | 2-3 |
| children, relatedItems, holdings | 10 |
| search | 15 |
Costs are multiplied by pagination first argument.
Max complexity: 1000
Introspection Control¶
- Development: Introspection enabled (for GraphQL Playground)
- Production: Introspection disabled
Detection via:
- SF_ENVIRONMENT === 'dev'
- sf_debug === true
Authentication¶
Reuses ApiKeyService from ahgAPIPlugin:
// In indexAction.class.php
protected function authenticate(): bool
{
// 1. Check session auth
if ($this->context->user->isAuthenticated()) {
$this->apiKeyInfo = [
'type' => 'session',
'scopes' => ['read', 'write', 'delete'],
// ...
];
return true;
}
// 2. Check API key
$this->apiKeyService = new ApiKeyService();
$this->apiKeyInfo = $this->apiKeyService->authenticate();
// ...
}
Supported headers:
- X-API-Key
- X-REST-API-Key
- Authorization: Bearer <key>
Resolvers¶
BaseResolver¶
abstract class BaseResolver
{
protected ApiRepository $repository;
protected string $culture;
protected function buildConnection(array $items, int $total, int $offset, int $first): array
{
return ConnectionTypes::buildConnection($items, $total, $offset, $first);
}
}
ItemResolver¶
Key methods:
| Method | Purpose |
|---|---|
resolveBySlug($slug) |
Get item by URL slug |
resolveById($id) |
Get item by database ID |
resolveList($first, $offset, ...) |
Paginated item list |
resolveChildren($parentId, ...) |
Child items |
resolveAncestors($itemId) |
Hierarchy path |
resolveDates($itemId) |
Date events |
resolveSubjects($itemId) |
Subject access points |
resolveCreators($itemId) |
Related actors |
resolveDigitalObjects($itemId) |
Attached files |
resolveSearch($query, ...) |
Full-text search |
ActorResolver¶
| Method | Purpose |
|---|---|
resolveBySlug($slug) |
Get actor by slug |
resolveList($first, $offset, $entityType) |
Paginated actor list |
resolveRelatedItems($actorId, ...) |
Items linked to actor |
TaxonomyResolver¶
| Method | Purpose |
|---|---|
resolveAll() |
All taxonomies |
resolveById($id) |
Single taxonomy |
resolveTerms($taxonomyId) |
Terms in taxonomy |
resolveTermChildren($parentId) |
Child terms |
Routes¶
Registered in ahgGraphQLPluginConfiguration.class.php:
| Method | Route | Action |
|---|---|---|
| POST | /api/graphql |
graphql/index |
| GET | /api/graphql |
graphql/index |
| GET | /api/graphql/playground |
graphql/playground |
Configuration¶
Plugin Configuration¶
class ahgGraphQLPluginConfiguration extends sfPluginConfiguration
{
public function initialize()
{
// Enable graphql module
$enabledModules[] = 'graphql';
// Register routes
$this->dispatcher->connect('routing.load_configuration', [$this, 'routingLoadConfiguration']);
}
}
GraphQL Service Options¶
$service = new GraphQLService([
'debug' => false, // Show detailed errors
'maxDepth' => 10, // Query depth limit
'maxComplexity' => 1000, // Complexity limit
'introspection' => false, // Allow schema introspection
'culture' => 'en', // Default language
]);
Error Responses¶
Validation Errors¶
{
"errors": [
{
"message": "Query depth of 12 exceeds maximum allowed depth of 10",
"locations": [{"line": 1, "column": 1}],
"extensions": {"code": "DEPTH_LIMIT"}
}
]
}
Authentication Errors¶
Field Errors¶
Testing¶
CLI Test¶
php -r '
require "config/ProjectConfiguration.class.php";
$config = ProjectConfiguration::getApplicationConfiguration("qubit", "prod", false);
sfContext::createInstance($config);
require_once "atom-framework/bootstrap.php";
// ... autoloader setup ...
$service = new AhgGraphQLPlugin\GraphQLService(["debug" => true]);
$result = $service->execute("{ taxonomies { id name } }");
print_r($result);
'
HTTP Test¶
curl -X POST https://your-instance.com/api/graphql \
-H "Content-Type: application/json" \
-H "X-API-Key: your-key" \
-d '{"query": "{ items(first: 3) { totalCount } }"}'
Dependencies¶
PHP Dependencies¶
| Package | Version | Purpose |
|---|---|---|
| webonyx/graphql-php | ^15.0 | GraphQL implementation |
Plugin Dependencies¶
| Plugin | Purpose |
|---|---|
| ahgAPIPlugin | ApiKeyService, ApiRepository |
Performance Considerations¶
- Pagination limits - Max 100 items per page
- Depth limits - Max 10 levels deep
- Complexity limits - Max 1000 per query
- Query logging - Optional analytics
- N+1 prevention - Batch loading in resolvers
Future Enhancements¶
- Mutations (create/update/delete)
- Subscriptions (real-time updates)
- DataLoader for batch loading
- Persisted queries
- Rate limiting per operation