Skip to content

Commit

Permalink
Merge pull request #1912 from cardstack/commands-types-allow-pojo
Browse files Browse the repository at this point in the history
Allow command execute to take in pojos rather than cards
  • Loading branch information
lukemelia authored Dec 11, 2024
2 parents ea44dff + c85f62d commit 9d610f8
Show file tree
Hide file tree
Showing 4 changed files with 242 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,10 @@ export default class CreateProductRequirementsInstance extends Command<
let prdCard = new ProductRequirementDocument();

let saveCardCommand = new SaveCardCommand(this.commandContext);
let SaveCardInputType = await saveCardCommand.getInputType();
await saveCardCommand.execute(
new SaveCardInputType({
realm: input.realm,
card: prdCard,
}),
);
await saveCardCommand.execute({
realm: input.realm,
card: prdCard,
});
// Get patch command, this takes the card and returns a command that can be used to patch the card
let patchPRDCommand = new PatchCardCommand(this.commandContext, {
cardType: ProductRequirementDocument,
Expand Down
30 changes: 21 additions & 9 deletions packages/host/app/services/command-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,17 @@ export default class CommandService extends Service {
// Get the input type and validate/construct the payload
let InputType = await command.getInputType();

// Construct a new instance of the input type with the payload
let typedInput = new InputType({
...toolCall.arguments.attributes,
...toolCall.arguments.relationships,
});
// Construct a new instance of the input type with the
// The input is undefined if the command has no input type
let typedInput;
if (InputType) {
typedInput = new InputType({
...toolCall.arguments.attributes,
...toolCall.arguments.relationships,
});
} else {
typedInput = undefined;
}
let res = await command.execute(typedInput);
await this.matrixService.sendReactionEvent(
event.room_id!,
Expand Down Expand Up @@ -131,10 +137,16 @@ export default class CommandService extends Service {
// Get the input type and validate/construct the payload
let InputType = await commandToRun.getInputType();
// Construct a new instance of the input type with the payload
let typedInput = new InputType({
...payload.attributes,
...payload.relationships,
});
// The input is undefined if the command has no input type
let typedInput;
if (InputType) {
typedInput = new InputType({
...payload.attributes,
...payload.relationships,
});
} else {
typedInput = undefined;
}
[res] = await all([
await commandToRun.execute(typedInput),
await timeout(DELAY_FOR_APPLYING_UI), // leave a beat for the "applying" state of the UI to be shown
Expand Down
191 changes: 191 additions & 0 deletions packages/host/tests/integration/commands/commands-calling-test.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { getOwner } from '@ember/owner';
import { RenderingTestContext } from '@ember/test-helpers';

import { module, test } from 'qunit';

import { Command, CommandContext } from '@cardstack/runtime-common';

import type CommandService from '@cardstack/host/services/command-service';

import RealmService from '@cardstack/host/services/realm';

import { lookupService, testRealmURL, testRealmInfo } from '../../helpers';
import {
CardDef,
StringField,
contains,
field,
setupBaseRealm,
} from '../../helpers/base-realm';
import { setupRenderingTest } from '../../helpers/setup';

class StubRealmService extends RealmService {
get defaultReadableRealm() {
return {
path: testRealmURL,
info: testRealmInfo,
};
}
}

module('Integration | commands | commands-calling', function (hooks) {
setupRenderingTest(hooks);
setupBaseRealm(hooks);
let commandContext: CommandContext;

hooks.beforeEach(function (this: RenderingTestContext) {
getOwner(this)!.register('service:realm', StubRealmService);

let commandService = lookupService<CommandService>('command-service');
commandContext = commandService.commandContext;
});

test('can be called with a card as input', async function (assert) {
class CommandInput extends CardDef {
@field inputField1 = contains(StringField);
@field inputField2 = contains(StringField);
}
class CommandOutput extends CardDef {
@field outputField = contains(StringField);
}

class ExampleCommand extends Command<CommandInput, CommandOutput> {
inputType = CommandInput;

async getInputType() {
return CommandInput;
}

async run(input: CommandInput) {
return new CommandOutput({
outputField: `Hello ${input.inputField1}${input.inputField2}`,
});
}
}
let exampleCommand = new ExampleCommand(commandContext);

const InputType = await exampleCommand.getInputType();
let input = new InputType({
inputField1: 'World',
inputField2: '!',
});
let output = await exampleCommand.execute(input);
assert.strictEqual(output.outputField, 'Hello World!');
});

test('can be called with plain object as input', async function (assert) {
class CommandInput extends CardDef {
@field inputField1 = contains(StringField);
@field inputField2 = contains(StringField);
}
class CommandOutput extends CardDef {
@field outputField = contains(StringField);
}

class ExampleCommand extends Command<CommandInput, CommandOutput> {
inputType = CommandInput;

async getInputType() {
return CommandInput;
}

async run(input: CommandInput) {
return new CommandOutput({
outputField: `Hello ${input.inputField1}${input.inputField2}`,
});
}
}
let exampleCommand = new ExampleCommand(commandContext);
let output = await exampleCommand.execute({
inputField1: 'World',
inputField2: '!',
});
assert.strictEqual(output.outputField, 'Hello World!');
});

test('can call a command with just some of the fields', async function (assert) {
class CommandInput extends CardDef {
@field inputField1 = contains(StringField);
@field inputField2 = contains(StringField);
}
class CommandOutput extends CardDef {
@field outputField = contains(StringField);
}

class ExampleCommand extends Command<CommandInput, CommandOutput> {
inputType = CommandInput;

async getInputType() {
return CommandInput;
}

async run(input: CommandInput) {
return new CommandOutput({
outputField: `Hello ${input.inputField1}${input.inputField2}`,
});
}
}
let exampleCommand = new ExampleCommand(commandContext);

let output = await exampleCommand.execute({
inputField1: 'World',
});
assert.strictEqual(output.outputField, 'Hello Worldundefined');
});

test('CardDef fields are optional', async function (assert) {
class CommandInput extends CardDef {
@field inputField1 = contains(StringField);
@field inputField2 = contains(StringField);
}
class CommandOutput extends CardDef {
@field outputField = contains(StringField);
}

class ExampleCommand extends Command<CommandInput, CommandOutput> {
inputType = CommandInput;

async getInputType() {
return CommandInput;
}

async run(input: CommandInput) {
return new CommandOutput({
outputField: `Hello ${input.inputField1}${input.inputField2}`,
});
}
}
let exampleCommand = new ExampleCommand(commandContext);

let output = await exampleCommand.execute({
inputField1: 'World',
inputField2: '!',
title: 'test',
});
assert.strictEqual(output.outputField, 'Hello World!');
});

test('Commands work without taking input', async function (assert) {
class CommandOutput extends CardDef {
@field outputField = contains(StringField);
}

class ExampleCommand extends Command<undefined, CommandOutput> {
inputType = undefined;

async getInputType() {
return undefined;
}

async run() {
return new CommandOutput({
outputField: 'Hello',
});
}
}
let exampleCommand = new ExampleCommand(commandContext);

let output = await exampleCommand.execute(undefined);
assert.strictEqual(output.outputField, 'Hello');
});
});
30 changes: 26 additions & 4 deletions packages/runtime-common/commands.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isCardDef } from './code-ref';
import { Deferred } from './deferred';
import type * as CardAPI from 'https://cardstack.com/base/card-api';
import { CardDef } from 'https://cardstack.com/base/card-api';
Expand Down Expand Up @@ -51,7 +52,9 @@ export abstract class Command<
CommandConfiguration extends any | undefined = undefined,
> {
// Is this actually type checking ?
abstract getInputType(): Promise<{ new (args: any): CardInputType }>; // TODO: can we do better than any here?
abstract getInputType(): Promise<
{ new (args?: Partial<CardInputType>): CardInputType } | undefined
>; // TODO: can we do better than any here?

invocations: CommandInvocation<CardInputType, CardResultType>[] = [];

Expand All @@ -66,20 +69,39 @@ export abstract class Command<
protected readonly configuration?: CommandConfiguration | undefined, // we'd like this to be required *if* CommandConfiguration is defined, and allow the user to skip it when CommandConfiguration is undefined
) {}

async execute(input: CardInputType): Promise<CardResultType> {
async execute(
input: CardInputType extends CardDef | undefined
? CardInputType | Partial<Omit<CardInputType, keyof CardDef>>
: never,
): Promise<CardResultType> {
// internal bookkeeping
// todo: support for this.runTask being defined
// runTask would be an ember task, run would just be a normal function

let inputCard: CardInputType;
if (input === undefined) {
inputCard = undefined as CardInputType;
} else if (isCardDef(input.constructor)) {
inputCard = input as CardInputType;
} else {
let InputType = await this.getInputType();
if (!InputType) {
throw new Error('Input provided but no input type found');
} else {
inputCard = new InputType(
input as Partial<CardInputType>,
) as CardInputType;
}
}
let invocation = new CommandInvocation<CardInputType, CardResultType>(
input,
inputCard,
);

this.invocations.push(invocation);
this.nextCompletionDeferred.fulfill(invocation.promise);

try {
let result = await this.run(input);
let result = await this.run(inputCard);
invocation.fulfill(result);
return result;
} catch (error) {
Expand Down

0 comments on commit 9d610f8

Please sign in to comment.