Skip to content

Commit

Permalink
feat(hasura): metadata v3 support
Browse files Browse the repository at this point in the history
fixes #250
  • Loading branch information
WonderPanda committed Jul 1, 2021
1 parent 1e22974 commit 5e7135a
Show file tree
Hide file tree
Showing 28 changed files with 338 additions and 64 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"editor.formatOnSave": true
}
2 changes: 2 additions & 0 deletions packages/hasura/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
test/__fixtures__/hasura/metadata/databases/additional/tables/public_additional_table.yaml
test/__fixtures__/hasura/metadata/databases/default/tables/public_default_table.yaml
9 changes: 9 additions & 0 deletions packages/hasura/src/hasura.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export interface ScheduledEventRetryConfig extends EventRetryConfig {
}

export interface TrackedHasuraEventHandlerConfig {
/**
* This is required if using v3 metadata
*/
databaseName?: string;
schema?: string;
tableName: string;
triggerName: string;
Expand Down Expand Up @@ -134,6 +138,11 @@ export interface HasuraModuleConfig {
* amount of boilerplate
*/
managedMetaDataConfig?: {
/**
* The version of hasura metadata being targeted
*/
metadataVersion?: 2 | 3;

/**
* The ENV key in which Hasura will store the secret header value used to validate event payloads
*/
Expand Down
130 changes: 66 additions & 64 deletions packages/hasura/src/hasura.metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,63 +10,35 @@ import { readFileSync, writeFileSync } from 'fs';
import { orderBy } from 'lodash';
import {
TableEntry,
EventTriggerDefinition,
Columns,
CronTrigger,
} from './hasura-metadata-dist/HasuraMetadataV2';
import { mergeEventHandlerConfig } from './metadata/event-triggers';

const utf8 = 'utf-8';

const MISSING_META_CONFIG = 'No configuration for meta available';

const defaultHasuraRetryConfig: ScheduledEventRetryConfig = {
intervalInSeconds: 10,
numRetries: 3,
timeoutInSeconds: 60,
toleranceSeconds: 21600,
};

const convertEventTriggerDefinition = (
configDef: TrackedHasuraEventHandlerConfig['definition']
): EventTriggerDefinition => {
if (configDef.type === 'insert') {
return {
enable_manual: false,
insert: {
columns: Columns.Empty,
},
};
}

if (configDef.type === 'delete') {
return {
enable_manual: false,
delete: {
columns: Columns.Empty,
},
};
}

return {
enable_manual: false,
update: {
columns: configDef.columns ?? Columns.Empty,
},
};
};

export const isTrackedHasuraEventHandlerConfig = (
eventHandlerConfig: HasuraEventHandlerConfig | TrackedHasuraEventHandlerConfig
): eventHandlerConfig is TrackedHasuraEventHandlerConfig => {
return 'definition' in eventHandlerConfig;
};

export const updateEventTriggerMeta = (
const updateEventTriggerMetaV2 = (
moduleConfig: HasuraModuleConfig,
eventHandlerConfigs: TrackedHasuraEventHandlerConfig[]
) => {
const { managedMetaDataConfig } = moduleConfig;

if (!managedMetaDataConfig) {
throw new Error('No configuration for meta available');
throw new Error(MISSING_META_CONFIG);
}

const defaultRetryConfig =
Expand All @@ -78,13 +50,8 @@ export const updateEventTriggerMeta = (
const tableEntries = safeLoad(tablesMeta) as TableEntry[];

orderBy(eventHandlerConfigs, (x) => x.triggerName).forEach((config) => {
const {
schema = 'public',
tableName,
triggerName,
definition,
retryConfig = defaultRetryConfig,
} = config;
const { schema = 'public', tableName } = config;

const matchingTable = tableEntries.find(
(x) => x.table.schema === schema && x.table.name === tableName
);
Expand All @@ -95,44 +62,79 @@ export const updateEventTriggerMeta = (
);
}

const { intervalInSeconds, numRetries, timeoutInSeconds } = retryConfig;
const eventTriggers = (matchingTable.event_triggers ?? []).filter(
(x) => x.name !== triggerName
matchingTable.event_triggers = mergeEventHandlerConfig(
config,
moduleConfig,
defaultRetryConfig,
matchingTable
);

matchingTable.event_triggers = [
...eventTriggers,
{
name: triggerName,
definition: convertEventTriggerDefinition(definition),
retry_conf: {
num_retries: numRetries,
interval_sec: intervalInSeconds,
timeout_sec: timeoutInSeconds,
},
webhook_from_env: managedMetaDataConfig.nestEndpointEnvName,
headers: [
{
name: moduleConfig.webhookConfig.secretHeader,
value_from_env: managedMetaDataConfig.secretHeaderEnvName,
},
],
},
];
});

const yamlString = safeDump(tableEntries);
writeFileSync(tablesYamlPath, yamlString, utf8);
};

const updateEventTriggerMetaV3 = (
moduleConfig: HasuraModuleConfig,
eventHandlerConfigs: TrackedHasuraEventHandlerConfig[]
) => {
const { managedMetaDataConfig } = moduleConfig;

if (!managedMetaDataConfig) {
throw new Error(MISSING_META_CONFIG);
}

const defaultRetryConfig =
managedMetaDataConfig.defaultEventRetryConfig ?? defaultHasuraRetryConfig;

eventHandlerConfigs.forEach((config) => {
const { schema = 'public', databaseName = 'default', tableName } = config;

const tableYamlPath = `${managedMetaDataConfig.dirPath}/databases/${databaseName}/tables/${schema}_${tableName}.yaml`;
const tableMeta = readFileSync(tableYamlPath, utf8);
const tableEntry = safeLoad(tableMeta) as TableEntry;

tableEntry.event_triggers = mergeEventHandlerConfig(
config,
moduleConfig,
defaultRetryConfig,
tableEntry
);

const yamlString = safeDump(tableEntry);
writeFileSync(tableYamlPath, yamlString, utf8);
});
};

export const updateEventTriggerMeta = (
moduleConfig: HasuraModuleConfig,
eventHandlerConfigs: TrackedHasuraEventHandlerConfig[]
) => {
const { managedMetaDataConfig } = moduleConfig;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { metadataVersion = 3 } = moduleConfig.managedMetaDataConfig!;

if (!managedMetaDataConfig) {
throw new Error(MISSING_META_CONFIG);
}

if (metadataVersion === 2) {
updateEventTriggerMetaV2(moduleConfig, eventHandlerConfigs);
} else if (metadataVersion === 3) {
updateEventTriggerMetaV3(moduleConfig, eventHandlerConfigs);
} else {
throw new Error(`Invalid Hasura metadata version: ${metadataVersion}`);
}
};

export const updateScheduledEventTriggerMeta = (
moduleConfig: HasuraModuleConfig,
scheduledEventHandlerConfigs: TrackedHasuraScheduledEventHandlerConfig[]
) => {
const { managedMetaDataConfig } = moduleConfig;

if (!managedMetaDataConfig) {
throw new Error('No configuration for meta available');
throw new Error(MISSING_META_CONFIG);
}

const cronTriggersYamlPath = `${managedMetaDataConfig.dirPath}/cron_triggers.yaml`;
Expand Down
76 changes: 76 additions & 0 deletions packages/hasura/src/metadata/event-triggers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
Columns,
EventTriggerDefinition,
TableEntry,
} from '../hasura-metadata-dist/HasuraMetadataV2';
import {
HasuraModuleConfig,
ScheduledEventRetryConfig,
TrackedHasuraEventHandlerConfig,
} from '../hasura.interfaces';

const convertEventTriggerDefinition = (
configDef: TrackedHasuraEventHandlerConfig['definition']
): EventTriggerDefinition => {
if (configDef.type === 'insert') {
return {
enable_manual: false,
insert: {
columns: Columns.Empty,
},
};
}

if (configDef.type === 'delete') {
return {
enable_manual: false,
delete: {
columns: Columns.Empty,
},
};
}

return {
enable_manual: false,
update: {
columns: configDef.columns ?? Columns.Empty,
},
};
};

export const mergeEventHandlerConfig = (
config: TrackedHasuraEventHandlerConfig,
moduleConfig: HasuraModuleConfig,
defaultRetryConfig: ScheduledEventRetryConfig,
existingTableEntry: TableEntry
): TableEntry['event_triggers'] => {
const { managedMetaDataConfig } = moduleConfig;

const { triggerName, definition, retryConfig = defaultRetryConfig } = config;

const eventTriggers = (existingTableEntry.event_triggers ?? []).filter(
(x) => x.name !== triggerName
);

const { intervalInSeconds, numRetries, timeoutInSeconds } = retryConfig;

return [
...eventTriggers,
{
name: triggerName,
definition: convertEventTriggerDefinition(definition),
retry_conf: {
num_retries: numRetries,
interval_sec: intervalInSeconds,
timeout_sec: timeoutInSeconds,
},
webhook_from_env: managedMetaDataConfig?.nestEndpointEnvName,
headers: [
{
name: moduleConfig.webhookConfig.secretHeader,
value_from_env: managedMetaDataConfig?.secretHeaderEnvName,
},
],
},
];
};
Empty file.
100 changes: 100 additions & 0 deletions packages/hasura/src/tests/hasura.metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HasuraModule } from '../hasura.module';
import * as path from 'path';
import { INestApplication, Injectable } from '@nestjs/common';
import { TrackedHasuraEventHandler } from '../hasura.decorators';
import * as fs from 'fs';
import { safeLoad } from 'js-yaml';

@Injectable()
class TestEventHandlerService {
@TrackedHasuraEventHandler({
tableName: 'default_table',
triggerName: 'default_table_event_handler',
definition: {
type: 'insert',
},
})
public defaultHandler() {
console.log('default');
}

@TrackedHasuraEventHandler({
databaseName: 'additional',
tableName: 'additional_table',
triggerName: 'additional_table_event_handler',
definition: {
type: 'delete',
},
})
public additionalHandler() {
console.log('additional');
}
}

describe('Hasura Metadata', () => {
describe('v3 metadata', () => {
let app: INestApplication;

const v3MetadataPath = path.join(
__dirname,
'../../test/__fixtures__/hasura/metadata'
);

// Ensure that the filesystem is clean so that we can ensure proper metadata comparison
const tables = ['default', 'additional'];
tables.forEach((x) => {
const destinationPath = path.join(
v3MetadataPath,
`databases/${x}/tables/public_${x}_table.yaml`
);
if (fs.existsSync(destinationPath)) {
fs.unlinkSync(destinationPath);
}
fs.copyFileSync(`${destinationPath}.tmpl`, destinationPath);
});

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
HasuraModule.forRoot(HasuraModule, {
webhookConfig: {
secretFactory: 'secret',
secretHeader: 'NESTJS_SECRET_HEADER',
},
managedMetaDataConfig: {
dirPath: v3MetadataPath,
secretHeaderEnvName: 'NESTJS_WEBHOOK_SECRET_HEADER_VALUE',
nestEndpointEnvName: 'NESTJS_EVENT_WEBHOOK_ENDPOINT',
defaultEventRetryConfig: {
intervalInSeconds: 15,
numRetries: 3,
timeoutInSeconds: 100,
toleranceSeconds: 21600,
},
},
}),
],
providers: [TestEventHandlerService],
}).compile();

app = moduleFixture.createNestApplication();
await app.init();
});

it.each([['default'], ['additional']])(
'manages event handler metadata: %s database',
(d) => {
const tablePath = path.join(
v3MetadataPath,
`databases/${d}/tables/public_${d}_table.yaml`
);

const actual = fs.readFileSync(tablePath, 'utf-8');
const expected = fs.readFileSync(`${tablePath}.expected`, 'utf-8');

expect(safeLoad(actual)).toEqual(safeLoad(expected));
}
);
});
});
Loading

0 comments on commit 5e7135a

Please sign in to comment.