Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pull OpenAPI & GraphQL specifications #35

Merged
merged 9 commits into from
Mar 11, 2024
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 });
}
}
Loading