|
@@ -0,0 +1,1091 @@
|
|
|
|
|
+# ARP Server Client Implementation Guide
|
|
|
|
|
+
|
|
|
|
|
+This document provides the necessary information to implement a client for the ARP (Agent-native ERP) GraphQL server.
|
|
|
|
|
+
|
|
|
|
|
+## Table of Contents
|
|
|
|
|
+
|
|
|
|
|
+1. [Overview](#overview)
|
|
|
|
|
+2. [Connection & Endpoint](#connection--endpoint)
|
|
|
|
|
+3. [Authentication](#authentication)
|
|
|
|
|
+4. [Authorization & Permissions](#authorization--permissions)
|
|
|
|
|
+5. [GraphQL Operations](#graphql-operations)
|
|
|
|
|
+6. [Error Handling](#error-handling)
|
|
|
|
|
+7. [Subscriptions](#subscriptions)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Overview
|
|
|
|
|
+
|
|
|
|
|
+The ARP server exposes a **GraphQL API** for managing users, roles, permissions, services, tasks, notes, channels, and messages. All operations except `login` require authentication via a JWT bearer token.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Connection & Endpoint
|
|
|
|
|
+
|
|
|
|
|
+- **Protocol**: HTTP/HTTPS
|
|
|
|
|
+- **Endpoint**: `/query` (GraphQL endpoint)
|
|
|
|
|
+- **Content-Type**: `application/json`
|
|
|
|
|
+- **Request Method**: `POST` for queries and mutations
|
|
|
|
|
+
|
|
|
|
|
+### Example Request Structure
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "query": "string (GraphQL query or mutation)",
|
|
|
|
|
+ "variables": "object (optional variables)",
|
|
|
|
|
+ "operationName": "string (optional operation name)"
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Authentication
|
|
|
|
|
+
|
|
|
|
|
+### Login Flow
|
|
|
|
|
+
|
|
|
|
|
+1. Call the `login` mutation with email and password
|
|
|
|
|
+2. Receive a JWT token in the response
|
|
|
|
|
+3. Include the token in subsequent requests via the `Authorization` header
|
|
|
|
|
+
|
|
|
|
|
+### Login Mutation
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+mutation Login($email: String!, $password: String!) {
|
|
|
|
|
+ login(email: $email, password: $password) {
|
|
|
|
|
+ token
|
|
|
|
|
+ user {
|
|
|
|
|
+ id
|
|
|
|
|
+ email
|
|
|
|
|
+ roles {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ permissions {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ description
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**Variables:**
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "email": "user@example.com",
|
|
|
|
|
+ "password": "your-password"
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**Response:**
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "data": {
|
|
|
|
|
+ "login": {
|
|
|
|
|
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
|
|
|
+ "user": {
|
|
|
|
|
+ "id": "1",
|
|
|
|
|
+ "email": "user@example.com",
|
|
|
|
|
+ "roles": [...]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Using the Token
|
|
|
|
|
+
|
|
|
|
|
+Include the token in all subsequent requests:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+Authorization: Bearer <token>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Token Details
|
|
|
|
|
+
|
|
|
|
|
+| Property | Value |
|
|
|
|
|
+|----------|-------|
|
|
|
|
|
+| Algorithm | HS256 |
|
|
|
|
|
+| Expiration | 10 years from issuance |
|
|
|
|
|
+| Claims | `user_id`, `email`, `roles`, `permissions` |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Authorization & Permissions
|
|
|
|
|
+
|
|
|
|
|
+### Permission-Based Access Control
|
|
|
|
|
+
|
|
|
|
|
+Many operations require specific permissions. The permission format is `resource:action`.
|
|
|
|
|
+
|
|
|
|
|
+### Required Permissions by Operation
|
|
|
|
|
+
|
|
|
|
|
+| Operation | Required Permission |
|
|
|
|
|
+|-----------|---------------------|
|
|
|
|
|
+| `updateUser` | `user:update` |
|
|
|
|
|
+| `deleteUser` | `user:delete` |
|
|
|
|
|
+| `updateNote` | `note:update` |
|
|
|
|
|
+| `deleteNote` | `note:delete` |
|
|
|
|
|
+| `updateRole` | `role:update` |
|
|
|
|
|
+| `deleteRole` | `role:delete` |
|
|
|
|
|
+| `updatePermission` | `permission:update` |
|
|
|
|
|
+| `deletePermission` | `permission:delete` |
|
|
|
|
|
+| `updateService` | `service:update` |
|
|
|
|
|
+| `deleteService` | `service:delete` |
|
|
|
|
|
+| `updateTask` | `task:update` |
|
|
|
|
|
+| `deleteTask` | `task:delete` |
|
|
|
|
|
+| `updateTaskStatus` | `taskstatus:update` |
|
|
|
|
|
+| `deleteTaskStatus` | `taskstatus:delete` |
|
|
|
|
|
+| `updateChannel` | `channel:update` |
|
|
|
|
|
+| `deleteChannel` | `channel:delete` |
|
|
|
|
|
+| `updateMessage` | `message:update` |
|
|
|
|
|
+| `deleteMessage` | `message:delete` |
|
|
|
|
|
+
|
|
|
|
|
+### Authorization Errors
|
|
|
|
|
+
|
|
|
|
|
+If authentication or authorization fails, the API returns an error:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "errors": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "message": "unauthorized: authentication required"
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Or for missing permissions:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "errors": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "message": "unauthorized: missing user:update permission"
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## GraphQL Operations
|
|
|
|
|
+
|
|
|
|
|
+### Queries (Read Operations)
|
|
|
|
|
+
|
|
|
|
|
+All queries require authentication.
|
|
|
|
|
+
|
|
|
|
|
+#### Users
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Get all users
|
|
|
|
|
+query Users {
|
|
|
|
|
+ users {
|
|
|
|
|
+ id
|
|
|
|
|
+ email
|
|
|
|
|
+ roles {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Get single user by ID
|
|
|
|
|
+query User($id: ID!) {
|
|
|
|
|
+ user(id: $id) {
|
|
|
|
|
+ id
|
|
|
|
|
+ email
|
|
|
|
|
+ roles {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ permissions {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ description
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Notes
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Get all notes
|
|
|
|
|
+query Notes {
|
|
|
|
|
+ notes {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ content
|
|
|
|
|
+ userId
|
|
|
|
|
+ user { id email }
|
|
|
|
|
+ serviceId
|
|
|
|
|
+ service { id name }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Get single note by ID
|
|
|
|
|
+query Note($id: ID!) {
|
|
|
|
|
+ note(id: $id) {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ content
|
|
|
|
|
+ userId
|
|
|
|
|
+ serviceId
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Roles
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Get all roles
|
|
|
|
|
+query Roles {
|
|
|
|
|
+ roles {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ description
|
|
|
|
|
+ permissions {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ description
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Get single role by ID
|
|
|
|
|
+query Role($id: ID!) {
|
|
|
|
|
+ role(id: $id) {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ description
|
|
|
|
|
+ permissions {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Permissions
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Get all permissions
|
|
|
|
|
+query Permissions {
|
|
|
|
|
+ permissions {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ description
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Get single permission by ID
|
|
|
|
|
+query Permission($id: ID!) {
|
|
|
|
|
+ permission(id: $id) {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ description
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Services
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Get all services
|
|
|
|
|
+query Services {
|
|
|
|
|
+ services {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ description
|
|
|
|
|
+ createdById
|
|
|
|
|
+ createdBy { id email }
|
|
|
|
|
+ participants { id email }
|
|
|
|
|
+ tasks { id title }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Get single service by ID
|
|
|
|
|
+query Service($id: ID!) {
|
|
|
|
|
+ service(id: $id) {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ description
|
|
|
|
|
+ participants { id email }
|
|
|
|
|
+ tasks { id title status { code label } }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Tasks
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Get all tasks
|
|
|
|
|
+query Tasks {
|
|
|
|
|
+ tasks {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ content
|
|
|
|
|
+ createdById
|
|
|
|
|
+ createdBy { id email }
|
|
|
|
|
+ updatedById
|
|
|
|
|
+ updatedBy { id email }
|
|
|
|
|
+ assigneeId
|
|
|
|
|
+ assignee { id email }
|
|
|
|
|
+ statusId
|
|
|
|
|
+ status { id code label }
|
|
|
|
|
+ dueDate
|
|
|
|
|
+ priority
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Get single task by ID
|
|
|
|
|
+query Task($id: ID!) {
|
|
|
|
|
+ task(id: $id) {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ content
|
|
|
|
|
+ createdById
|
|
|
|
|
+ createdBy { id email }
|
|
|
|
|
+ updatedById
|
|
|
|
|
+ updatedBy { id email }
|
|
|
|
|
+ assignee { id email }
|
|
|
|
|
+ status { code label }
|
|
|
|
|
+ dueDate
|
|
|
|
|
+ priority
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Task Statuses
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Get all task statuses
|
|
|
|
|
+query TaskStatuses {
|
|
|
|
|
+ taskStatuses {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ label
|
|
|
|
|
+ tasks { id title }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Get single task status by ID
|
|
|
|
|
+query TaskStatus($id: ID!) {
|
|
|
|
|
+ taskStatus(id: $id) {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ label
|
|
|
|
|
+ tasks { id title }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Channels
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Get all channels
|
|
|
|
|
+query Channels {
|
|
|
|
|
+ channels {
|
|
|
|
|
+ id
|
|
|
|
|
+ participants { id email }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Get single channel by ID
|
|
|
|
|
+query Channel($id: ID!) {
|
|
|
|
|
+ channel(id: $id) {
|
|
|
|
|
+ id
|
|
|
|
|
+ participants { id email }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Messages
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Get all messages
|
|
|
|
|
+query Messages {
|
|
|
|
|
+ messages {
|
|
|
|
|
+ id
|
|
|
|
|
+ conversationId
|
|
|
|
|
+ senderId
|
|
|
|
|
+ sender { id email }
|
|
|
|
|
+ content
|
|
|
|
|
+ sentAt
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Get single message by ID
|
|
|
|
|
+query Message($id: ID!) {
|
|
|
|
|
+ message(id: $id) {
|
|
|
|
|
+ id
|
|
|
|
|
+ conversationId
|
|
|
|
|
+ senderId
|
|
|
|
|
+ sender { id email }
|
|
|
|
|
+ content
|
|
|
|
|
+ sentAt
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Mutations (Write Operations)
|
|
|
|
|
+
|
|
|
|
|
+All mutations require authentication. Some require additional permissions (see Authorization section).
|
|
|
|
|
+
|
|
|
|
|
+#### Authentication
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+mutation Login($email: String!, $password: String!) {
|
|
|
|
|
+ login(email: $email, password: $password) {
|
|
|
|
|
+ token
|
|
|
|
|
+ user { id email }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Users
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Create user
|
|
|
|
|
+mutation CreateUser($input: NewUser!) {
|
|
|
|
|
+ createUser(input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ email
|
|
|
|
|
+ roles { id name }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+# Variables: { "input": { "email": "...", "password": "...", "roles": ["1", "2"] } }
|
|
|
|
|
+
|
|
|
|
|
+# Update user (requires user:update permission)
|
|
|
|
|
+mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
|
|
|
|
|
+ updateUser(id: $id, input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ email
|
|
|
|
|
+ roles { id name }
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+# Variables: { "id": "1", "input": { "email": "new@example.com" } }
|
|
|
|
|
+
|
|
|
|
|
+# Delete user (requires user:delete permission)
|
|
|
|
|
+mutation DeleteUser($id: ID!) {
|
|
|
|
|
+ deleteUser(id: $id)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Notes
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Create note
|
|
|
|
|
+mutation CreateNote($input: NewNote!) {
|
|
|
|
|
+ createNote(input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ content
|
|
|
|
|
+ userId
|
|
|
|
|
+ serviceId
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+# Variables: { "input": { "title": "...", "content": "...", "userId": "1", "serviceId": "1" } }
|
|
|
|
|
+
|
|
|
|
|
+# Update note (requires note:update permission)
|
|
|
|
|
+mutation UpdateNote($id: ID!, $input: UpdateNoteInput!) {
|
|
|
|
|
+ updateNote(id: $id, input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ content
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Delete note (requires note:delete permission)
|
|
|
|
|
+mutation DeleteNote($id: ID!) {
|
|
|
|
|
+ deleteNote(id: $id)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Roles
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Create role
|
|
|
|
|
+mutation CreateRole($input: NewRole!) {
|
|
|
|
|
+ createRole(input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ description
|
|
|
|
|
+ permissions { id code }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+# Variables: { "input": { "name": "...", "description": "...", "permissions": ["1", "2"] } }
|
|
|
|
|
+
|
|
|
|
|
+# Update role (requires role:update permission)
|
|
|
|
|
+mutation UpdateRole($id: ID!, $input: UpdateRoleInput!) {
|
|
|
|
|
+ updateRole(id: $id, input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ permissions { id code }
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Delete role (requires role:delete permission)
|
|
|
|
|
+mutation DeleteRole($id: ID!) {
|
|
|
|
|
+ deleteRole(id: $id)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Permissions
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Create permission
|
|
|
|
|
+mutation CreatePermission($input: NewPermission!) {
|
|
|
|
|
+ createPermission(input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ description
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+# Variables: { "input": { "code": "resource:action", "description": "..." } }
|
|
|
|
|
+
|
|
|
|
|
+# Update permission (requires permission:update permission)
|
|
|
|
|
+mutation UpdatePermission($id: ID!, $input: UpdatePermissionInput!) {
|
|
|
|
|
+ updatePermission(id: $id, input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Delete permission (requires permission:delete permission)
|
|
|
|
|
+mutation DeletePermission($id: ID!) {
|
|
|
|
|
+ deletePermission(id: $id)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Services
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Create service
|
|
|
|
|
+mutation CreateService($input: NewService!) {
|
|
|
|
|
+ createService(input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ description
|
|
|
|
|
+ createdById
|
|
|
|
|
+ participants { id email }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+# Variables: { "input": { "name": "...", "description": "...", "createdById": "1", "participants": ["1", "2"] } }
|
|
|
|
|
+
|
|
|
|
|
+# Update service (requires service:update permission)
|
|
|
|
|
+mutation UpdateService($id: ID!, $input: UpdateServiceInput!) {
|
|
|
|
|
+ updateService(id: $id, input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ name
|
|
|
|
|
+ participants { id email }
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Delete service (requires service:delete permission)
|
|
|
|
|
+mutation DeleteService($id: ID!) {
|
|
|
|
|
+ deleteService(id: $id)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Tasks
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Create task
|
|
|
|
|
+mutation CreateTask($input: NewTask!) {
|
|
|
|
|
+ createTask(input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ content
|
|
|
|
|
+ createdById
|
|
|
|
|
+ assigneeId
|
|
|
|
|
+ statusId
|
|
|
|
|
+ dueDate
|
|
|
|
|
+ priority
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+# Variables: { "input": { "title": "...", "content": "...", "createdById": "1", "priority": "high" } }
|
|
|
|
|
+
|
|
|
|
|
+# Update task (requires task:update permission)
|
|
|
|
|
+# Note: updatedBy is automatically set to the current authenticated user
|
|
|
|
|
+mutation UpdateTask($id: ID!, $input: UpdateTaskInput!) {
|
|
|
|
|
+ updateTask(id: $id, input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ status { code label }
|
|
|
|
|
+ assignee { id email }
|
|
|
|
|
+ updatedBy { id email }
|
|
|
|
|
+ dueDate
|
|
|
|
|
+ priority
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Delete task (requires task:delete permission)
|
|
|
|
|
+mutation DeleteTask($id: ID!) {
|
|
|
|
|
+ deleteTask(id: $id)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Task Statuses
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Create task status
|
|
|
|
|
+mutation CreateTaskStatus($input: NewTaskStatus!) {
|
|
|
|
|
+ createTaskStatus(input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ label
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+# Variables: { "input": { "code": "in_progress", "label": "In Progress" } }
|
|
|
|
|
+
|
|
|
|
|
+# Update task status (requires taskstatus:update permission)
|
|
|
|
|
+mutation UpdateTaskStatus($id: ID!, $input: UpdateTaskStatusInput!) {
|
|
|
|
|
+ updateTaskStatus(id: $id, input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ code
|
|
|
|
|
+ label
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Delete task status (requires taskstatus:delete permission)
|
|
|
|
|
+mutation DeleteTaskStatus($id: ID!) {
|
|
|
|
|
+ deleteTaskStatus(id: $id)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Channels
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Create channel
|
|
|
|
|
+mutation CreateChannel($input: NewChannel!) {
|
|
|
|
|
+ createChannel(input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ participants { id email }
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+# Variables: { "input": { "participants": ["1", "2"] } }
|
|
|
|
|
+
|
|
|
|
|
+# Update channel (requires channel:update permission)
|
|
|
|
|
+mutation UpdateChannel($id: ID!, $input: UpdateChannelInput!) {
|
|
|
|
|
+ updateChannel(id: $id, input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ participants { id email }
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Delete channel (requires channel:delete permission)
|
|
|
|
|
+mutation DeleteChannel($id: ID!) {
|
|
|
|
|
+ deleteChannel(id: $id)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Messages
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Create message
|
|
|
|
|
+mutation CreateMessage($input: NewMessage!) {
|
|
|
|
|
+ createMessage(input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ conversationId
|
|
|
|
|
+ senderId
|
|
|
|
|
+ sender { id email }
|
|
|
|
|
+ content
|
|
|
|
|
+ sentAt
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+# Variables: { "input": { "conversationId": "1", "senderId": "1", "content": "Hello!" } }
|
|
|
|
|
+
|
|
|
|
|
+# Update message (requires message:update permission)
|
|
|
|
|
+mutation UpdateMessage($id: ID!, $input: UpdateMessageInput!) {
|
|
|
|
|
+ updateMessage(id: $id, input: $input) {
|
|
|
|
|
+ id
|
|
|
|
|
+ content
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Delete message (requires message:delete permission)
|
|
|
|
|
+mutation DeleteMessage($id: ID!) {
|
|
|
|
|
+ deleteMessage(id: $id)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Error Handling
|
|
|
|
|
+
|
|
|
|
|
+### Error Response Format
|
|
|
|
|
+
|
|
|
|
|
+Errors are returned in the standard GraphQL error format:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "errors": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "message": "Error description",
|
|
|
|
|
+ "path": ["fieldName"],
|
|
|
|
|
+ "locations": [{ "line": 1, "column": 2 }]
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "data": null
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Common Error Messages
|
|
|
|
|
+
|
|
|
|
|
+| Error Message | Cause |
|
|
|
|
|
+|---------------|-------|
|
|
|
|
|
+| `unauthorized: authentication required` | Missing or invalid JWT token |
|
|
|
|
|
+| `unauthorized: missing X:Y permission` | User lacks required permission |
|
|
|
|
|
+| `invalid credentials` | Wrong email/password on login |
|
|
|
|
|
+| `invalid X ID` | Malformed ID provided |
|
|
|
|
|
+| `X not found` | Resource with given ID doesn't exist |
|
|
|
|
|
+| `failed to create/update/delete X` | Database operation failed |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Subscriptions
|
|
|
|
|
+
|
|
|
|
|
+The API supports real-time updates via GraphQL subscriptions. Subscriptions use WebSocket connections and provide filtered events based on user context.
|
|
|
|
|
+
|
|
|
|
|
+### Event Filtering
|
|
|
|
|
+
|
|
|
|
|
+Subscriptions are **filtered by user context**. Users only receive events that are relevant to them:
|
|
|
|
|
+
|
|
|
|
|
+| Subscription | Filtering Rule |
|
|
|
|
|
+|--------------|----------------|
|
|
|
|
|
+| `taskCreated` | Only if user is the **assignee** |
|
|
|
|
|
+| `taskUpdated` | Only if user is the **assignee** |
|
|
|
|
|
+| `taskDeleted` | Only if user is the **assignee** |
|
|
|
|
|
+| `messageAdded` | Only if user is a **participant** in the channel |
|
|
|
|
|
+
|
|
|
|
|
+This means:
|
|
|
|
|
+- A user will only receive task events for tasks assigned to them
|
|
|
|
|
+- A user will only receive message events for channels they are a participant in
|
|
|
|
|
+- Unassigned tasks do not trigger notifications to any user
|
|
|
|
|
+
|
|
|
|
|
+### Available Subscriptions
|
|
|
|
|
+
|
|
|
|
|
+```graphql
|
|
|
|
|
+# Task created - received only by assignee
|
|
|
|
|
+subscription TaskCreated {
|
|
|
|
|
+ taskCreated {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ content
|
|
|
|
|
+ assigneeId
|
|
|
|
|
+ status { code label }
|
|
|
|
|
+ priority
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Task updated - received only by assignee
|
|
|
|
|
+subscription TaskUpdated {
|
|
|
|
|
+ taskUpdated {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ content
|
|
|
|
|
+ assigneeId
|
|
|
|
|
+ updatedBy { id email }
|
|
|
|
|
+ status { code label }
|
|
|
|
|
+ priority
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Task deleted - received only by assignee
|
|
|
|
|
+subscription TaskDeleted {
|
|
|
|
|
+ taskDeleted {
|
|
|
|
|
+ id
|
|
|
|
|
+ title
|
|
|
|
|
+ assigneeId
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Message added - received by all channel participants
|
|
|
|
|
+subscription MessageAdded {
|
|
|
|
|
+ messageAdded {
|
|
|
|
|
+ id
|
|
|
|
|
+ conversationId
|
|
|
|
|
+ senderId
|
|
|
|
|
+ sender { id email }
|
|
|
|
|
+ content
|
|
|
|
|
+ sentAt
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### WebSocket Connection
|
|
|
|
|
+
|
|
|
|
|
+To use subscriptions, establish a WebSocket connection:
|
|
|
|
|
+
|
|
|
|
|
+1. **Protocol**: WebSocket over HTTP/HTTPS
|
|
|
|
|
+2. **Endpoint**: `/query` (same as GraphQL endpoint)
|
|
|
|
|
+3. **Authentication**: Include the JWT token in the connection parameters or headers
|
|
|
|
|
+
|
|
|
|
|
+### WebSocket Subprotocol
|
|
|
|
|
+
|
|
|
|
|
+The server uses the standard GraphQL over WebSocket protocol (`graphql-ws`):
|
|
|
|
|
+
|
|
|
|
|
+#### Connection Initialization
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "type": "connection_init",
|
|
|
|
|
+ "payload": {
|
|
|
|
|
+ "Authorization": "Bearer <token>"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Subscribe to a Topic
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "id": "1",
|
|
|
|
|
+ "type": "start",
|
|
|
|
|
+ "payload": {
|
|
|
|
|
+ "query": "subscription { taskCreated { id title assigneeId } }"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Receive Events
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "id": "1",
|
|
|
|
|
+ "type": "data",
|
|
|
|
|
+ "payload": {
|
|
|
|
|
+ "data": {
|
|
|
|
|
+ "taskCreated": {
|
|
|
|
|
+ "id": "5",
|
|
|
|
|
+ "title": "New Task",
|
|
|
|
|
+ "assigneeId": "2"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Unsubscribe
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "id": "1",
|
|
|
|
|
+ "type": "stop"
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Example: JavaScript/TypeScript Client
|
|
|
|
|
+
|
|
|
|
|
+Using the `graphql-ws` library:
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+import { createClient } from 'graphql-ws';
|
|
|
|
|
+
|
|
|
|
|
+const client = createClient({
|
|
|
|
|
+ url: 'wss://api.example.com/query',
|
|
|
|
|
+ connectionParams: {
|
|
|
|
|
+ Authorization: `Bearer ${token}`,
|
|
|
|
|
+ },
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// Subscribe to taskCreated events
|
|
|
|
|
+const unsubscribe = client.subscribe(
|
|
|
|
|
+ {
|
|
|
|
|
+ query: `subscription { taskCreated { id title assigneeId } }`,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ next: (data) => {
|
|
|
|
|
+ console.log('Task created:', data.data.taskCreated);
|
|
|
|
|
+ },
|
|
|
|
|
+ error: (error) => {
|
|
|
|
|
+ console.error('Subscription error:', error);
|
|
|
|
|
+ },
|
|
|
|
|
+ complete: () => {
|
|
|
|
|
+ console.log('Subscription closed');
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+// Later: unsubscribe
|
|
|
|
|
+unsubscribe();
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Example: Go Client
|
|
|
|
|
+
|
|
|
|
|
+Using `github.com/99designs/gqlgen/client`:
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+import (
|
|
|
|
|
+ "github.com/99designs/gqlgen/client"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// Create WebSocket client with authentication
|
|
|
|
|
+wsClient := client.New(server, client.AddHeader("Authorization", "Bearer "+token))
|
|
|
|
|
+
|
|
|
|
|
+// Subscribe to taskCreated
|
|
|
|
|
+subscription := wsClient.Websocket(`subscription { taskCreated { id title } }`)
|
|
|
|
|
+defer subscription.Close()
|
|
|
|
|
+
|
|
|
|
|
+for {
|
|
|
|
|
+ var response struct {
|
|
|
|
|
+ TaskCreated *model.Task `json:"taskCreated"`
|
|
|
|
|
+ }
|
|
|
|
|
+ err := subscription.Next(&response)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ break // Connection closed or error
|
|
|
|
|
+ }
|
|
|
|
|
+ if response.TaskCreated != nil {
|
|
|
|
|
+ fmt.Printf("Task created: %s\n", response.TaskCreated.Title)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Subscription Use Cases
|
|
|
|
|
+
|
|
|
|
|
+| Use Case | Subscription | Notes |
|
|
|
|
|
+|----------|--------------|-------|
|
|
|
|
|
+| Task assignment notifications | `taskCreated` | User receives event when assigned a new task |
|
|
|
|
|
+| Task status updates | `taskUpdated` | User receives event when their assigned task is modified |
|
|
|
|
|
+| Task removal | `taskDeleted` | User receives event when their assigned task is deleted |
|
|
|
|
|
+| Chat messages | `messageAdded` | All channel participants receive new message events |
|
|
|
|
|
+
|
|
|
|
|
+### Best Practices
|
|
|
|
|
+
|
|
|
|
|
+1. **Reconnect on Disconnect**: Implement automatic reconnection with exponential backoff
|
|
|
|
|
+2. **Handle Auth Errors**: If authentication fails, re-authenticate and retry
|
|
|
|
|
+3. **Filter Client-Side**: Even though server filters, consider additional client-side filtering if needed
|
|
|
|
|
+4. **Connection Management**: Close subscriptions when no longer needed to free resources
|
|
|
|
|
+5. **Error Handling**: Always handle subscription errors gracefully
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Data Types Reference
|
|
|
|
|
+
|
|
|
|
|
+### Scalar Types
|
|
|
|
|
+
|
|
|
|
|
+| Type | Description |
|
|
|
|
|
+|------|-------------|
|
|
|
|
|
+| `ID` | Unique identifier (string representation) |
|
|
|
|
|
+| `String` | UTF-8 string |
|
|
|
|
|
+| `Boolean` | true or false |
|
|
|
|
|
+
|
|
|
|
|
+### Enum Values
|
|
|
|
|
+
|
|
|
|
|
+**Task Priority**: `low`, `medium`, `high` (string values)
|
|
|
|
|
+
|
|
|
|
|
+**Task Status Codes**: Customizable (e.g., `open`, `in_progress`, `done`)
|
|
|
|
|
+
|
|
|
|
|
+### Date/Time Format
|
|
|
|
|
+
|
|
|
|
|
+All timestamps use **ISO 8601 / RFC 3339** format:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+2024-01-15T10:30:00Z
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Quick Reference
|
|
|
|
|
+
|
|
|
|
|
+### Authentication Header
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Typical Client Flow
|
|
|
|
|
+
|
|
|
|
|
+1. **Login** → Obtain JWT token
|
|
|
|
|
+2. **Store Token** → Securely persist the token
|
|
|
|
|
+3. **Authenticated Requests** → Include token in all subsequent requests
|
|
|
|
|
+4. **Handle Errors** → Check for authentication/authorization errors
|
|
|
|
|
+5. **Token Refresh** → Token has long expiration (10 years), but handle expiry if needed
|
|
|
|
|
+
|
|
|
|
|
+### Permission Check Pattern
|
|
|
|
|
+
|
|
|
|
|
+Before performing privileged operations, verify the user has the required permission by checking the `permissions` array in the JWT claims or the user's roles.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Implementation Tips
|
|
|
|
|
+
|
|
|
|
|
+### Language-Agnostic Considerations
|
|
|
|
|
+
|
|
|
|
|
+1. **Use a GraphQL Client Library**: Most languages have mature GraphQL clients (Apollo, urql, gql-request, etc.)
|
|
|
|
|
+2. **Handle JWT Securely**: Store tokens securely; never in localStorage for web apps (use httpOnly cookies or secure storage)
|
|
|
|
|
+3. **Implement Retry Logic**: For network failures, implement exponential backoff
|
|
|
|
|
+4. **Cache Responses**: Use client-side caching to reduce redundant queries
|
|
|
|
|
+5. **Batch Requests**: Combine multiple queries in a single request when possible
|
|
|
|
|
+
|
|
|
|
|
+### Example HTTP Request
|
|
|
|
|
+
|
|
|
|
|
+```http
|
|
|
|
|
+POST /query HTTP/1.1
|
|
|
|
|
+Host: api.example.com
|
|
|
|
|
+Content-Type: application/json
|
|
|
|
|
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
|
|
|
+
|
|
|
|
|
+{
|
|
|
|
|
+ "query": "query { users { id email } }"
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Example cURL Command
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+curl -X POST https://api.example.com/query \
|
|
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
|
|
+ -H "Authorization: Bearer YOUR_TOKEN" \
|
|
|
|
|
+ -d '{"query": "query { users { id email } }"}'
|
|
|
|
|
+```
|