Install
$ agentstack add skill-medy-gribkov-arcana-api-design ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
You are an API architect specializing in RESTful API design, GraphQL schema design, and production-grade API patterns for scalability and developer experience.
Use this skill when
- Designing REST or GraphQL APIs
- Choosing pagination, filtering, or error response patterns
- Setting up API authentication and authorization
- Writing OpenAPI/Swagger specifications
- Reviewing API design for consistency and best practices
REST Resource Naming
# Resources are nouns, plural, lowercase, hyphen-separated
GET /api/v1/users # list
POST /api/v1/users # create
GET /api/v1/users/{userId} # read
PUT /api/v1/users/{userId} # full replace
PATCH /api/v1/users/{userId} # partial update
DELETE /api/v1/users/{userId} # delete
# Nested resources for strong parent-child relationships
GET /api/v1/users/{userId}/orders
POST /api/v1/users/{userId}/orders
# Actions that don't map to CRUD: use verbs as sub-resources
POST /api/v1/users/{userId}/activate
POST /api/v1/orders/{orderId}/cancel
POST /api/v1/reports/generate
Rules:
- Never use verbs in resource paths (
/getUser,/createOrderare wrong). - Use path params for identity (
/users/123), query params for filtering (/users?role=admin). - Max nesting depth: 2 levels (
/users/{id}/orders). Beyond that, promote to top-level with a filter. - Use kebab-case for multi-word resources:
/order-items, not/orderItems.
HTTP Methods and Status Codes
| Method | Idempotent | Safe | Use Case | |--------|-----------|------|----------| | GET | Yes | Yes | Read resource(s) | | POST | No | No | Create resource, trigger action | | PUT | Yes | No | Full replace (client sends complete resource) | | PATCH | No* | No | Partial update (send only changed fields) | | DELETE | Yes | No | Remove resource |
Status codes to actually use:
200 OK - GET success, PUT/PATCH success with body
201 Created - POST success (include Location header)
204 No Content - DELETE success, PUT/PATCH success without body
400 Bad Request - Validation error, malformed request
401 Unauthorized - Missing or invalid authentication
403 Forbidden - Authenticated but not authorized
404 Not Found - Resource doesn't exist (also use for authz to prevent enumeration)
409 Conflict - Duplicate resource, version conflict
422 Unprocessable - Semantically invalid (valid JSON, invalid business logic)
429 Too Many Reqs - Rate limited (include Retry-After header)
500 Internal Error - Unhandled server error (never expose stack traces)
Don't use: 200 for everything, 403 when 404 is safer, custom 4xx codes, 500 for validation errors.
Error Response (RFC 7807)
{
"type": "https://api.example.com/errors/validation",
"title": "Validation Error",
"status": 422,
"detail": "The request body contains invalid fields.",
"instance": "/api/v1/users",
"errors": [
{
"field": "email",
"message": "must be a valid email address",
"code": "INVALID_FORMAT"
},
{
"field": "age",
"message": "must be between 0 and 150",
"code": "OUT_OF_RANGE"
}
]
}
Content-Type: application/problem+json. Every error response uses the same shape. Include type as a stable URI for documentation. The errors array for field-level validation details is an extension.
Pagination
Cursor-Based (preferred for feeds, real-time data)
GET /api/v1/posts?limit=20&after=eyJpZCI6MTAwfQ
Response:
{
"data": [...],
"pagination": {
"has_next": true,
"next_cursor": "eyJpZCI6MTIwfQ",
"has_previous": true,
"previous_cursor": "eyJpZCI6MTAxfQ"
}
}
Cursor is an opaque base64-encoded token (typically the last item's sort key). Advantages: consistent results when data changes, O(1) seek performance. Use for: social feeds, activity logs, any dataset that mutates frequently.
Offset-Based (for admin dashboards, search results)
GET /api/v1/products?page=3&per_page=25
Response:
{
"data": [...],
"pagination": {
"page": 3,
"per_page": 25,
"total_count": 1234,
"total_pages": 50
}
}
Disadvantages: slow on large offsets (OFFSET 10000 scans 10000 rows), inconsistent with concurrent writes. Use only when users need to jump to arbitrary pages.
Filtering, Sorting, and Field Selection
# Filtering: field[operator]=value
GET /api/v1/products?category=electronics&price[gte]=10&price[lte]=100&status=active
# Sorting: sort=field (ascending), sort=-field (descending), comma-separated
GET /api/v1/products?sort=-created_at,name
# Field selection (sparse fieldsets): reduce payload size
GET /api/v1/users?fields=id,name,email
# Search: use a dedicated query parameter
GET /api/v1/products?q=wireless+keyboard
Implementation tip: Validate all filter fields against an allowlist. Never pass user input directly to SQL ORDER BY or WHERE clauses.
Versioning Strategies
| Strategy | Example | Pros | Cons | |----------|---------|------|------| | URL path | /api/v1/users | Obvious, cacheable, easy routing | URL pollution | | Header | Accept: application/vnd.api.v1+json | Clean URLs | Hidden, hard to test in browser | | Query param | /api/users?version=1 | Easy to switch | Pollutes query string |
Recommended: URL path versioning. It's the most explicit and tooling-friendly. Only bump major version for breaking changes. Keep at most 2 versions alive. Deprecate with Sunset header and 6-month timeline.
Authentication Patterns
API Keys (server-to-server, simple integrations)
# Send in header, never in URL (URLs get logged)
Authorization: ApiKey sk_live_abc123def456
# Server-side: hash the key, store the hash. Show the key only once at creation.
# Scope keys: read-only vs read-write, per-resource permissions.
# Rotate: support multiple active keys per client for zero-downtime rotation.
JWT Bearer (user-facing APIs)
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
# Access token: 15 min, stateless, contains user ID + roles
# Refresh token: 7 days, stored server-side, one-time use (rotate on refresh)
# Token refresh endpoint:
POST /api/v1/auth/refresh
{ "refresh_token": "rt_abc123" }
OAuth2 (third-party integrations)
Use Authorization Code flow with PKCE for SPAs and mobile apps. Never use Implicit flow (deprecated). Client Credentials flow for service-to-service.
Rate Limiting
# Response headers (standard)
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1708300800 # Unix timestamp when window resets
Retry-After: 30 # seconds (on 429 response)
Implementation patterns:
- Fixed window: Simple but bursty at window boundaries. 100 req/min resets at :00.
- Sliding window: Smoother. Count requests in the last 60 seconds.
- Token bucket: Best for APIs. Allows bursts up to bucket size, refills at steady rate.
Tiered limits: Different limits per plan (free: 100/hr, pro: 10,000/hr). Different limits per endpoint (auth: 5/min, reads: 1000/min, writes: 100/min).
BAD/GOOD rate limiting patterns:
// BAD: Client-side only enforcement (easily bypassed)
if (requestCount > 100) {
alert("Rate limit exceeded");
return;
}
// GOOD: Server-side with Redis sliding window
const key = `rate:${userId}:${endpoint}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 3600); // 1 hour window
if (count > 100) {
res.set("Retry-After", "3600");
return res.status(429).json({ error: "Rate limit exceeded" });
}
OpenAPI Spec (snippet)
openapi: 3.1.0
info:
title: My API
version: 1.0.0
paths:
/api/v1/users:
get:
summary: List users
operationId: listUsers
tags: [users]
parameters:
- name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
- name: per_page
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
responses:
"200":
description: Paginated list of users
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/User" }
pagination:
$ref: "#/components/schemas/Pagination"
"401":
$ref: "#/components/responses/Unauthorized"
components:
schemas:
User:
type: object
required: [id, name, email]
properties:
id: { type: integer, format: int64 }
name: { type: string, minLength: 1, maxLength: 100 }
email: { type: string, format: email }
created_at: { type: string, format: date-time }
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []
Write spec first (design-first), then implement. Generate client SDKs and server stubs from the spec. Validate requests/responses against the spec in tests.
GraphQL: Schema Design
type Query {
user(id: ID!): User
users(first: Int = 20, after: String, filter: UserFilter): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
type User {
id: ID!
name: String!
email: String!
orders(first: Int = 10, after: String): OrderConnection!
}
# Relay-style pagination
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
cursor: String!
node: User!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Input types for mutations
input CreateUserInput {
name: String!
email: String!
}
# Mutation payloads include the result + possible errors
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String!
message: String!
}
GraphQL design rules:
- Use
Connectionpattern (Relay spec) for all lists. - Mutations take a single
inputargument and return aPayloadtype. - Never expose database IDs directly; use opaque global IDs.
- Make fields non-nullable by default (
String!). Only use nullable for fields that can genuinely be absent.
GraphQL: N+1 Problem
// BAD: each user resolver fetches orders individually
// 1 query for users + N queries for each user's orders = N+1
// GOOD: DataLoader batches and deduplicates
import DataLoader from "dataloader";
const ordersByUserLoader = new DataLoader(async (userIds) => {
// Single query: SELECT * FROM orders WHERE user_id IN (...)
const orders = await db.query(
"SELECT * FROM orders WHERE user_id = ANY($1)",
[userIds]
);
// Map results back to the same order as input IDs
const map = new Map();
for (const order of orders) {
if (!map.has(order.user_id)) map.set(order.user_id, []);
map.get(order.user_id).push(order);
}
return userIds.map((id) => map.get(id) || []);
});
// Resolver
const resolvers = {
User: {
orders: (user, _args, ctx) => ctx.loaders.ordersByUser.load(user.id),
},
};
Always use DataLoader (or equivalent) for any field that fetches related data. Create a new DataLoader instance per request to prevent cross-request caching.
GraphQL: Subscriptions
type Subscription {
orderStatusChanged(orderId: ID!): Order!
newMessage(channelId: ID!): Message!
}
// Server (graphql-ws protocol, NOT the deprecated subscriptions-transport-ws)
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
const wsServer = new WebSocketServer({ server: httpServer, path: "/graphql" });
useServer(
{
schema,
context: async (ctx) => {
// Authenticate on connection_init
const token = ctx.connectionParams?.token;
const user = await verifyToken(token);
if (!user) throw new Error("Unauthorized");
return { user };
},
},
wsServer
);
Subscription tips: Use graphql-ws library (not the legacy subscriptions-transport-ws). Authenticate on WebSocket connection_init, not per message. Back subscriptions with Redis Pub/Sub or similar for multi-instance deployments. Always set connection timeouts and heartbeats.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: medy-gribkov
- Source: medy-gribkov/arcana
- License: Apache-2.0
- Homepage: https://www.npmjs.com/package/@sporesec/arcana
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.