diff --git a/README.md b/README.md index 8f7449ca..17f71e97 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,12 @@ npx directus-sync pull Retrieves the current schema and collections from Directus and stores them locally. This command does not modify the database. +It also retrieves the specifications (GraphQL & OpenAPI) and stores them locally. +It gets specifications from the `/server/specs/*` endpoints: + +- [OpenAPI](https://docs.directus.io/reference/system/server.html#get-openapi-specification) +- [GraphQL SDL (Item & System scopes)](https://docs.directus.io/reference/system/server.html#get-graphql-schema) + ### Diff ```shell @@ -101,9 +107,6 @@ These options can be used with any command to configure the operation of `direct - `-p, --directus-password ` Provide the Directus password. Alternatively, set the `DIRECTUS_ADMIN_PASSWORD` environment variable. -- `--no-split` - Indicates whether the schema snapshot should be split into multiple files. By default, snapshots are split. - - `--dump-path ` Set the base path for the dump. This must be an absolute path. The default is `"./directus-config"`. @@ -114,9 +117,18 @@ These options can be used with any command to configure the operation of `direct - `--snapshot-path ` Specify the path for the schema snapshot dump, relative to the dump path. The default is `"snapshot"`. +- `--no-split` + Indicates whether the schema snapshot should be split into multiple files. By default, snapshots are split. + - `-f, --force` Force the diff of schema, even if the Directus version is different. The default is `false`. +- `--specs-path ` + Specify the path for the specifications dump (GraphQL & OpenAPI), relative to the dump path. The default is `"specs"`. + +- `--no-specs` + Do not dump the specifications (GraphQL & OpenAPI). By default, specifications are dumped. + - `-h, --help` Display help information for the `directus-sync` commands. @@ -146,6 +158,8 @@ module.exports = { dumpPath: './directus-config', collectionsPath: 'collections', snapshotPath: 'snapshot', + specsPath: 'specs', + specs: true, }; ``` diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fba72049..1c3a6e40 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -47,7 +47,7 @@ const configPathOption = new Option( // Shared options const noSplitOption = new Option( '--no-split', - `should the schema snapshot be split into multiple files (default "${DefaultConfig.split}")`, + `should split the schema snapshot into multiple files (default "${DefaultConfig.split}")`, ); const dumpPathOption = new Option( '--dump-path ', @@ -65,6 +65,14 @@ const forceOption = new Option( '-f, --force', `force the diff of schema, even if the Directus version is different (default "${DefaultConfig.force}")`, ); +const specificationsPathOption = new Option( + '--specs-path ', + `the path for the specifications dump (GraphQL & OpenAPI), relative to the dump path (default "${DefaultConfig.specsPath}")`, +); +const noSpecificationsOption = new Option( + '--no-specs', + `should dump the GraphQL & OpenAPI specifications (default "${DefaultConfig.specs}")`, +); program .version(getVersion()) @@ -82,6 +90,8 @@ program .addOption(dumpPathOption) .addOption(collectionsPathOption) .addOption(snapshotPathOption) + .addOption(noSpecificationsOption) + .addOption(specificationsPathOption) .action(wrapAction(runPull)); program @@ -132,6 +142,9 @@ function cleanCommandOptions(commandOptions: Record) { if (commandOptions.split === true) { delete commandOptions.split; } + if (commandOptions.specs === true) { + delete commandOptions.specs; + } return commandOptions; } diff --git a/packages/cli/src/lib/commands/pull.ts b/packages/cli/src/lib/commands/pull.ts index ce5453cc..72f23f9f 100644 --- a/packages/cli/src/lib/commands/pull.ts +++ b/packages/cli/src/lib/commands/pull.ts @@ -1,11 +1,14 @@ import { Container } from 'typedi'; -import { SnapshotClient } from '../services'; +import { SnapshotClient, SpecificationsClient } from '../services'; import { loadCollections } from '../loader'; export async function runPull() { // Snapshot await Container.get(SnapshotClient).pull(); + // Specifications + await Container.get(SpecificationsClient).pull(); + // Collections const collections = loadCollections(); for (const collection of collections) { diff --git a/packages/cli/src/lib/services/config/config.ts b/packages/cli/src/lib/services/config/config.ts index e5e6b16a..7bbc5909 100644 --- a/packages/cli/src/lib/services/config/config.ts +++ b/packages/cli/src/lib/services/config/config.ts @@ -59,6 +59,17 @@ export class ConfigService { }; } + @Cacheable() + getSpecificationsConfig() { + const dumpPath = Path.resolve(this.requireOptions('dumpPath')); + const specificationsSubPath = this.requireOptions('specsPath'); + const specificationsPath = Path.resolve(dumpPath, specificationsSubPath); + return { + dumpPath: specificationsPath, + enabled: this.requireOptions('specs'), + }; + } + /** * Returns the Directus config, either with a token or with an email/password */ diff --git a/packages/cli/src/lib/services/config/default-config.ts b/packages/cli/src/lib/services/config/default-config.ts index 4b966f8c..e772ab7a 100644 --- a/packages/cli/src/lib/services/config/default-config.ts +++ b/packages/cli/src/lib/services/config/default-config.ts @@ -8,7 +8,14 @@ export const DefaultConfigPaths = [ export const DefaultConfig: Pick< Options, - 'debug' | 'dumpPath' | 'collectionsPath' | 'snapshotPath' | 'split' | 'force' + | 'debug' + | 'dumpPath' + | 'collectionsPath' + | 'snapshotPath' + | 'split' + | 'force' + | 'specs' + | 'specsPath' > = { // Global debug: false, @@ -17,6 +24,9 @@ export const DefaultConfig: Pick< collectionsPath: 'collections', snapshotPath: 'snapshot', split: true, + // Specifications + specs: true, + specsPath: 'specs', // Diff, push force: false, }; diff --git a/packages/cli/src/lib/services/config/schema.ts b/packages/cli/src/lib/services/config/schema.ts index 5d0dee3a..b39e5206 100644 --- a/packages/cli/src/lib/services/config/schema.ts +++ b/packages/cli/src/lib/services/config/schema.ts @@ -34,6 +34,9 @@ export const OptionsFields = { dumpPath: z.string(), collectionsPath: z.string(), snapshotPath: z.string(), + // Specifications + specs: z.boolean(), + specsPath: z.string(), // Diff, push force: z.boolean(), // Untrack @@ -58,6 +61,9 @@ export const ConfigFileOptionsSchema = z.object({ dumpPath: OptionsFields.dumpPath.optional(), collectionsPath: OptionsFields.collectionsPath.optional(), snapshotPath: OptionsFields.snapshotPath.optional(), + // Specifications config + specs: OptionsFields.specs.optional(), + specsPath: OptionsFields.specsPath.optional(), // Hooks config hooks: OptionsHooksSchema.optional(), }); diff --git a/packages/cli/src/lib/services/index.ts b/packages/cli/src/lib/services/index.ts index df99cc70..6f308199 100644 --- a/packages/cli/src/lib/services/index.ts +++ b/packages/cli/src/lib/services/index.ts @@ -1,4 +1,5 @@ export * from './config'; export * from './migration-client'; export * from './snapshot'; +export * from './specifications'; export * from './collections'; diff --git a/packages/cli/src/lib/services/specifications/index.ts b/packages/cli/src/lib/services/specifications/index.ts new file mode 100644 index 00000000..213b409b --- /dev/null +++ b/packages/cli/src/lib/services/specifications/index.ts @@ -0,0 +1 @@ +export * from './specifications-client'; diff --git a/packages/cli/src/lib/services/specifications/specifications-client.ts b/packages/cli/src/lib/services/specifications/specifications-client.ts new file mode 100644 index 00000000..32a105f7 --- /dev/null +++ b/packages/cli/src/lib/services/specifications/specifications-client.ts @@ -0,0 +1,96 @@ +import { Inject, Service } from 'typedi'; +import { MigrationClient } from '../migration-client'; +import { + OpenApiSpecOutput, + readGraphqlSdl, + readOpenApiSpec, +} from '@directus/sdk'; +import path from 'path'; +import { mkdirpSync, removeSync, writeJsonSync } from 'fs-extra'; +import { LOGGER } from '../../constants'; +import pino from 'pino'; +import { getChildLogger } from '../../helpers'; +import { ConfigService } from '../config'; +import { writeFileSync } from 'node:fs'; + +const ITEM_GRAPHQL_FILENAME = 'item.graphql'; +const SYSTEM_GRAPHQL_FILENAME = 'system.graphql'; +const OPENAPI_FILENAME = 'openapi.json'; + +@Service() +export class SpecificationsClient { + protected readonly dumpPath: string; + + protected readonly enabled: boolean; + + protected readonly logger: pino.Logger; + + constructor( + config: ConfigService, + @Inject(LOGGER) baseLogger: pino.Logger, + protected readonly migrationClient: MigrationClient, + ) { + this.logger = getChildLogger(baseLogger, 'snapshot'); + const { dumpPath, enabled } = config.getSpecificationsConfig(); + this.dumpPath = dumpPath; + this.enabled = enabled; + } + + /** + * Save the snapshot locally + */ + async pull() { + if (!this.enabled) { + return; + } + + const itemGraphQL = await this.getGraphQL('item'); + this.saveGraphQLData(itemGraphQL, ITEM_GRAPHQL_FILENAME); + this.logger.debug(`Saved Item GraphQL schema to ${this.dumpPath}`); + + const systemGraphQL = await this.getGraphQL('system'); + this.saveGraphQLData(systemGraphQL, SYSTEM_GRAPHQL_FILENAME); + this.logger.debug(`Saved System GraphQL schema to ${this.dumpPath}`); + + const openapi = await this.getOpenAPI(); + this.saveOpenAPIData(openapi); + this.logger.debug(`Saved OpenAPI specification to ${this.dumpPath}`); + } + + /** + * Get GraphQL SDL from the server + */ + protected async getGraphQL(scope?: 'item' | 'system') { + const directus = await this.migrationClient.get(); + const response = await directus.request(readGraphqlSdl(scope)); + return await response.text(); + } + + /** + * Get OpenAPI specifications from the server + */ + protected async getOpenAPI() { + const directus = await this.migrationClient.get(); + return await directus.request(readOpenApiSpec()); + } + + /** + * Save the GraphQL data to the dump file. + */ + protected saveGraphQLData(data: string, filename: string): void { + mkdirpSync(this.dumpPath); + const filePath = path.join(this.dumpPath, filename); + removeSync(filePath); + writeFileSync(filePath, data); + } + + /** + * Save the OpenAPI JSON data to the dump file. + */ + protected saveOpenAPIData(data: OpenApiSpecOutput): void { + mkdirpSync(this.dumpPath); + const filePath = path.join(this.dumpPath, OPENAPI_FILENAME); + removeSync(filePath); + writeJsonSync(filePath, data, { spaces: 2 }); + } +}