Files
FlociClone/docs/services/appsync.md
T
2026-09-22 08:57:00 -04:00

408 lines
20 KiB
Markdown

# AppSync
**Protocol:** REST JSON
**Endpoint:** `http://localhost:4566/v1/apis/...`
Floci implements the AWS AppSync Management API, providing local emulation of GraphQL API configuration, schema management, data source binding, resolver mapping, API key provisioning, custom domains, and channel namespaces.
## OIDC issuer network policy
AppSync OIDC authentication uses the shared JWT issuer policy. By default, issuer discovery and
JWKS requests require HTTPS and reject local, private, link-local, and other non-public addresses.
For an isolated development environment, set `FLOCI_SECURITY_ALLOW_PRIVATE_JWT_TARGETS=true`.
This also applies to API Gateway HTTP API JWT authorizers. The option permits private HTTPS
targets and HTTP URLs that use a literal private or loopback address.
## Supported Operations
### GraphQL API
| Operation | Description |
|---|---|
| `CreateGraphqlApi` | Create a GraphQL API |
| `GetGraphqlApi` | Get a GraphQL API by ID |
| `UpdateGraphqlApi` | Update a GraphQL API |
| `DeleteGraphqlApi` | Delete a GraphQL API and all child resources |
| `ListGraphqlApis` | List all GraphQL APIs |
### Schema
| Operation | Description |
|---|---|
| `StartSchemaCreation` | Start schema creation: validates and parses SDL via the GraphQL sidecar (invalid SDL returns 400) |
| `GetSchemaCreationStatus` | Get schema creation status |
| `GetIntrospectionSchema` | Get the introspection schema |
### Data Sources
| Operation | Description |
|---|---|
| `CreateDataSource` | Create a data source |
| `GetDataSource` | Get a data source by name |
| `UpdateDataSource` | Update a data source |
| `DeleteDataSource` | Delete a data source |
| `ListDataSources` | List all data sources for an API |
### Resolvers
| Operation | Description |
|---|---|
| `CreateResolver` | Create a resolver |
| `GetResolver` | Get a resolver by type and field |
| `UpdateResolver` | Update a resolver |
| `DeleteResolver` | Delete a resolver |
| `ListResolvers` | List all resolvers for an API |
| `ListResolversByType` | List resolvers for a specific type |
| `ListResolversByFunction` | List resolvers attached to a specific function |
### Functions
| Operation | Description |
|---|---|
| `CreateFunction` | Create a function configuration |
| `GetFunction` | Get a function by ID |
| `UpdateFunction` | Update a function |
| `DeleteFunction` | Delete a function |
| `ListFunctions` | List all functions for an API |
### Types
| Operation | Description |
|---|---|
| `CreateType` | Create a type |
| `GetType` | Get a type by name |
| `UpdateType` | Update a type |
| `DeleteType` | Delete a type |
| `ListTypes` | List all types for an API |
### API Keys
| Operation | Description |
|---|---|
| `CreateApiKey` | Create an API key |
| `GetApiKey` | Get an API key by ID |
| `UpdateApiKey` | Update an API key |
| `DeleteApiKey` | Delete an API key |
| `ListApiKeys` | List all API keys for an API |
As on AWS, `ApiKey.id` is the key value itself (`da2-` followed by 26 lowercase alphanumerics) and is what clients send in the `x-api-key` header. There is no separate secret field.
### Tags
| Operation | Description |
|---|---|
| `TagResource` | Add tags to a resource |
| `UntagResource` | Remove tags from a resource |
| `ListTagsForResource` | List tags on a resource |
### Environment Variables
| Operation | Description |
|---|---|
| `GetEnvironmentVariables` | Get environment variables for an API |
| `PutEnvironmentVariables` | Set environment variables for an API |
### Domain Names
| Operation | Description |
|---|---|
| `CreateDomainName` | Register a custom domain name |
| `GetDomainName` | Get domain name configuration |
| `UpdateDomainName` | Update domain name description |
| `ListDomainNames` | List all domain names |
| `DeleteDomainName` | Delete a custom domain name |
| `AssociateApi` | Associate a domain name with a GraphQL API |
| `GetAssociatedApi` | Get the API associated with a domain name |
| `DisassociateApi` | Disassociate a domain name from a GraphQL API |
| `ListApiAssociations` | List all associations for an API |
### Channel Namespaces
| Operation | Description |
|---|---|
| `CreateChannelNamespace` | Create a channel namespace |
| `GetChannelNamespace` | Get a channel namespace by name |
| `UpdateChannelNamespace` | Update a channel namespace description |
| `ListChannelNamespaces` | List all channel namespaces for an API |
| `DeleteChannelNamespace` | Delete a channel namespace |
### Merged API Associations
| Operation | Description |
|---|---|
| `CreateApiAssociation` | Associate a source API with a merged API |
| `GetApiAssociation` | Get a merged API association |
| `DeleteApiAssociation` | Delete a merged API association |
| `ListApiAssociations` | List all merged API associations |
### Enhanced Metrics
| Operation | Description |
|---|---|
| `GetEnhancedMetricsConfig` | Get the enhanced metrics configuration |
## Schema Registry
Schema parsing and query execution run in the **GraphQL sidecar** (issue #2917), not in Floci's
own process: [graphql-java](https://github.com/graphql-java/graphql-java) is a ~3.8MB dependency
used only by AppSync, so keeping it out of Floci's JVM/native image altogether, mirroring the
Cedar sidecar pattern already used for Verified Permissions, avoids paying that cost in every
build regardless of whether AppSync is ever used. The sidecar is `floci/floci-sidecar-graphql`,
published from [floci-io/floci-sidecars](https://github.com/floci-io/floci-sidecars) and pinned to
an exact version (a cached `latest` is never re-pulled): pull it ahead of time on an air-gapped
host with `docker pull floci/floci-sidecar-graphql:0.2.0`. `GraphqlSidecarManager` lazily starts
the sidecar container on first use and checks the contract version it reports on `/health`; a
sidecar speaking another contract major is refused immediately with an `InternalServerException`
naming the image and the variable to change. `floci.services.appsync.graphql-url` points at an
already-running instance instead (Docker Compose setups) and is checked the same way.
The sidecar itself carries no AppSync-specific knowledge: no `@aws_auth`, no IAM, no directive
semantics, and no AWS scalar vocabulary either. `StartSchemaCreation` sends the SDL (with
AppSync's directive declarations and the 17 custom scalar types injected, customers never declare
those themselves) plus a `scalars` mapping (each AppSync scalar name onto one of the sidecar's
generic coercion kinds, e.g. `AWSDateTime` onto `date-time`) to the sidecar's
`/v1/schema/validate`, which parses and compiles it generically. Invalid schemas are rejected
asynchronously (status `FAILED` with details after `PROCESSING`), using the sidecar's structured,
per-problem errors to build the same `codeErrors` shape this API always returned. Valid schemas
are registered in Floci's own `SchemaRegistry` (now just a raw-SDL cache; nothing is compiled
in-process) and persisted to the schema store.
At execution time, Floci calls the sidecar's `/v1/plan` to learn which `(type, field)` coordinates
a query will visit and what directives are on each, computes `@aws_auth`/IAM/Cognito/Lambda
authorization decisions itself (this AWS-specific logic never runs in the sidecar), and passes any
denied coordinates to `/v1/execute` as an opaque list; the sidecar nulls those fields out with the
given error, without ever knowing why.
On emulator startup, after storage load and orphan recovery, Floci **rehydrates** SUCCESS SDLs
from the schema store into `SchemaRegistry` so `POST /v1/apis/{apiId}/graphql` works across
restarts (memory/persistent/hybrid/wal), without re-validating against the sidecar, since a
persisted SDL already passed validation once and restarts shouldn't eagerly start a container that
might otherwise never be needed.
The following **AWS scalar types** are pre-registered and available in any schema without requiring explicit `scalar` declarations:
| Scalar | Java Type | Validation |
|--------|-----------|------------|
| `AWSJSON` | String | Valid JSON syntax |
| `AWSDateTime` | String | ISO 8601 datetime |
| `AWSDate` | String | ISO 8601 date (yyyy-MM-dd) |
| `AWSTime` | String | ISO 8601 time |
| `AWSTimestamp` | Long | Unix epoch seconds (0 to 32503680000) |
| `AWSEmail` | String | RFC 5322 email format |
| `AWSURL` | String | Valid URL |
| `AWSPhone` | String | E.164 format (+1234567890) |
| `AWSIPAddress` | String | IPv4 or IPv6 |
| `AWSBoolean` | Boolean | Boolean value |
| `AWSLong` | Long | 64-bit signed integer |
| `AWSInteger` | Integer | 32-bit signed integer |
| `AWSShort` | Integer | 16-bit signed integer (-32768 to 32767) |
| `AWSFloat` | Double | IEEE 754 double-precision |
| `AWSBigDecimal` | String | Arbitrary-precision decimal |
| `AWSBigInt` | String | Arbitrary-precision integer |
| `AWSByte` | String | Base64-encoded byte array |
The following **AppSync directives** are pre-defined and recognized in schemas:
| Directive | Locations | Purpose |
|-----------|-----------|---------|
| `@aws_api_key` | OBJECT, FIELD_DEFINITION | Require API key auth |
| `@aws_iam` | OBJECT, FIELD_DEFINITION | Require IAM auth |
| `@aws_cognito_user_pools(cognito_groups: [String!]!)` | OBJECT, FIELD_DEFINITION | Require Cognito user pool auth |
| `@aws_oidc` | OBJECT, FIELD_DEFINITION | Require OIDC auth |
| `@aws_lambda` | OBJECT, FIELD_DEFINITION | Require Lambda auth |
| `@aws_subscribe(mutations: [String!]!)` | FIELD_DEFINITION | Link subscription to mutation |
| `@aws_auth(cognito_groups: [String!]!)` | OBJECT, FIELD_DEFINITION | Require Cognito groups (ignored when additional auth modes exist) |
| `@aws_delta_sync` | OBJECT | Delta sync configuration |
Unknown directives are rejected during schema registration.
Schema extensions (`extend type Query { ... }`) are supported natively through graphql-java, running in the sidecar.
## GraphQL execute (data-plane)
| Surface | Path | Content-Types |
|---|---|---|
| HTTP GraphQL | `POST /v1/apis/{apiId}/graphql` | `application/json`, `application/graphql` (+ charset) |
Execute is a **separate** data-plane endpoint from the management API. Request body is GraphQL-over-HTTP JSON: `{ "query", "variables?", "operationName?" }`.
Responses are `application/json` with AWS AppSync wire shapes (`data` / `errors[]` with top-level `errorType` / `errorInfo`). Most GraphQL syntax and validation errors return **HTTP 200** with `errors[]`.
| Case | HTTP | Notes |
|---|---|---|
| Query / introspection / validation / syntax (incl. blank `query`) | 200 | Nullable fields may be `null` until DataFetchers (Phase 8) |
| HTTP subscription operation | 200 | `OperationNotSupported` (realtime WebSocket is a later phase) |
| Empty body / `{}` / `[]` / unparseable JSON / bad Content-Type | 400 | `MalformedHttpRequestException` |
| Missing `operationName` with multiple operations | 400 | `BadRequestException` — `Missing operation name.` |
| Unknown `apiId` | 404 | `NotFoundException` |
| API exists but no executable schema (incl. PROCESSING) | **502** | `GraphQLSchemaException` — `No schema definition exists.` + `x-amzn-errortype` |
| Unexpected failure | 500 | `InternalFailure` |
| Missing/invalid/expired credentials, unconfigured mode, Lambda deny | **401** | `UnauthorizedException` — GraphQL does not run; `x-amzn-errortype` is set. Missing headers use message `Missing authorization header`. |
| Field directive mismatch, Cognito group miss, Lambda `deniedFields`, IAM field DENY | **200** | Field is `null` and `errors[]` contains `Unauthorized` — `Not Authorized to access {field} on type {type}` (no `x-amzn-errortype`) |
**Evidence for data-plane statuses** (empty/`[]`/`{}` → 400; missing schema → 502): AppSync team sample in [graphql/graphql-over-http#81](https://github.com/graphql/graphql-over-http/issues/81) (@robzhu). The management API Reference lists `GraphQLSchemaException` as HTTP 400 for “schema not valid” on management operations — a different surface than the GraphQL execute data plane.
### Execute authentication
Request auth runs after Content-Type and body parse and after API lookup (unknown `apiId` is still 404). It runs before schema lookup, so a missing schema with no credentials is 401, not 502.
Headers are classified by **shape** (not primary-then-fallback). If both `x-api-key` and SigV4 `Authorization` are present, **SigV4 wins**. AppSync does not fall back to the API key when IAM validation fails.
Missing required auth headers return HTTP **401** `UnauthorizedException` with message `Missing authorization header`. Invalid or expired credentials that are present still return 401 with `You are not authorized to make this call.`
| Header shape | Mode |
|---|---|
| `Authorization` starts with `AWS4-HMAC-SHA256` | `AWS_IAM` (wins over `x-api-key` if both are present) |
| `x-api-key` present | `API_KEY` |
| `Authorization: Bearer <jwt>` | Cognito and/or OIDC (matched by `iss` / `aud` or `azp`) |
| Other `Authorization` | `AWS_LAMBDA` |
Configured modes are the API default `authenticationType` plus `additionalAuthenticationProviders`. A classified mode that is not configured returns 401.
| Mode | Emulator notes |
|---|---|
| API_KEY | Lookup by `ApiKey.id`, which is the key value (`da2-…`). Identity is absent (not `{}`). Default key expiry is 7 days when `expires` is omitted; stored `expires` is rounded down to the nearest hour. Create/UpdateApiKey require `expires` between 1 and 365 days from now (`ApiKeyValidityOutOfBoundsException`, 400). `deletes` is `expires` plus 60 days. |
| AWS_IAM | Verifies a real header-signed SigV4 request (`appsync` service, fixed `/v1/apis/{apiId}/graphql` canonical path, 5-minute clock skew): the `Credential=` access key must resolve to a secret via `IamService`, and the signature must match. The legacy `test`/`test` pair is still emulator ALLOW, but it must be signed with secret `test` like any other key, and it is not a bypass. A temporary (`ASIA...`) credential must also present the `X-Amz-Security-Token` header matching the one issued for it. An unknown or unsigned key is always 401 and never becomes the account-root identity. Known keys additionally evaluate `appsync:GraphQL`. |
| Cognito / OIDC | JWT signature is verified, not just decoded. Cognito checks the token against the issuing user pool's own RS256 signing key (`alg`, `kid`, issuer, audience/`clientId`, expiry); OIDC checks it against the configured issuer's published JWKS (via OIDC discovery), the same way the HTTP API JWT authorizer does. Both fail closed: an unreachable issuer, unsupported algorithm (including `none`), unmatched `kid`, or bad signature is 401. OIDC as the sole mode still skips the token's own `iss` claim check, but the signature is always verified against the configured issuer's keys. OIDC identity is `{sub, issuer, claims}` (no `sourceIp`). |
| Lambda | AppSync `isAuthorized` contract via `LambdaService.invoke` (not an API Gateway policy document). |
SDL field auth: unmarked fields require the API **default** mode. Additional modes unlock fields tagged `@aws_api_key` / `@aws_iam` / `@aws_oidc` / `@aws_cognito_user_pools` / `@aws_lambda`. Multiple directives on a field are OR. Field-level directives override type-level. `@aws_auth` is allowed on `OBJECT \| FIELD_DEFINITION` and is ignored when additional modes exist.
Duplicate `API_KEY` / `AWS_IAM` / `AWS_LAMBDA` (and the same Cognito pool or OIDC issuer) between default and additional providers is rejected on create/update with management 400 `BadRequestException`: `Authentication type {TYPE} for additional authentication provider {N} already specified on the API. It can only be specified once.` (`N` is 1-based in `additionalAuthenticationProviders`).
## Pagination
All `List` operations support cursor-based pagination via query parameters:
| Parameter | Description |
|---|---|
| `maxResults` | Maximum number of items to return |
| `nextToken` | Opaque token for the next page |
The `nextToken` is a Base64 URL-encoded integer offset. A missing token starts from offset 0. An invalid token returns `InvalidNextTokenException` (400).
```bash
# First page
aws appsync list-graphql-apis \
--max-results 10 \
--endpoint-url $AWS_ENDPOINT_URL
# Next page (use the nextToken from previous response)
aws appsync list-graphql-apis \
--max-results 10 \
--next-token "eyJvZmZzZXQiOjEwfQ==" \
--endpoint-url $AWS_ENDPOINT_URL
```
## Cascade Delete
Deleting a GraphQL API (`DeleteGraphqlApi`) automatically deletes all child resources:
- Schema and schema creation status
- All data sources
- All resolvers
- All functions
- All types
- All API keys
- All channel namespaces
- All domain name associations
This matches AWS behavior where deleting an API removes its entire configuration.
## Not Implemented
These AWS AppSync capabilities are not yet implemented and are tracked in future phases:
- **DataFetcher / resolver dispatch** (Phase 8): resolver mapping templates and field resolution with non-null values
- **Data source adapters** (Phase 9): DynamoDB, Lambda, HTTP, EventBridge, OpenSearch, RDS connectors
- **Guardrails** (Phase 10): query depth / complexity limits and related errors
- **Realtime subscriptions** (Phase 11+): WebSocket real-time subscriptions
- **Caching**: API-level and per-resolver caching
- **Merged API source management**: `AssociateMergedGraphqlApi`, `AssociateSourceGraphqlApi`, `StartSchemaMerge`, `ListTypesByAssociation`
- **Data source introspection**: `StartDataSourceIntrospection`, `GetDataSourceIntrospection`
## Configuration
| Variable | Default | Description |
|---|---|---|
| `FLOCI_SERVICES_APPSYNC_ENABLED` | `true` | Enable or disable the service |
| `FLOCI_SERVICES_APPSYNC_VTL_MAX_LOOPS` | `10000` | Maximum `#foreach` iterations a VTL resolver template may execute |
| `FLOCI_SERVICES_APPSYNC_VTL_MAX_OUTPUT_CHARS` | `1048576` | Maximum characters a VTL resolver template may render |
| `FLOCI_SERVICES_APPSYNC_VTL_TIMEOUT_MILLIS` | `5000` | Maximum wall-clock time a VTL resolver template may spend evaluating |
Request/response mapping templates render inside the same VTL reflection sandbox described for
API Gateway in [api-gateway.md](api-gateway.md#configuration) (`SecureUberspector`, with `Class`,
`ClassLoader`, `Runtime`, `ProcessBuilder`, `System`, `Thread`, `java.io.File` and related
classes/packages blocked), and are subject to the same three limits above. The loop cap truncates
a `#foreach` at the configured iteration count and lets the template finish rendering with
whatever output it produced up to that point; it does not fail the resolver. Exceeding the
output-size or execution-time limit does fail the resolver, the same way any other VTL evaluation
error does.
## Examples
```bash
export AWS_ENDPOINT_URL=http://localhost:4566
# Create a GraphQL API
aws appsync create-graphql-api \
--name my-api \
--authentication-type API_KEY \
--endpoint-url $AWS_ENDPOINT_URL
# Start schema creation
aws appsync start-schema-creation \
--api-id API_ID \
--definition 'type Query { hello: String }' \
--endpoint-url $AWS_ENDPOINT_URL
# Create a data source (NONE type for local resolvers)
aws appsync create-data-source \
--api-id API_ID \
--name my-datasource \
--type NONE \
--endpoint-url $AWS_ENDPOINT_URL
# Create a resolver
aws appsync create-resolver \
--api-id API_ID \
--type-name Query \
--field-name hello \
--data-source-name my-datasource \
--endpoint-url $AWS_ENDPOINT_URL
# Create an API key
aws appsync create-api-key \
--api-id API_ID \
--description "Test key" \
--endpoint-url $AWS_ENDPOINT_URL
# List all APIs
aws appsync list-graphql-apis \
--endpoint-url $AWS_ENDPOINT_URL
# Register a custom domain
aws appsync create-domain-name \
--domain-name api.example.com \
--certificate-arn arn:aws:acm:us-east-1:000000000000:certificate/123 \
--endpoint-url $AWS_ENDPOINT_URL
# Associate domain with API
aws appsync associate-api \
--domain-name api.example.com \
--api-id API_ID \
--endpoint-url $AWS_ENDPOINT_URL
# Execute a GraphQL query (data-plane; send credentials for the API auth mode)
curl -s -X POST "$AWS_ENDPOINT_URL/v1/apis/API_ID/graphql" \
-H "Content-Type: application/json" \
-H "x-api-key: da2-YOUR_API_KEY" \
-d '{"query":"{ hello }"}'
# Create a channel namespace
aws appsync create-channel-namespace \
--api-id API_ID \
--name my-channels \
--endpoint-url $AWS_ENDPOINT_URL
```