Skip to content

Commit

Permalink
feat: support Struct as a Launch input type (#113)
Browse files Browse the repository at this point in the history
* test: checkpoint for adding test cases

* test: fixing all the broken test cases

* feat: add input to support entering structs

* chore: cleanup
  • Loading branch information
schottra authored Oct 23, 2020
1 parent fde5a02 commit 3eee332
Show file tree
Hide file tree
Showing 11 changed files with 402 additions and 34 deletions.
7 changes: 7 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,10 @@ export function toBoolean(value?: string): boolean {
}
return ['true', 'True', 'TRUE', '1'].includes(value);
}

/** Simple shared stringify function to ensure consistency in formatting with
* respect to spacing.
*/
export function stringifyValue(value: any): string {
return JSON.stringify(value, null, 2);
}
3 changes: 3 additions & 0 deletions src/components/Launch/LaunchForm/LaunchFormInputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CollectionInput } from './CollectionInput';
import { formStrings } from './constants';
import { LaunchState } from './launchMachine';
import { SimpleInput } from './SimpleInput';
import { StructInput } from './StructInput';
import { useStyles } from './styles';
import {
BaseInterpretedLaunchState,
Expand All @@ -22,6 +23,8 @@ function getComponentForInput(input: InputProps, showErrors: boolean) {
return <BlobInput {...props} />;
case InputType.Collection:
return <CollectionInput {...props} />;
case InputType.Struct:
return <StructInput {...props} />;
case InputType.Map:
case InputType.Unknown:
case InputType.None:
Expand Down
38 changes: 38 additions & 0 deletions src/components/Launch/LaunchForm/StructInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { TextField } from '@material-ui/core';
import * as React from 'react';
import { InputChangeHandler, InputProps } from './types';
import { getLaunchInputId } from './utils';

function stringChangeHandler(onChange: InputChangeHandler) {
return ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
onChange(value);
};
}

/** Handles rendering of the input component for a Struct */
export const StructInput: React.FC<InputProps> = props => {
const {
error,
label,
name,
onChange,
typeDefinition: { subtype },
value = ''
} = props;
const hasError = !!error;
const helperText = hasError ? error : props.helperText;
return (
<TextField
id={getLaunchInputId(name)}
error={hasError}
helperText={helperText}
fullWidth={true}
label={label}
multiline={true}
onChange={stringChangeHandler(onChange)}
rowsMax={8}
value={value}
variant="outlined"
/>
);
};
1 change: 1 addition & 0 deletions src/components/Launch/LaunchForm/inputHelpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const allowedDateFormats = [ISO_8601, RFC_2822];

const primitivePath = 'scalar.primitive';
export const schemaUriPath = 'scalar.schema.uri';
export const structPath = 'scalar.generic';

/** Strings constants which can be used to perform a deep `get` on a scalar
* literal type using a primitive value.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { integerHelper } from './integer';
import { noneHelper } from './none';
import { schemaHelper } from './schema';
import { stringHelper } from './string';
import { structHelper } from './struct';
import { InputHelper } from './types';

const unsupportedHelper = noneHelper;
Expand All @@ -28,7 +29,7 @@ const inputHelpers: Record<InputType, InputHelper> = {
[InputType.None]: noneHelper,
[InputType.Schema]: schemaHelper,
[InputType.String]: stringHelper,
[InputType.Struct]: unsupportedHelper,
[InputType.Struct]: structHelper,
[InputType.Unknown]: unsupportedHelper
};

Expand Down
149 changes: 149 additions & 0 deletions src/components/Launch/LaunchForm/inputHelpers/struct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { stringifyValue } from 'common/utils';
import { Core, Protobuf } from 'flyteidl';
import { InputValue } from '../types';
import { structPath } from './constants';
import { ConverterInput, InputHelper, InputValidatorParams } from './types';
import { extractLiteralWithCheck } from './utils';

type PrimitiveType = string | number | boolean | null | object;

function asValueWithKind(value: Protobuf.IValue): Protobuf.Value {
return value instanceof Protobuf.Value
? value
: Protobuf.Value.create(value);
}

function protobufValueToPrimitive(
value: Protobuf.IValue
): PrimitiveType | PrimitiveType[] {
const valueWithKind = asValueWithKind(value);
const { kind } = valueWithKind;
switch (kind) {
case 'nullValue':
return null;
case 'structValue':
if (valueWithKind.structValue == null) {
throw new Error('Unexpected empty structValue field');
}
return protobufStructToObject(valueWithKind.structValue);
case 'listValue':
if (valueWithKind.listValue == null) {
throw new Error('Unexpected empty listValue field');
}
return protobufListToArray(valueWithKind.listValue);
case undefined:
throw new Error('Unexpected missing Value.kind');
default:
return valueWithKind[kind];
}
}

function primitiveToProtobufValue(value: any): Protobuf.IValue {
if (value == null) {
return { nullValue: Protobuf.NullValue.NULL_VALUE };
}
if (Array.isArray(value)) {
return { listValue: { values: value.map(primitiveToProtobufValue) } };
}
switch (typeof value) {
case 'boolean':
return { boolValue: !!value };
case 'number':
return { numberValue: value };
case 'string':
return { stringValue: value };
case 'object':
return { structValue: objectToProtobufStruct(value) };
default:
throw new Error(`Unsupported value type: ${typeof value} `);
}
}

function protobufListToArray(list: Protobuf.IListValue): PrimitiveType[] {
if (!list.values) {
return [];
}

return list.values.map(protobufValueToPrimitive);
}

function protobufStructToObject(struct: Protobuf.IStruct): Dictionary<any> {
if (struct.fields == null) {
return {};
}

return Object.entries(struct.fields).reduce<Dictionary<any>>(
(out, [key, value]) => {
out[key] = protobufValueToPrimitive(value);
return out;
},
{}
);
}

function objectToProtobufStruct(obj: Dictionary<any>): Protobuf.IStruct {
const fields = Object.entries(obj).reduce<Record<string, Protobuf.IValue>>(
(out, [key, value]) => {
try {
out[key] = primitiveToProtobufValue(value);
return out;
} catch (e) {
throw new Error(
`Failed to convert value ${key} to Protobuf.Value: ${e}`
);
}
},
{}
);

return { fields };
}

function fromLiteral(literal: Core.ILiteral): InputValue {
const structValue = extractLiteralWithCheck<Protobuf.IStruct>(
literal,
structPath
);

return stringifyValue(protobufStructToObject(structValue));
}

function toLiteral({ value }: ConverterInput): Core.ILiteral {
let parsedObject: Dictionary<any>;

if (typeof value === 'object') {
parsedObject = value;
} else {
const stringValue =
typeof value === 'string' ? value : value.toString();

try {
parsedObject = JSON.parse(stringValue);
if (typeof parsedObject !== 'object') {
throw new Error(`Result was of type: ${typeof parsedObject}`);
}
} catch (e) {
throw new Error(`Value did not parse to an object`);
}
}

return { scalar: { generic: objectToProtobufStruct(parsedObject) } };
}

function validate({ value }: InputValidatorParams) {
if (typeof value !== 'string') {
throw new Error('Value is not a string');
}

try {
JSON.parse(value);
} catch (e) {
throw new Error(`Value did not parse to an object: ${e}`);
}
}

export const structHelper: InputHelper = {
fromLiteral,
toLiteral,
validate
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { stringifyValue } from 'common/utils';
import { Core } from 'flyteidl';
import * as Long from 'long';
import { BlobDimensionality } from 'models';
Expand Down Expand Up @@ -67,7 +68,7 @@ describe('literalToInputValue', () => {
literalToInputTestCases.map(([typeDefinition, input, output]) =>
it(`should correctly convert ${
typeDefinition.type
}: ${JSON.stringify(input)}`, () =>
}: ${stringifyValue(input)}`, () =>
expect(literalToInputValue(typeDefinition, input)).toEqual(
output
))
Expand All @@ -90,7 +91,7 @@ describe('literalToInputValue', () => {
literalToInputTestCases.map(([typeDefinition, input, output]) => {
it(`should correctly convert collection of ${
typeDefinition.type
}: ${JSON.stringify(input)}`, () => {
}: ${stringifyValue(input)}`, () => {
const collection: Core.ILiteral = {
collection: {
// Duplicate it to test comma separation
Expand Down Expand Up @@ -149,7 +150,7 @@ describe('inputToLiteral', () => {
literalTestCases.map(([typeDefinition, input, output]) => {
it(`should correctly convert ${
typeDefinition.type
}: ${JSON.stringify(input)} (${typeof input})`, () =>
}: ${stringifyValue(input)} (${typeof input})`, () =>
expect(
inputToLiteral(makeSimpleInput(typeDefinition, input))
).toEqual(output));
Expand All @@ -158,33 +159,48 @@ describe('inputToLiteral', () => {

describe('Collections', () => {
literalTestCases.map(([typeDefinition, input, output]) => {
let value: any;
if (['boolean', 'number'].includes(typeof input)) {
value = input;
let singleCollectionValue: any;
let nestedCollectionValue: any;
if (typeDefinition.type === InputType.Struct) {
const objValue = JSON.parse(input);
singleCollectionValue = stringifyValue([objValue]);
nestedCollectionValue = stringifyValue([[objValue]]);
} else if (['boolean', 'number'].includes(typeof input)) {
singleCollectionValue = `[${input}]`;
nestedCollectionValue = `[[${input}]]`;
} else if (input == null) {
value = 'null';
singleCollectionValue = '[null]';
nestedCollectionValue = '[[null]]';
} else if (typeof input === 'string' || Long.isLong(input)) {
value = `"${input}"`;
singleCollectionValue = `["${input}"]`;
nestedCollectionValue = `[["${input}"]]`;
} else if (input instanceof Date) {
value = `"${input.toISOString()}"`;
const dateString = input.toISOString();
singleCollectionValue = `["${dateString}"]`;
nestedCollectionValue = `[["${dateString}"]]`;
} else {
value = JSON.stringify(input);
const stringValue = stringifyValue(input);
singleCollectionValue = `[${stringValue}]`;
nestedCollectionValue = `[[${stringValue}]]`;
}

it(`should correctly convert collection of type ${
typeDefinition.type
}: [${JSON.stringify(value)}] (${typeof input})`, () => {
}: ${singleCollectionValue} (${typeof input})`, () => {
const result = inputToLiteral(
makeCollectionInput(typeDefinition, `[${value}]`)
makeCollectionInput(typeDefinition, singleCollectionValue)
);
expect(result.collection!.literals![0]).toEqual(output);
});

it(`should correctly convert nested collection of type ${
typeDefinition.type
}: [[${JSON.stringify(value)}]] (${typeof input})`, () => {
}: ${nestedCollectionValue} (${typeof input})`, () => {
const result = inputToLiteral(
makeNestedCollectionInput(typeDefinition, `[[${value}]]`)
makeNestedCollectionInput(
typeDefinition,
nestedCollectionValue
)
);
expect(
result.collection!.literals![0].collection!.literals![0]
Expand Down Expand Up @@ -221,15 +237,15 @@ function generateValidityTests(
{ valid, invalid }: { valid: any[]; invalid: any[] }
) {
valid.map(value =>
it(`should treat ${JSON.stringify(
it(`should treat ${stringifyValue(
value
)} (${typeof value}) as valid`, () => {
const input = makeSimpleInput(typeDefinition, value);
expect(() => validateInput(input)).not.toThrowError();
})
);
invalid.map(value =>
it(`should treat ${JSON.stringify(
it(`should treat ${stringifyValue(
value
)} (${typeof value}) as invalid`, () => {
const input = makeSimpleInput(typeDefinition, value);
Expand Down
Loading

0 comments on commit 3eee332

Please sign in to comment.