Skip to content

Commit

Permalink
feat: pull OpenAPI & GraphQL specifications (#35)
Browse files Browse the repository at this point in the history
* feat: add service to pull specs from remote

* refactor: change specifications to specs

* chore: typo in split option

* feat: pull system graphql specs

* refactor: explicitly store items schema as item.graphql

* fix: avoid specs option override

* docs: add details in README.md

* docs: re-order options list

* docs: add spaces in options list
  • Loading branch information
EdouardDem authored Mar 11, 2024
1 parent bd56e8e commit 5d41d07
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 6 deletions.
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -101,9 +107,6 @@ These options can be used with any command to configure the operation of `direct
- `-p, --directus-password <directusPassword>`
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 <dumpPath>`
Set the base path for the dump. This must be an absolute path. The default
is `"./directus-config"`.
Expand All @@ -114,9 +117,18 @@ These options can be used with any command to configure the operation of `direct
- `--snapshot-path <snapshotPath>`
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 <specsPath>`
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.

Expand Down Expand Up @@ -146,6 +158,8 @@ module.exports = {
dumpPath: './directus-config',
collectionsPath: 'collections',
snapshotPath: 'snapshot',
specsPath: 'specs',
specs: true,
};
```

Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dumpPath>',
Expand All @@ -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 <specsPath>',
`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())
Expand All @@ -82,6 +90,8 @@ program
.addOption(dumpPathOption)
.addOption(collectionsPathOption)
.addOption(snapshotPathOption)
.addOption(noSpecificationsOption)
.addOption(specificationsPathOption)
.action(wrapAction(runPull));

program
Expand Down Expand Up @@ -132,6 +142,9 @@ function cleanCommandOptions(commandOptions: Record<string, unknown>) {
if (commandOptions.split === true) {
delete commandOptions.split;
}
if (commandOptions.specs === true) {
delete commandOptions.specs;
}
return commandOptions;
}

Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/lib/commands/pull.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/lib/services/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/src/lib/services/config/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +24,9 @@ export const DefaultConfig: Pick<
collectionsPath: 'collections',
snapshotPath: 'snapshot',
split: true,
// Specifications
specs: true,
specsPath: 'specs',
// Diff, push
force: false,
};
6 changes: 6 additions & 0 deletions packages/cli/src/lib/services/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
});
1 change: 1 addition & 0 deletions packages/cli/src/lib/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './config';
export * from './migration-client';
export * from './snapshot';
export * from './specifications';
export * from './collections';
1 change: 1 addition & 0 deletions packages/cli/src/lib/services/specifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './specifications-client';
Original file line number Diff line number Diff line change
@@ -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<Response>(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 });
}
}

0 comments on commit 5d41d07

Please sign in to comment.