From 0c17f2b016a485f6fecd97e6a91382fa0d013478 Mon Sep 17 00:00:00 2001 From: Neel Date: Tue, 3 Nov 2026 20:46:56 -0500 Subject: [PATCH 1/2] feat: describe reactor contracts for SDK generation Add AbstractReactor2, so a reactor declares its inputs once as a record. keysToGet, keyRequired, key descriptions, MCP types and the typed input are all derived from it, and the return value is wrapped in a NounMetadata. --help, asMcpTool() and positional inputs keep working. - Move Echo, GetCurrentInsightId and StopPixelExecution onto AbstractReactor2, with the same outputs and messages. - Add @Internal (prerna.openapi) to mark a reactor or REST operation as not for general use. Monolith publishes it as x-internal. - Add @DynamicOutput for reactors whose output has no fixed type. - Add the swagger-annotations-jakarta dependency and AbstractReactor2Test. --- pom.xml | 9 + src/prerna/openapi/Internal.java | 56 +++ src/prerna/reactor/AbstractReactor2.java | 440 ++++++++++++++++++ src/prerna/reactor/DynamicOutput.java | 51 ++ src/prerna/reactor/EchoReactor.java | 23 +- src/prerna/reactor/NoInput.java | 37 ++ .../reactor/StopPixelExecutionReactor.java | 31 +- .../insights/GetCurrentInsightIdReactor.java | 16 +- ...delUsageLimitColumnMigrationUnitTests.java | 27 ++ .../WorkRoutineDocumentsUnitTests.java | 27 ++ .../ds/py/PandasCompatibilityTests.java | 27 ++ ...ModelRouterEngineTurnRoutingUnitTests.java | 27 ++ .../ModelRouterEngineTypeSafeUnitTests.java | 27 ++ .../usage/ModelUsageBilledToUnitTests.java | 27 ++ test/prerna/reactor/AbstractReactor2Test.java | 275 +++++++++++ .../WorkShowItemReactorUnitTests.java | 27 ++ 16 files changed, 1083 insertions(+), 44 deletions(-) create mode 100644 src/prerna/openapi/Internal.java create mode 100644 src/prerna/reactor/AbstractReactor2.java create mode 100644 src/prerna/reactor/DynamicOutput.java create mode 100644 src/prerna/reactor/NoInput.java create mode 100644 test/prerna/reactor/AbstractReactor2Test.java diff --git a/pom.xml b/pom.xml index 96a649a5120..70f7b7f56c1 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,8 @@ cannot share a single property. --> 2.22.4 2.22 + + 2.2.30 33.7.2-jre 3.1.8 @@ -1989,6 +1991,13 @@ jackson-annotations ${jackson2.annotations.version} + + + + io.swagger.core.v3 + swagger-annotations-jakarta + ${swagger.version} + com.fasterxml.jackson.datatype diff --git a/src/prerna/openapi/Internal.java b/src/prerna/openapi/Internal.java new file mode 100644 index 00000000000..91cadc8aacd --- /dev/null +++ b/src/prerna/openapi/Internal.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.openapi; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a REST operation or a reactor as internal: it is published in the + * OpenAPI document as {@code "x-internal": true}, and every SDK generated from + * the document tags it so its users know not to rely on it. It still works for + * everyone; this is a warning, not access control. + *

+ * Put it on a resource method, on a resource class (every operation in it), or on + * an {@code AbstractReactor2} class. To leave something out of the document and + * the SDKs entirely, use swagger-core's {@code @Hidden} instead. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface Internal { + + /** + * Why it is internal, such as "Used by the admin console only". It is shown to + * SDK users next to the internal tag. + */ + String value() default ""; +} diff --git a/src/prerna/reactor/AbstractReactor2.java b/src/prerna/reactor/AbstractReactor2.java new file mode 100644 index 00000000000..efc16ae79b1 --- /dev/null +++ b/src/prerna/reactor/AbstractReactor2.java @@ -0,0 +1,440 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import io.swagger.v3.oas.annotations.media.Schema; +import prerna.sablecc2.om.GenRowStruct; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * A reactor that declares its inputs as a record and its output as a Java type, + * so its contract can be read without running it. + *

+ * The input record is the only place the inputs are declared. From it this class + * fills {@link #keysToGet} and {@link #keyRequired} (so {@code organizeKeys()}, + * {@code --help} and {@code asMcpTool()} keep working), answers + * {@link #getDescriptionForKey(String)} and {@link #getKeyTypeForMCP(String)}, + * and builds the typed input passed to {@link #run(Record)}. The return value is + * wrapped in a {@link NounMetadata} whose data type follows from it. + * + *

{@code
+ * public class StopPixelExecutionReactor
+ * 		extends AbstractReactor2 {
+ *
+ * 	public record Input(@Schema(description = "The id for the job") String id) {
+ * 	}
+ *
+ * 	protected String run(Input input) {
+ * 		return "Cancel requested for pixel job " + input.id();
+ * 	}
+ * }
+ * }
+ * + * Record components are required unless they are annotated + * {@code @Schema(requiredMode = NOT_REQUIRED)}; an optional component must use a + * boxed type, and is {@code null} when it is not passed. Supported component + * types are {@code String}, {@code Integer}/{@code int}, {@code Long}/{@code long}, + * {@code Double}/{@code double}, {@code Boolean}/{@code boolean}, enums, + * {@code List} of those, {@code Map} and {@code Object} (any value). A reactor + * whose output has no fixed type returns {@link NounMetadata}, or {@code Object}, + * and is annotated {@link DynamicOutput}. + *

+ * The SDK generator reads {@link #inputType(Class)} and {@link #outputType(Class)} + * from the class alone; they never construct the reactor. + * + * @param the input record; {@link NoInput} when there are none + * @param the output type + */ +public abstract class AbstractReactor2 extends AbstractReactor { + + private final Class inputRecord; + private final RecordComponent[] inputComponents; + + protected AbstractReactor2() { + this.inputRecord = inputType(getClass()); + this.inputComponents = this.inputRecord.getRecordComponents(); + + for (RecordComponent component : this.inputComponents) { + checkSupportedInput(component); + } + Type output = outputType(getClass()); + if (isDynamicOutput(output) && getClass().getAnnotation(DynamicOutput.class) == null) { + throw new IllegalStateException(getClass().getName() + + " returns a value with no fixed type; annotate it with @DynamicOutput(\"why\")"); + } + + this.keysToGet = Arrays.stream(this.inputComponents).map(RecordComponent::getName).toArray(String[]::new); + this.keyRequired = Arrays.stream(this.inputComponents).mapToInt(c -> isRequired(c) ? 1 : 0).toArray(); + } + + /** + * Runs the reactor. + * + * @param input the inputs, already checked: required ones are present and + * every value has its declared type + * @return the output; a {@link NounMetadata} is returned as it is + */ + protected abstract O run(I input); + + /** + * The operation types of the returned noun. Defaults to {@code OPERATION}, + * the same as {@code new NounMetadata(value, type)}. + */ + protected List getOperationTypes() { + return List.of(PixelOperationType.OPERATION); + } + + @Override + public final NounMetadata execute() { + organizeKeys(); + O result = run(readInput()); + if (result instanceof NounMetadata noun) { + return noun; + } + return new NounMetadata(toNounValue(result), dataTypeFor(result), getOperationTypes()); + } + + @Override + protected String getDescriptionForKey(String key) { + for (RecordComponent component : this.inputComponents) { + if (component.getName().equals(key)) { + Schema schema = schemaOf(component); + if (schema != null && !schema.description().isEmpty()) { + return schema.description(); + } + } + } + return super.getDescriptionForKey(key); + } + + @Override + protected MCP_KEY_TYPE getKeyTypeForMCP(String key) { + for (RecordComponent component : this.inputComponents) { + if (component.getName().equals(key)) { + Class type = component.getType(); + if (type == String.class || type.isEnum()) { + return MCP_KEY_TYPE.STRING; + } else if (isOneOf(type, Integer.class, int.class, Long.class, long.class)) { + return MCP_KEY_TYPE.INTEGER; + } else if (isOneOf(type, Double.class, double.class)) { + return MCP_KEY_TYPE.NUMBER; + } else if (isOneOf(type, Boolean.class, boolean.class)) { + return MCP_KEY_TYPE.BOOLEAN; + } else if (List.class.isAssignableFrom(type)) { + return MCP_KEY_TYPE.ARRAY; + } else if (Map.class.isAssignableFrom(type)) { + return MCP_KEY_TYPE.OBJECT; + } + } + } + return super.getKeyTypeForMCP(key); + } + + /* + * Reading the contract from the class, without constructing it + */ + + /** + * Returns the input record of a reactor class. + * + * @throws IllegalArgumentException if the class does not extend + * {@code AbstractReactor2} with concrete type + * arguments + */ + @SuppressWarnings("unchecked") + public static Class inputType(Class reactorClass) { + Type input = typeArguments(reactorClass)[0]; + if (!(input instanceof Class inputClass) || !inputClass.isRecord()) { + throw new IllegalArgumentException(reactorClass.getName() + ": the input must be a record, got " + input); + } + return (Class) inputClass; + } + + /** + * Returns the declared output type of a reactor class, such as + * {@code String.class} or a {@code ParameterizedType} for {@code List}. + */ + public static Type outputType(Class reactorClass) { + return typeArguments(reactorClass)[1]; + } + + /** + * Whether an output type has no fixed shape: {@link NounMetadata} or + * {@code Object}. + */ + public static boolean isDynamicOutput(Type output) { + return output == NounMetadata.class || output == Object.class; + } + + /** + * Whether an input record component is required: everything is, unless it is + * annotated {@code @Schema(requiredMode = NOT_REQUIRED)}. + */ + public static boolean isRequired(RecordComponent component) { + Schema schema = schemaOf(component); + return schema == null || schema.requiredMode() != Schema.RequiredMode.NOT_REQUIRED; + } + + /** + * Returns the {@code @Schema} on a record component. Annotations on a + * component are copied to its accessor and field, which is where they are read. + */ + public static Schema schemaOf(RecordComponent component) { + Schema schema = component.getAccessor().getAnnotation(Schema.class); + if (schema != null) { + return schema; + } + try { + Field field = component.getDeclaringRecord().getDeclaredField(component.getName()); + return field.getAnnotation(Schema.class); + } catch (NoSuchFieldException e) { + return null; + } + } + + private static Type[] typeArguments(Class reactorClass) { + Class current = reactorClass; + while (current != null && current.getSuperclass() != AbstractReactor2.class) { + current = current.getSuperclass(); + } + if (current == null) { + throw new IllegalArgumentException(reactorClass.getName() + " does not extend AbstractReactor2"); + } + Type superclass = current.getGenericSuperclass(); + if (!(superclass instanceof ParameterizedType parameterized)) { + throw new IllegalArgumentException( + current.getName() + " must give AbstractReactor2 its input and output types"); + } + Type[] arguments = parameterized.getActualTypeArguments(); + for (Type argument : arguments) { + if (argument instanceof java.lang.reflect.TypeVariable) { + throw new IllegalArgumentException(current.getName() + + " must give AbstractReactor2 concrete input and output types, not " + argument); + } + } + return arguments; + } + + /* + * Building the input + */ + + private void checkSupportedInput(RecordComponent component) { + Class type = component.getType(); + if (type.isPrimitive() && !isRequired(component)) { + throw new IllegalStateException(this.inputRecord.getName() + "." + component.getName() + + " is optional, so it must use a boxed type rather than " + type.getName()); + } + if (List.class.isAssignableFrom(type)) { + if (!isScalar(listElementType(component))) { + throw new IllegalStateException(this.inputRecord.getName() + "." + component.getName() + + ": a list input must hold strings, numbers, booleans or enums"); + } + return; + } + if (!isScalar(type) && !Map.class.isAssignableFrom(type) && type != Object.class) { + throw new IllegalStateException(this.inputRecord.getName() + "." + component.getName() + + ": input type " + type.getName() + " is not supported"); + } + } + + @SuppressWarnings("unchecked") + private I readInput() { + Object[] values = new Object[this.inputComponents.length]; + Class[] types = new Class[this.inputComponents.length]; + for (int i = 0; i < this.inputComponents.length; i++) { + types[i] = this.inputComponents[i].getType(); + values[i] = readComponent(this.inputComponents[i]); + } + try { + Constructor constructor = this.inputRecord.getDeclaredConstructor(types); + constructor.setAccessible(true); + return (I) constructor.newInstance(values); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof RuntimeException runtime) { + throw runtime; + } + throw new IllegalArgumentException(e.getCause()); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot build " + this.inputRecord.getName(), e); + } + } + + private Object readComponent(RecordComponent component) { + String name = component.getName(); + Class type = component.getType(); + + if (List.class.isAssignableFrom(type)) { + GenRowStruct values = nounsFor(name); + if (values == null) { + return null; + } + Class element = listElementType(component); + List list = new ArrayList<>(); + for (Object value : values.getAllValues()) { + list.add(convertScalar(value, element, name)); + } + return list; + } + + if (Map.class.isAssignableFrom(type) || type == Object.class) { + GenRowStruct values = nounsFor(name); + if (values == null) { + return null; + } + Object value = values.get(0); + if (Map.class.isAssignableFrom(type) && !(value instanceof Map)) { + throw new IllegalArgumentException(name + " must be a map"); + } + return value; + } + + // organizeKeys() already read scalars, named or by position, as text + String text = this.keyValue.get(name); + if (text == null) { + return null; + } + return convertScalar(text, type, name); + } + + /** The values passed for a key, or by position when the reactor has one input. */ + private GenRowStruct nounsFor(String name) { + GenRowStruct values = this.store == null ? null : this.store.getGenRowStruct(name); + if ((values == null || values.isEmpty()) && this.inputComponents.length == 1) { + values = this.curRow; + } + return values == null || values.isEmpty() ? null : values; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static Object convertScalar(Object value, Class type, String name) { + try { + if (type == String.class) { + return value.toString(); + } else if (type == Integer.class || type == int.class) { + return value instanceof Number n && !(value instanceof Double || value instanceof Float) + ? n.intValue() + : new BigDecimal(value.toString()).intValueExact(); + } else if (type == Long.class || type == long.class) { + return value instanceof Number n && !(value instanceof Double || value instanceof Float) + ? n.longValue() + : new BigDecimal(value.toString()).longValueExact(); + } else if (type == Double.class || type == double.class) { + return value instanceof Number n ? n.doubleValue() : Double.parseDouble(value.toString()); + } else if (type == Boolean.class || type == boolean.class) { + if (value instanceof Boolean b) { + return b; + } + String text = value.toString(); + if (text.equalsIgnoreCase("true") || text.equalsIgnoreCase("false")) { + return Boolean.parseBoolean(text); + } + throw new IllegalArgumentException(name + " must be true or false, got " + text); + } else if (type.isEnum()) { + try { + return Enum.valueOf((Class) type, value.toString()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(name + " must be one of " + + Arrays.toString(type.getEnumConstants()) + ", got " + value); + } + } else if (type == Object.class) { + return value; + } + } catch (NumberFormatException | ArithmeticException e) { + throw new IllegalArgumentException(name + " must be a whole number or a number as declared, got " + value); + } + throw new IllegalStateException(name + ": input type " + type.getName() + " is not supported"); + } + + private static Class listElementType(RecordComponent component) { + Type generic = component.getGenericType(); + if (generic instanceof ParameterizedType parameterized + && parameterized.getActualTypeArguments()[0] instanceof Class element) { + return element; + } + throw new IllegalStateException( + component.getDeclaringRecord().getName() + "." + component.getName() + " must be a List<...> of one type"); + } + + private static boolean isScalar(Class type) { + return type == String.class || type.isEnum() || isOneOf(type, Integer.class, int.class, Long.class, long.class, + Double.class, double.class, Boolean.class, boolean.class); + } + + private static boolean isOneOf(Class type, Class... options) { + for (Class option : options) { + if (type == option) { + return true; + } + } + return false; + } + + /* + * Wrapping the output + */ + + private static Object toNounValue(Object result) { + return result instanceof Enum e ? e.name() : result; + } + + private static PixelDataType dataTypeFor(Object result) { + if (result == null) { + return PixelDataType.NULL_VALUE; + } else if (result instanceof String || result instanceof Enum) { + return PixelDataType.CONST_STRING; + } else if (result instanceof Integer || result instanceof Long || result instanceof Short + || result instanceof Byte) { + return PixelDataType.CONST_INT; + } else if (result instanceof Number) { + return PixelDataType.CONST_DECIMAL; + } else if (result instanceof Boolean) { + return PixelDataType.BOOLEAN; + } else if (result instanceof Map || result instanceof Record) { + return PixelDataType.MAP; + } else if (result instanceof Collection || result.getClass().isArray()) { + return PixelDataType.VECTOR; + } + throw new IllegalStateException("Output type " + result.getClass().getName() + " is not supported"); + } +} diff --git a/src/prerna/reactor/DynamicOutput.java b/src/prerna/reactor/DynamicOutput.java new file mode 100644 index 00000000000..33bba5b232e --- /dev/null +++ b/src/prerna/reactor/DynamicOutput.java @@ -0,0 +1,51 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks an {@link AbstractReactor2} whose output has no fixed type, such as one + * that returns a {@code NounMetadata} it was given. The contract then describes + * the output as any value, and the reason is written into the OpenAPI document + * so SDK users know why it is untyped. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DynamicOutput { + + /** + * Why the output has no fixed type, for example "returns its input". + */ + String value(); +} diff --git a/src/prerna/reactor/EchoReactor.java b/src/prerna/reactor/EchoReactor.java index ef0d35fa01f..c5b0e322554 100644 --- a/src/prerna/reactor/EchoReactor.java +++ b/src/prerna/reactor/EchoReactor.java @@ -27,20 +27,21 @@ *******************************************************************************/ package prerna.reactor; +import io.swagger.v3.oas.annotations.media.Schema; import prerna.sablecc2.om.GenRowStruct; import prerna.sablecc2.om.PixelDataType; -import prerna.sablecc2.om.ReactorKeysEnum; import prerna.sablecc2.om.nounmeta.NounMetadata; -public class EchoReactor extends AbstractReactor { +@DynamicOutput("returns the value it is given, whatever its type") +public class EchoReactor extends AbstractReactor2 { - public EchoReactor() { - this.keysToGet = new String[] {ReactorKeysEnum.VALUE.getKey()}; + public record Input( + @Schema(description = "The value to echo back", requiredMode = Schema.RequiredMode.NOT_REQUIRED) Object value) { } - + @Override - public NounMetadata execute() { - organizeKeys(); + protected NounMetadata run(Input input) { + // returns the noun itself, so the value keeps the type it was given GenRowStruct grs = this.store.getGenRowStruct(this.keysToGet[0]); if(grs != null && !grs.isEmpty()) { return grs.getNoun(0); @@ -53,14 +54,6 @@ public NounMetadata execute() { return new NounMetadata("", PixelDataType.CONST_STRING); } - @Override - protected String getDescriptionForKey(String key) { - if(key.equals(ReactorKeysEnum.VALUE.getKey())) { - return "The value to echo back"; - } - return super.getDescriptionForKey(key); - } - @Override public String getReactorDescription() { return "This reactor returns back the input the user enters"; diff --git a/src/prerna/reactor/NoInput.java b/src/prerna/reactor/NoInput.java new file mode 100644 index 00000000000..bc84320c1c3 --- /dev/null +++ b/src/prerna/reactor/NoInput.java @@ -0,0 +1,37 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * The input of an {@link AbstractReactor2} that takes no inputs. + */ +@Schema(description = "No inputs") +public record NoInput() { +} diff --git a/src/prerna/reactor/StopPixelExecutionReactor.java b/src/prerna/reactor/StopPixelExecutionReactor.java index af9a740fdb7..0392f36d203 100644 --- a/src/prerna/reactor/StopPixelExecutionReactor.java +++ b/src/prerna/reactor/StopPixelExecutionReactor.java @@ -29,6 +29,7 @@ import java.util.Objects; +import io.swagger.v3.oas.annotations.media.Schema; import prerna.auth.User; import prerna.auth.utils.SecurityAdminUtils; import prerna.reactor.agent.AgentCancelHook; @@ -36,29 +37,21 @@ import prerna.sablecc2.comm.PixelJobManager; import prerna.sablecc2.comm.PixelJobManager.InterruptResult; import prerna.sablecc2.comm.PixelJobRunner; -import prerna.sablecc2.om.PixelDataType; -import prerna.sablecc2.om.PixelOperationType; -import prerna.sablecc2.om.ReactorKeysEnum; -import prerna.sablecc2.om.nounmeta.NounMetadata; import prerna.tcp.client.SocketClient; -public class StopPixelExecutionReactor extends AbstractReactor { +public class StopPixelExecutionReactor extends AbstractReactor2 { - public StopPixelExecutionReactor() { - this.keysToGet = new String[] { ReactorKeysEnum.ID.getKey() }; - this.keyRequired = new int[] { 1 }; + public record Input( + @Schema(description = "The id for the job. If running the pixel synchronously, the job id will be the same as the insight id.") String id) { } @Override - public NounMetadata execute() { - this.organizeKeys(); - - String jobId = this.keyValue.get(ReactorKeysEnum.ID.getKey()); + protected String run(Input input) { + String jobId = input.id(); PixelJobManager jobManager = PixelJobManager.getManager(); PixelJobRunner jobRunner = jobManager.getJob(jobId); if (jobRunner == null) { - return new NounMetadata("Pixel job " + jobId + " not found", PixelDataType.CONST_STRING, - PixelOperationType.OPERATION); + return "Pixel job " + jobId + " not found"; } User user = this.insight.getUser(); @@ -112,19 +105,11 @@ public NounMetadata execute() { } else { message = "Pixel job " + jobId + " not found"; } - return new NounMetadata(message, PixelDataType.CONST_STRING, PixelOperationType.OPERATION); + return message; } @Override public String getReactorDescription() { return "Stop the current execution of a pixel job"; } - - @Override - protected String getDescriptionForKey(String key) { - if (key.equalsIgnoreCase(ReactorKeysEnum.ID.getKey())) { - return "The id for the job. If running the pixel synchronously, the job id will be the same as the insight id."; - } - return super.getDescriptionForKey(key); - } } diff --git a/src/prerna/reactor/insights/GetCurrentInsightIdReactor.java b/src/prerna/reactor/insights/GetCurrentInsightIdReactor.java index 1ea0fb933d5..c77ddcfd4d8 100644 --- a/src/prerna/reactor/insights/GetCurrentInsightIdReactor.java +++ b/src/prerna/reactor/insights/GetCurrentInsightIdReactor.java @@ -27,15 +27,19 @@ *******************************************************************************/ package prerna.reactor.insights; -import prerna.reactor.AbstractReactor; -import prerna.sablecc2.om.PixelDataType; -import prerna.sablecc2.om.nounmeta.NounMetadata; +import prerna.reactor.AbstractReactor2; +import prerna.reactor.NoInput; -public class GetCurrentInsightIdReactor extends AbstractReactor { +public class GetCurrentInsightIdReactor extends AbstractReactor2 { @Override - public NounMetadata execute() { - return new NounMetadata(this.insight.getInsightId(), PixelDataType.CONST_STRING); + protected String run(NoInput input) { + return this.insight.getInsightId(); + } + + @Override + public String getReactorDescription() { + return "Returns the id of the current insight"; } } diff --git a/test/prerna/auth/utils/ModelUsageLimitColumnMigrationUnitTests.java b/test/prerna/auth/utils/ModelUsageLimitColumnMigrationUnitTests.java index c0e01e895d0..c240113fad8 100644 --- a/test/prerna/auth/utils/ModelUsageLimitColumnMigrationUnitTests.java +++ b/test/prerna/auth/utils/ModelUsageLimitColumnMigrationUnitTests.java @@ -1,3 +1,30 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ package prerna.auth.utils; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/test/prerna/collaboration/WorkRoutineDocumentsUnitTests.java b/test/prerna/collaboration/WorkRoutineDocumentsUnitTests.java index 803b8a60b50..b703f6c434b 100644 --- a/test/prerna/collaboration/WorkRoutineDocumentsUnitTests.java +++ b/test/prerna/collaboration/WorkRoutineDocumentsUnitTests.java @@ -1,3 +1,30 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ package prerna.collaboration; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/test/prerna/ds/py/PandasCompatibilityTests.java b/test/prerna/ds/py/PandasCompatibilityTests.java index d41bcafdbda..13611a4fe03 100644 --- a/test/prerna/ds/py/PandasCompatibilityTests.java +++ b/test/prerna/ds/py/PandasCompatibilityTests.java @@ -1,3 +1,30 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ package prerna.ds.py; import static org.junit.jupiter.api.Assertions.assertArrayEquals; diff --git a/test/prerna/engine/impl/model/ModelRouterEngineTurnRoutingUnitTests.java b/test/prerna/engine/impl/model/ModelRouterEngineTurnRoutingUnitTests.java index 0ff29188c7e..7fa9205c17b 100644 --- a/test/prerna/engine/impl/model/ModelRouterEngineTurnRoutingUnitTests.java +++ b/test/prerna/engine/impl/model/ModelRouterEngineTurnRoutingUnitTests.java @@ -1,3 +1,30 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ package prerna.engine.impl.model; import static org.junit.jupiter.api.Assertions.*; diff --git a/test/prerna/engine/impl/model/ModelRouterEngineTypeSafeUnitTests.java b/test/prerna/engine/impl/model/ModelRouterEngineTypeSafeUnitTests.java index cb46278ebd8..1db02e9cf31 100644 --- a/test/prerna/engine/impl/model/ModelRouterEngineTypeSafeUnitTests.java +++ b/test/prerna/engine/impl/model/ModelRouterEngineTypeSafeUnitTests.java @@ -1,3 +1,30 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ package prerna.engine.impl.model; import static org.junit.jupiter.api.Assertions.*; diff --git a/test/prerna/engine/impl/model/usage/ModelUsageBilledToUnitTests.java b/test/prerna/engine/impl/model/usage/ModelUsageBilledToUnitTests.java index 151987bd7a5..6a484322daa 100644 --- a/test/prerna/engine/impl/model/usage/ModelUsageBilledToUnitTests.java +++ b/test/prerna/engine/impl/model/usage/ModelUsageBilledToUnitTests.java @@ -1,3 +1,30 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ /******************************************************************************* ' * Copyright 2015 Defense Health Agency (DHA) * diff --git a/test/prerna/reactor/AbstractReactor2Test.java b/test/prerna/reactor/AbstractReactor2Test.java new file mode 100644 index 00000000000..9502d9b2bde --- /dev/null +++ b/test/prerna/reactor/AbstractReactor2Test.java @@ -0,0 +1,275 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +import io.swagger.v3.oas.annotations.media.Schema; +import prerna.openapi.Internal; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +class AbstractReactor2Test { + + enum Color { + RED, BLUE + } + + /** Echoes its typed input back as a map, to check how each type is read. */ + static class TypesReactor extends AbstractReactor2> { + + record Input(@Schema(description = "A name") String name, @Schema(description = "A count") Integer count, + @Schema(description = "A ratio") double ratio, @Schema(description = "A flag") Boolean flag, + @Schema(description = "A color") Color color, @Schema(description = "Some tags") List tags, + @Schema(description = "A note", requiredMode = Schema.RequiredMode.NOT_REQUIRED) String note) { + } + + @Override + protected Map run(Input input) { + Map out = new java.util.HashMap<>(); + out.put("name", input.name()); + out.put("count", input.count()); + out.put("ratio", input.ratio()); + out.put("flag", input.flag()); + out.put("color", input.color()); + out.put("tags", input.tags()); + out.put("note", input.note()); + return out; + } + } + + static class OneInputReactor extends AbstractReactor2 { + + record Input(@Schema(description = "How many") Integer count) { + } + + @Override + protected Integer run(Input input) { + return input.count() * 2; + } + + @Override + protected List getOperationTypes() { + return List.of(PixelOperationType.SUCCESS); + } + } + + static class OptionalPrimitiveReactor extends AbstractReactor2 { + + record Input(@Schema(description = "Bad", requiredMode = Schema.RequiredMode.NOT_REQUIRED) int count) { + } + + @Override + protected String run(Input input) { + return ""; + } + } + + @Internal("Testing only") + static class InternalReactor extends AbstractReactor2 { + @Override + protected String run(NoInput input) { + return "internal"; + } + } + + static class UndeclaredDynamicReactor extends AbstractReactor2 { + @Override + protected Object run(NoInput input) { + return null; + } + } + + private static void named(AbstractReactor reactor, String key, Object... values) { + reactor.curNoun(key); + for (Object value : values) { + if (value instanceof String s) { + reactor.getCurRow().addLiteral(s); + } else if (value instanceof Integer i) { + reactor.getCurRow().addInteger(i); + } else if (value instanceof Double d) { + reactor.getCurRow().addDecimal(d); + } else if (value instanceof Boolean b) { + reactor.getCurRow().addBoolean(b); + } + } + reactor.closeNoun(key); + } + + private static TypesReactor typesReactorWithRequiredInputs() { + return typesReactorWithColor("BLUE"); + } + + private static TypesReactor typesReactorWithColor(String color) { + TypesReactor reactor = new TypesReactor(); + reactor.In(); + named(reactor, "name", "Ann"); + named(reactor, "count", 3); + named(reactor, "ratio", 0.5); + named(reactor, "flag", true); + named(reactor, "color", color); + named(reactor, "tags", "a", "b"); + return reactor; + } + + @Test + void declaresKeysRequirednessAndDescriptionsFromTheRecord() { + TypesReactor reactor = new TypesReactor(); + + assertArrayEquals(new String[] { "name", "count", "ratio", "flag", "color", "tags", "note" }, + reactor.keysToGet); + JSONObject tool = reactor.asMcpTool(); + JSONObject properties = tool.getJSONObject("inputSchema").getJSONObject("properties"); + assertEquals("A count", properties.getJSONObject("count").getString("description")); + assertEquals("integer", properties.getJSONObject("count").getString("type")); + assertEquals("number", properties.getJSONObject("ratio").getString("type")); + assertEquals("boolean", properties.getJSONObject("flag").getString("type")); + assertEquals("array", properties.getJSONObject("tags").getString("type")); + assertEquals(List.of("name", "count", "ratio", "flag", "color", "tags"), + tool.getJSONObject("inputSchema").getJSONArray("required").toList()); + } + + @Test + @SuppressWarnings("unchecked") + void readsEveryInputAsItsDeclaredType() { + NounMetadata noun = typesReactorWithRequiredInputs().execute(); + Map out = (Map) noun.getValue(); + + assertEquals(PixelDataType.MAP, noun.getNounType()); + assertEquals("Ann", out.get("name")); + assertEquals(3, out.get("count")); + assertEquals(0.5, out.get("ratio")); + assertEquals(true, out.get("flag")); + assertEquals(Color.BLUE, out.get("color")); + assertEquals(List.of("a", "b"), out.get("tags")); + assertNull(out.get("note")); + assertEquals(List.of(PixelOperationType.OPERATION), noun.getOpType()); + } + + @Test + void rejectsAMissingRequiredInputTheWayOrganizeKeysDoes() { + TypesReactor reactor = new TypesReactor(); + reactor.In(); + named(reactor, "name", "Ann"); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); + assertTrue(error.getMessage().startsWith("Required input(s) missing:"), error.getMessage()); + } + + @Test + void rejectsAValueOfTheWrongType() { + TypesReactor reactor = typesReactorWithColor("GREEN"); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); + assertTrue(error.getMessage().contains("color must be one of [RED, BLUE]"), error.getMessage()); + } + + @Test + void acceptsASingleInputByPositionAndWrapsTheOutputWithItsOperationTypes() { + OneInputReactor reactor = new OneInputReactor(); + reactor.In(); + reactor.getCurRow().addInteger(21); + + NounMetadata noun = reactor.execute(); + + assertEquals(42, noun.getValue()); + assertEquals(PixelDataType.CONST_INT, noun.getNounType()); + assertEquals(List.of(PixelOperationType.SUCCESS), noun.getOpType()); + } + + @Test + void readsTheContractFromTheClassWithoutConstructingIt() { + assertEquals(StopPixelExecutionReactor.Input.class, + AbstractReactor2.inputType(StopPixelExecutionReactor.class)); + assertEquals(String.class, AbstractReactor2.outputType(StopPixelExecutionReactor.class)); + assertEquals(NoInput.class, AbstractReactor2.inputType(prerna.reactor.insights.GetCurrentInsightIdReactor.class)); + assertTrue(AbstractReactor2.isDynamicOutput(AbstractReactor2.outputType(EchoReactor.class))); + assertThrows(IllegalArgumentException.class, () -> AbstractReactor2.inputType(AbstractReactor.class)); + } + + @Test + void failsFastOnContractsItCannotHonor() { + assertThrows(IllegalStateException.class, OptionalPrimitiveReactor::new); + assertThrows(IllegalStateException.class, UndeclaredDynamicReactor::new); + } + + @Test + void keepsThePilotReactorsContracts() { + StopPixelExecutionReactor stop = new StopPixelExecutionReactor(); + assertArrayEquals(new String[] { "id" }, stop.keysToGet); + assertEquals("StopPixelExecution", stop.asMcpTool().getString("name")); + assertTrue(stop.getHelp().contains("If running the pixel synchronously")); + + EchoReactor echo = new EchoReactor(); + assertArrayEquals(new String[] { "value" }, echo.keysToGet); + } + + @Test + void internalIsReadableFromTheClassAndDoesNotChangeExecution() { + Internal internal = InternalReactor.class.getAnnotation(Internal.class); + assertEquals("Testing only", internal.value()); + + InternalReactor reactor = new InternalReactor(); + reactor.In(); + assertEquals("internal", reactor.execute().getValue()); + } + + @Test + void echoStillReturnsTheNounItWasGiven() { + EchoReactor named = new EchoReactor(); + named.In(); + named(named, "value", 7); + NounMetadata namedOut = named.execute(); + assertEquals(7, namedOut.getValue()); + assertEquals(PixelDataType.CONST_INT, namedOut.getNounType()); + + EchoReactor positional = new EchoReactor(); + positional.In(); + positional.getCurRow().addLiteral("hi"); + NounMetadata positionalOut = positional.execute(); + assertSame(positional.getCurRow().getNoun(0), positionalOut); + + EchoReactor empty = new EchoReactor(); + empty.In(); + NounMetadata emptyOut = empty.execute(); + assertEquals("", emptyOut.getValue()); + assertEquals(PixelDataType.CONST_STRING, emptyOut.getNounType()); + } +} diff --git a/test/prerna/reactor/collaboration/WorkShowItemReactorUnitTests.java b/test/prerna/reactor/collaboration/WorkShowItemReactorUnitTests.java index fba17b350ce..734be95daaa 100644 --- a/test/prerna/reactor/collaboration/WorkShowItemReactorUnitTests.java +++ b/test/prerna/reactor/collaboration/WorkShowItemReactorUnitTests.java @@ -1,3 +1,30 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ package prerna.reactor.collaboration; import static org.junit.jupiter.api.Assertions.assertEquals; From 97cdc0650a843c481cf6bf546e3eee1a7fed1e02 Mon Sep 17 00:00:00 2001 From: Neel Date: Tue, 3 Nov 2026 21:49:54 -0500 Subject: [PATCH 2/2] feat: infer every reactor's contract instead of AbstractReactor2 Reactors no longer have to change to be published. ReactorContract reads a contract from what every reactor already declares: - inputs from keysToGet (canonical names); - requiredness from keyRequired (none means nothing is required, as organizeKeys() checks); - input types from getKeyTypeForMCP; - descriptions from getDescriptionForKey and getReactorDescription. Annotations in prerna.openapi refine what inference cannot see: @PixelOutput declares a reactor's output, and @PixelInput (repeatable) refines one input. Naming a key the reactor does not declare is an error. - Roll back AbstractReactor2, NoInput and DynamicOutput, and restore Echo, StopPixelExecution and GetCurrentInsightId, now annotated so their published types are unchanged. - Move ReactorFactory's Pixel-name and frame-type rules into ReactorNames, shared with the contract reader. Registration is unchanged. - Add ReactorContractTest. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/prerna/openapi/Internal.java | 2 +- src/prerna/openapi/PixelInput.java | 85 ++++ .../PixelInputs.java} | 17 +- .../NoInput.java => openapi/PixelOutput.java} | 36 +- src/prerna/reactor/AbstractReactor2.java | 440 ------------------ src/prerna/reactor/EchoReactor.java | 25 +- src/prerna/reactor/ReactorContract.java | 199 ++++++++ src/prerna/reactor/ReactorFactory.java | 43 +- src/prerna/reactor/ReactorNames.java | 78 ++++ .../reactor/StopPixelExecutionReactor.java | 33 +- .../insights/GetCurrentInsightIdReactor.java | 18 +- test/prerna/reactor/AbstractReactor2Test.java | 275 ----------- test/prerna/reactor/ReactorContractTest.java | 203 ++++++++ 13 files changed, 663 insertions(+), 791 deletions(-) create mode 100644 src/prerna/openapi/PixelInput.java rename src/prerna/{reactor/DynamicOutput.java => openapi/PixelInputs.java} (79%) rename src/prerna/{reactor/NoInput.java => openapi/PixelOutput.java} (57%) delete mode 100644 src/prerna/reactor/AbstractReactor2.java create mode 100644 src/prerna/reactor/ReactorContract.java create mode 100644 src/prerna/reactor/ReactorNames.java delete mode 100644 test/prerna/reactor/AbstractReactor2Test.java create mode 100644 test/prerna/reactor/ReactorContractTest.java diff --git a/src/prerna/openapi/Internal.java b/src/prerna/openapi/Internal.java index 91cadc8aacd..1d9d6b7dd5f 100644 --- a/src/prerna/openapi/Internal.java +++ b/src/prerna/openapi/Internal.java @@ -40,7 +40,7 @@ * everyone; this is a warning, not access control. *

* Put it on a resource method, on a resource class (every operation in it), or on - * an {@code AbstractReactor2} class. To leave something out of the document and + * a reactor class. To leave something out of the document and * the SDKs entirely, use swagger-core's {@code @Hidden} instead. */ @Documented diff --git a/src/prerna/openapi/PixelInput.java b/src/prerna/openapi/PixelInput.java new file mode 100644 index 00000000000..8788d804c03 --- /dev/null +++ b/src/prerna/openapi/PixelInput.java @@ -0,0 +1,85 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.openapi; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Refines what is inferred for one of a reactor's inputs. Every input in + * {@code keysToGet} is published without it; use it where the inferred type, + * description or requiredness is not good enough. + * + *

{@code
+ * @PixelInput(name = "value", type = Object.class)
+ * @PixelInput(name = "limit", type = Integer.class, required = Requirement.OPTIONAL)
+ * public class EchoReactor extends AbstractReactor {
+ * }
+ * + * Naming a key the reactor does not declare fails the OpenAPI export. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Repeatable(PixelInputs.class) +public @interface PixelInput { + + /** Whether an input must be passed. */ + enum Requirement { + /** As {@code keyRequired} says. */ + INFERRED, + /** It must be passed. */ + REQUIRED, + /** It may be left out. */ + OPTIONAL + } + + /** The input's key, as in {@code keysToGet} (the canonical name, before any alias). */ + String name(); + + /** + * Its type: {@code String}, {@code Integer}, {@code Long}, {@code Double}, + * {@code Boolean}, an enum, {@code Map}, {@code List} (with {@link #items()}), + * or {@code Object} for any value. {@code Void}, the default, keeps the + * type inferred from {@code getKeyTypeForMCP}. + */ + Class type() default Void.class; + + /** The element type when {@link #type()} is {@code List}. */ + Class items() default Void.class; + + /** Its description. Empty, the default, keeps {@code getDescriptionForKey}. */ + String description() default ""; + + /** Whether it must be passed. {@code INFERRED}, the default, follows {@code keyRequired}. */ + Requirement required() default Requirement.INFERRED; +} diff --git a/src/prerna/reactor/DynamicOutput.java b/src/prerna/openapi/PixelInputs.java similarity index 79% rename from src/prerna/reactor/DynamicOutput.java rename to src/prerna/openapi/PixelInputs.java index 33bba5b232e..464031c6ac5 100644 --- a/src/prerna/reactor/DynamicOutput.java +++ b/src/prerna/openapi/PixelInputs.java @@ -25,7 +25,7 @@ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. *******************************************************************************/ -package prerna.reactor; +package prerna.openapi; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -33,19 +33,10 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** - * Marks an {@link AbstractReactor2} whose output has no fixed type, such as one - * that returns a {@code NounMetadata} it was given. The contract then describes - * the output as any value, and the reason is written into the OpenAPI document - * so SDK users know why it is untyped. - */ +/** Holds repeated {@link PixelInput} annotations. */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) -public @interface DynamicOutput { - - /** - * Why the output has no fixed type, for example "returns its input". - */ - String value(); +public @interface PixelInputs { + PixelInput[] value(); } diff --git a/src/prerna/reactor/NoInput.java b/src/prerna/openapi/PixelOutput.java similarity index 57% rename from src/prerna/reactor/NoInput.java rename to src/prerna/openapi/PixelOutput.java index bc84320c1c3..a3b63764ade 100644 --- a/src/prerna/reactor/NoInput.java +++ b/src/prerna/openapi/PixelOutput.java @@ -25,13 +25,39 @@ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. *******************************************************************************/ -package prerna.reactor; +package prerna.openapi; -import io.swagger.v3.oas.annotations.media.Schema; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; /** - * The input of an {@link AbstractReactor2} that takes no inputs. + * Declares what a reactor returns, so the SDKs can type its output. Without it + * the output is published as any value: a reactor's code does not say what it + * returns, so it cannot be inferred. + * + *
{@code
+ * @PixelOutput(value = String.class, description = "The id of the current insight")
+ * public class GetCurrentInsightIdReactor extends AbstractReactor {
+ * }
*/ -@Schema(description = "No inputs") -public record NoInput() { +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface PixelOutput { + + /** + * The type of the output: {@code String}, {@code Integer}, {@code Long}, + * {@code Double}, {@code Boolean}, an enum, {@code Map}, {@code List} (with + * {@link #items()}), a record, or {@code Object} for any value. + */ + Class value(); + + /** The element type when {@link #value()} is {@code List}. */ + Class items() default Void.class; + + /** What the output is. */ + String description() default ""; } diff --git a/src/prerna/reactor/AbstractReactor2.java b/src/prerna/reactor/AbstractReactor2.java deleted file mode 100644 index efc16ae79b1..00000000000 --- a/src/prerna/reactor/AbstractReactor2.java +++ /dev/null @@ -1,440 +0,0 @@ -/******************************************************************************* - * Copyright 2015 Defense Health Agency (DHA) - * - * If your use of this software does not include any GPLv2 components: - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ---------------------------------------------------------------------------- - * If your use of this software includes any GPLv2 components: - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 2 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *******************************************************************************/ -package prerna.reactor; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.RecordComponent; -import java.lang.reflect.Type; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.Map; - -import io.swagger.v3.oas.annotations.media.Schema; -import prerna.sablecc2.om.GenRowStruct; -import prerna.sablecc2.om.PixelDataType; -import prerna.sablecc2.om.PixelOperationType; -import prerna.sablecc2.om.nounmeta.NounMetadata; - -/** - * A reactor that declares its inputs as a record and its output as a Java type, - * so its contract can be read without running it. - *

- * The input record is the only place the inputs are declared. From it this class - * fills {@link #keysToGet} and {@link #keyRequired} (so {@code organizeKeys()}, - * {@code --help} and {@code asMcpTool()} keep working), answers - * {@link #getDescriptionForKey(String)} and {@link #getKeyTypeForMCP(String)}, - * and builds the typed input passed to {@link #run(Record)}. The return value is - * wrapped in a {@link NounMetadata} whose data type follows from it. - * - *

{@code
- * public class StopPixelExecutionReactor
- * 		extends AbstractReactor2 {
- *
- * 	public record Input(@Schema(description = "The id for the job") String id) {
- * 	}
- *
- * 	protected String run(Input input) {
- * 		return "Cancel requested for pixel job " + input.id();
- * 	}
- * }
- * }
- * - * Record components are required unless they are annotated - * {@code @Schema(requiredMode = NOT_REQUIRED)}; an optional component must use a - * boxed type, and is {@code null} when it is not passed. Supported component - * types are {@code String}, {@code Integer}/{@code int}, {@code Long}/{@code long}, - * {@code Double}/{@code double}, {@code Boolean}/{@code boolean}, enums, - * {@code List} of those, {@code Map} and {@code Object} (any value). A reactor - * whose output has no fixed type returns {@link NounMetadata}, or {@code Object}, - * and is annotated {@link DynamicOutput}. - *

- * The SDK generator reads {@link #inputType(Class)} and {@link #outputType(Class)} - * from the class alone; they never construct the reactor. - * - * @param the input record; {@link NoInput} when there are none - * @param the output type - */ -public abstract class AbstractReactor2 extends AbstractReactor { - - private final Class inputRecord; - private final RecordComponent[] inputComponents; - - protected AbstractReactor2() { - this.inputRecord = inputType(getClass()); - this.inputComponents = this.inputRecord.getRecordComponents(); - - for (RecordComponent component : this.inputComponents) { - checkSupportedInput(component); - } - Type output = outputType(getClass()); - if (isDynamicOutput(output) && getClass().getAnnotation(DynamicOutput.class) == null) { - throw new IllegalStateException(getClass().getName() - + " returns a value with no fixed type; annotate it with @DynamicOutput(\"why\")"); - } - - this.keysToGet = Arrays.stream(this.inputComponents).map(RecordComponent::getName).toArray(String[]::new); - this.keyRequired = Arrays.stream(this.inputComponents).mapToInt(c -> isRequired(c) ? 1 : 0).toArray(); - } - - /** - * Runs the reactor. - * - * @param input the inputs, already checked: required ones are present and - * every value has its declared type - * @return the output; a {@link NounMetadata} is returned as it is - */ - protected abstract O run(I input); - - /** - * The operation types of the returned noun. Defaults to {@code OPERATION}, - * the same as {@code new NounMetadata(value, type)}. - */ - protected List getOperationTypes() { - return List.of(PixelOperationType.OPERATION); - } - - @Override - public final NounMetadata execute() { - organizeKeys(); - O result = run(readInput()); - if (result instanceof NounMetadata noun) { - return noun; - } - return new NounMetadata(toNounValue(result), dataTypeFor(result), getOperationTypes()); - } - - @Override - protected String getDescriptionForKey(String key) { - for (RecordComponent component : this.inputComponents) { - if (component.getName().equals(key)) { - Schema schema = schemaOf(component); - if (schema != null && !schema.description().isEmpty()) { - return schema.description(); - } - } - } - return super.getDescriptionForKey(key); - } - - @Override - protected MCP_KEY_TYPE getKeyTypeForMCP(String key) { - for (RecordComponent component : this.inputComponents) { - if (component.getName().equals(key)) { - Class type = component.getType(); - if (type == String.class || type.isEnum()) { - return MCP_KEY_TYPE.STRING; - } else if (isOneOf(type, Integer.class, int.class, Long.class, long.class)) { - return MCP_KEY_TYPE.INTEGER; - } else if (isOneOf(type, Double.class, double.class)) { - return MCP_KEY_TYPE.NUMBER; - } else if (isOneOf(type, Boolean.class, boolean.class)) { - return MCP_KEY_TYPE.BOOLEAN; - } else if (List.class.isAssignableFrom(type)) { - return MCP_KEY_TYPE.ARRAY; - } else if (Map.class.isAssignableFrom(type)) { - return MCP_KEY_TYPE.OBJECT; - } - } - } - return super.getKeyTypeForMCP(key); - } - - /* - * Reading the contract from the class, without constructing it - */ - - /** - * Returns the input record of a reactor class. - * - * @throws IllegalArgumentException if the class does not extend - * {@code AbstractReactor2} with concrete type - * arguments - */ - @SuppressWarnings("unchecked") - public static Class inputType(Class reactorClass) { - Type input = typeArguments(reactorClass)[0]; - if (!(input instanceof Class inputClass) || !inputClass.isRecord()) { - throw new IllegalArgumentException(reactorClass.getName() + ": the input must be a record, got " + input); - } - return (Class) inputClass; - } - - /** - * Returns the declared output type of a reactor class, such as - * {@code String.class} or a {@code ParameterizedType} for {@code List}. - */ - public static Type outputType(Class reactorClass) { - return typeArguments(reactorClass)[1]; - } - - /** - * Whether an output type has no fixed shape: {@link NounMetadata} or - * {@code Object}. - */ - public static boolean isDynamicOutput(Type output) { - return output == NounMetadata.class || output == Object.class; - } - - /** - * Whether an input record component is required: everything is, unless it is - * annotated {@code @Schema(requiredMode = NOT_REQUIRED)}. - */ - public static boolean isRequired(RecordComponent component) { - Schema schema = schemaOf(component); - return schema == null || schema.requiredMode() != Schema.RequiredMode.NOT_REQUIRED; - } - - /** - * Returns the {@code @Schema} on a record component. Annotations on a - * component are copied to its accessor and field, which is where they are read. - */ - public static Schema schemaOf(RecordComponent component) { - Schema schema = component.getAccessor().getAnnotation(Schema.class); - if (schema != null) { - return schema; - } - try { - Field field = component.getDeclaringRecord().getDeclaredField(component.getName()); - return field.getAnnotation(Schema.class); - } catch (NoSuchFieldException e) { - return null; - } - } - - private static Type[] typeArguments(Class reactorClass) { - Class current = reactorClass; - while (current != null && current.getSuperclass() != AbstractReactor2.class) { - current = current.getSuperclass(); - } - if (current == null) { - throw new IllegalArgumentException(reactorClass.getName() + " does not extend AbstractReactor2"); - } - Type superclass = current.getGenericSuperclass(); - if (!(superclass instanceof ParameterizedType parameterized)) { - throw new IllegalArgumentException( - current.getName() + " must give AbstractReactor2 its input and output types"); - } - Type[] arguments = parameterized.getActualTypeArguments(); - for (Type argument : arguments) { - if (argument instanceof java.lang.reflect.TypeVariable) { - throw new IllegalArgumentException(current.getName() - + " must give AbstractReactor2 concrete input and output types, not " + argument); - } - } - return arguments; - } - - /* - * Building the input - */ - - private void checkSupportedInput(RecordComponent component) { - Class type = component.getType(); - if (type.isPrimitive() && !isRequired(component)) { - throw new IllegalStateException(this.inputRecord.getName() + "." + component.getName() - + " is optional, so it must use a boxed type rather than " + type.getName()); - } - if (List.class.isAssignableFrom(type)) { - if (!isScalar(listElementType(component))) { - throw new IllegalStateException(this.inputRecord.getName() + "." + component.getName() - + ": a list input must hold strings, numbers, booleans or enums"); - } - return; - } - if (!isScalar(type) && !Map.class.isAssignableFrom(type) && type != Object.class) { - throw new IllegalStateException(this.inputRecord.getName() + "." + component.getName() - + ": input type " + type.getName() + " is not supported"); - } - } - - @SuppressWarnings("unchecked") - private I readInput() { - Object[] values = new Object[this.inputComponents.length]; - Class[] types = new Class[this.inputComponents.length]; - for (int i = 0; i < this.inputComponents.length; i++) { - types[i] = this.inputComponents[i].getType(); - values[i] = readComponent(this.inputComponents[i]); - } - try { - Constructor constructor = this.inputRecord.getDeclaredConstructor(types); - constructor.setAccessible(true); - return (I) constructor.newInstance(values); - } catch (InvocationTargetException e) { - if (e.getCause() instanceof RuntimeException runtime) { - throw runtime; - } - throw new IllegalArgumentException(e.getCause()); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Cannot build " + this.inputRecord.getName(), e); - } - } - - private Object readComponent(RecordComponent component) { - String name = component.getName(); - Class type = component.getType(); - - if (List.class.isAssignableFrom(type)) { - GenRowStruct values = nounsFor(name); - if (values == null) { - return null; - } - Class element = listElementType(component); - List list = new ArrayList<>(); - for (Object value : values.getAllValues()) { - list.add(convertScalar(value, element, name)); - } - return list; - } - - if (Map.class.isAssignableFrom(type) || type == Object.class) { - GenRowStruct values = nounsFor(name); - if (values == null) { - return null; - } - Object value = values.get(0); - if (Map.class.isAssignableFrom(type) && !(value instanceof Map)) { - throw new IllegalArgumentException(name + " must be a map"); - } - return value; - } - - // organizeKeys() already read scalars, named or by position, as text - String text = this.keyValue.get(name); - if (text == null) { - return null; - } - return convertScalar(text, type, name); - } - - /** The values passed for a key, or by position when the reactor has one input. */ - private GenRowStruct nounsFor(String name) { - GenRowStruct values = this.store == null ? null : this.store.getGenRowStruct(name); - if ((values == null || values.isEmpty()) && this.inputComponents.length == 1) { - values = this.curRow; - } - return values == null || values.isEmpty() ? null : values; - } - - @SuppressWarnings({ "unchecked", "rawtypes" }) - private static Object convertScalar(Object value, Class type, String name) { - try { - if (type == String.class) { - return value.toString(); - } else if (type == Integer.class || type == int.class) { - return value instanceof Number n && !(value instanceof Double || value instanceof Float) - ? n.intValue() - : new BigDecimal(value.toString()).intValueExact(); - } else if (type == Long.class || type == long.class) { - return value instanceof Number n && !(value instanceof Double || value instanceof Float) - ? n.longValue() - : new BigDecimal(value.toString()).longValueExact(); - } else if (type == Double.class || type == double.class) { - return value instanceof Number n ? n.doubleValue() : Double.parseDouble(value.toString()); - } else if (type == Boolean.class || type == boolean.class) { - if (value instanceof Boolean b) { - return b; - } - String text = value.toString(); - if (text.equalsIgnoreCase("true") || text.equalsIgnoreCase("false")) { - return Boolean.parseBoolean(text); - } - throw new IllegalArgumentException(name + " must be true or false, got " + text); - } else if (type.isEnum()) { - try { - return Enum.valueOf((Class) type, value.toString()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException(name + " must be one of " - + Arrays.toString(type.getEnumConstants()) + ", got " + value); - } - } else if (type == Object.class) { - return value; - } - } catch (NumberFormatException | ArithmeticException e) { - throw new IllegalArgumentException(name + " must be a whole number or a number as declared, got " + value); - } - throw new IllegalStateException(name + ": input type " + type.getName() + " is not supported"); - } - - private static Class listElementType(RecordComponent component) { - Type generic = component.getGenericType(); - if (generic instanceof ParameterizedType parameterized - && parameterized.getActualTypeArguments()[0] instanceof Class element) { - return element; - } - throw new IllegalStateException( - component.getDeclaringRecord().getName() + "." + component.getName() + " must be a List<...> of one type"); - } - - private static boolean isScalar(Class type) { - return type == String.class || type.isEnum() || isOneOf(type, Integer.class, int.class, Long.class, long.class, - Double.class, double.class, Boolean.class, boolean.class); - } - - private static boolean isOneOf(Class type, Class... options) { - for (Class option : options) { - if (type == option) { - return true; - } - } - return false; - } - - /* - * Wrapping the output - */ - - private static Object toNounValue(Object result) { - return result instanceof Enum e ? e.name() : result; - } - - private static PixelDataType dataTypeFor(Object result) { - if (result == null) { - return PixelDataType.NULL_VALUE; - } else if (result instanceof String || result instanceof Enum) { - return PixelDataType.CONST_STRING; - } else if (result instanceof Integer || result instanceof Long || result instanceof Short - || result instanceof Byte) { - return PixelDataType.CONST_INT; - } else if (result instanceof Number) { - return PixelDataType.CONST_DECIMAL; - } else if (result instanceof Boolean) { - return PixelDataType.BOOLEAN; - } else if (result instanceof Map || result instanceof Record) { - return PixelDataType.MAP; - } else if (result instanceof Collection || result.getClass().isArray()) { - return PixelDataType.VECTOR; - } - throw new IllegalStateException("Output type " + result.getClass().getName() + " is not supported"); - } -} diff --git a/src/prerna/reactor/EchoReactor.java b/src/prerna/reactor/EchoReactor.java index c5b0e322554..3a57ce4be4a 100644 --- a/src/prerna/reactor/EchoReactor.java +++ b/src/prerna/reactor/EchoReactor.java @@ -27,21 +27,22 @@ *******************************************************************************/ package prerna.reactor; -import io.swagger.v3.oas.annotations.media.Schema; +import prerna.openapi.PixelInput; import prerna.sablecc2.om.GenRowStruct; import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.ReactorKeysEnum; import prerna.sablecc2.om.nounmeta.NounMetadata; -@DynamicOutput("returns the value it is given, whatever its type") -public class EchoReactor extends AbstractReactor2 { +@PixelInput(name = "value", type = Object.class) +public class EchoReactor extends AbstractReactor { - public record Input( - @Schema(description = "The value to echo back", requiredMode = Schema.RequiredMode.NOT_REQUIRED) Object value) { + public EchoReactor() { + this.keysToGet = new String[] {ReactorKeysEnum.VALUE.getKey()}; } - + @Override - protected NounMetadata run(Input input) { - // returns the noun itself, so the value keeps the type it was given + public NounMetadata execute() { + organizeKeys(); GenRowStruct grs = this.store.getGenRowStruct(this.keysToGet[0]); if(grs != null && !grs.isEmpty()) { return grs.getNoun(0); @@ -54,6 +55,14 @@ protected NounMetadata run(Input input) { return new NounMetadata("", PixelDataType.CONST_STRING); } + @Override + protected String getDescriptionForKey(String key) { + if(key.equals(ReactorKeysEnum.VALUE.getKey())) { + return "The value to echo back"; + } + return super.getDescriptionForKey(key); + } + @Override public String getReactorDescription() { return "This reactor returns back the input the user enters"; diff --git a/src/prerna/reactor/ReactorContract.java b/src/prerna/reactor/ReactorContract.java new file mode 100644 index 00000000000..59718f9ef35 --- /dev/null +++ b/src/prerna/reactor/ReactorContract.java @@ -0,0 +1,199 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import prerna.openapi.Internal; +import prerna.openapi.PixelInput; +import prerna.openapi.PixelOutput; + +/** + * What a reactor declares about itself, read for the OpenAPI document the SDKs + * are generated from. + *

+ * Everything is inferred from what reactors already declare, so no reactor has + * to change to be published: + *

    + *
  • inputs from {@code keysToGet} (the canonical name before any alias);
  • + *
  • requiredness from {@code keyRequired}: with none, nothing is required, + * the way {@code organizeKeys()} checks at runtime;
  • + *
  • input types from {@code getKeyTypeForMCP};
  • + *
  • descriptions from {@code getDescriptionForKey} and + * {@code getReactorDescription}.
  • + *
+ * {@link PixelInput} refines one input and {@link PixelOutput} declares the + * output, which cannot be inferred. Reading a contract constructs the reactor, + * since its constructor sets {@code keysToGet}; it never runs it. + * + * @param name the name it is called by in Pixel + * @param description its description, or a placeholder + * @param descriptionInferred whether the description is a placeholder + * @param inputs its inputs, in {@code keysToGet} order + * @param output its declared output, or null when undeclared + * @param internal its {@link Internal}, or null + */ +public record ReactorContract(String name, String description, boolean descriptionInferred, List inputs, + Output output, Internal internal) { + + /** + * One input. + * + * @param type {@code String}, {@code Integer}, {@code Double}, + * {@code Boolean}, {@code List}, {@code Map}, an enum, or + * {@code Object} for any value + * @param items the element type of a {@code List}, or {@code Void} + * @param typeInferred whether the type came from {@code getKeyTypeForMCP} + * rather than a {@link PixelInput} + */ + public record Input(String name, Class type, Class items, String description, boolean required, + boolean typeInferred, boolean descriptionInferred) { + } + + /** A declared output, from {@link PixelOutput}. */ + public record Output(Class type, Class items, String description) { + } + + /** Whether anything in the contract is inferred rather than declared. */ + public boolean inferred() { + return this.descriptionInferred || this.output == null + || this.inputs.stream().anyMatch(i -> i.typeInferred() || i.descriptionInferred()); + } + + /** The name a reactor class is called by in Pixel. */ + public static String pixelName(Class reactorClass) { + return ReactorNames.pixelName(reactorClass.getSimpleName()); + } + + /** Whether a reactor class is a general one rather than specific to one frame type. */ + public static boolean isGeneral(Class reactorClass) { + return ReactorNames.frameType(reactorClass.getPackageName()) == null; + } + + /** + * Reads a reactor's contract. + * + * @throws ReflectiveOperationException when the reactor cannot be constructed + * @throws IllegalArgumentException when it is not an {@code AbstractReactor}, + * or declares a key twice + * @throws IllegalStateException when a {@link PixelInput} names a key + * the reactor does not declare + */ + public static ReactorContract of(Class reactorClass) throws ReflectiveOperationException { + if (!AbstractReactor.class.isAssignableFrom(reactorClass)) { + throw new IllegalArgumentException(reactorClass.getName() + " is not an AbstractReactor"); + } + AbstractReactor reactor = (AbstractReactor) reactorClass.getDeclaredConstructor().newInstance(); + String name = pixelName(reactorClass); + + Map overrides = new LinkedHashMap<>(); + for (PixelInput input : reactorClass.getAnnotationsByType(PixelInput.class)) { + overrides.put(input.name(), input); + } + + List inputs = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + String[] keys = reactor.keysToGet == null ? new String[0] : reactor.keysToGet; + for (int i = 0; i < keys.length; i++) { + String key = keys[i] == null ? "" : keys[i].split(",", -1)[0].trim(); + if (key.isEmpty()) { + throw new IllegalArgumentException(name + " has an empty key at position " + i + " of keysToGet"); + } + if (!seen.add(key)) { + throw new IllegalArgumentException(name + " declares the key \"" + key + "\" twice"); + } + boolean required = reactor.keyRequired != null && i < reactor.keyRequired.length + && reactor.keyRequired[i] == 1; + + Class type = typeOf(reactor.getKeyTypeForMCP(key)); + Class items = Void.class; + boolean typeInferred = true; + String description = reactor.getDescriptionForKey(key); + boolean descriptionInferred = description == null || description.isBlank(); + if (descriptionInferred) { + description = "The " + key + " input"; + } + + PixelInput override = overrides.remove(key); + if (override != null) { + if (override.type() != Void.class) { + type = override.type(); + items = override.items(); + typeInferred = false; + } + if (!override.description().isBlank()) { + description = override.description(); + descriptionInferred = false; + } + if (override.required() == PixelInput.Requirement.REQUIRED) { + required = true; + } else if (override.required() == PixelInput.Requirement.OPTIONAL) { + required = false; + } + } + inputs.add(new Input(key, type, items, description, required, typeInferred, descriptionInferred)); + } + if (!overrides.isEmpty()) { + throw new IllegalStateException(reactorClass.getName() + " has @PixelInput for " + overrides.keySet() + + ", which are not in its keysToGet"); + } + + String description = reactor.getReactorDescription(); + boolean descriptionInferred = description == null || description.isBlank(); + if (descriptionInferred) { + description = name + " reactor"; + } + + PixelOutput declared = reactorClass.getAnnotation(PixelOutput.class); + Output output = declared == null ? null + : new Output(declared.value(), declared.items(), declared.description()); + + return new ReactorContract(name, description, descriptionInferred, List.copyOf(inputs), output, + reactorClass.getAnnotation(Internal.class)); + } + + /** Maps the type a reactor reports for MCP to a Java type. */ + private static Class typeOf(IReactor.MCP_KEY_TYPE type) { + if (type == null) { + return String.class; + } + return switch (type) { + case INTEGER -> Integer.class; + case NUMBER -> Double.class; + case BOOLEAN -> Boolean.class; + case ARRAY -> List.class; + case OBJECT -> Map.class; + default -> String.class; + }; + } +} diff --git a/src/prerna/reactor/ReactorFactory.java b/src/prerna/reactor/ReactorFactory.java index 13fd7baddc7..d44d5dd25da 100644 --- a/src/prerna/reactor/ReactorFactory.java +++ b/src/prerna/reactor/ReactorFactory.java @@ -501,44 +501,25 @@ private static void loadFromCP(String... packages) { // ignore abstract and engines if (!Modifier.isAbstract(actualClass.getModifiers()) && !Arrays.asList(actualClass.getInterfaces()).contains(IEngine.class)) { - String[] packagePaths = packageName.split("\\."); - // System.out.println("Package name " + packageName); - packageName = packagePaths[packagePaths.length - 1]; - boolean frame = false; - if (packagePaths.length > 2) { - frame = packagePaths[packagePaths.length - 2].equalsIgnoreCase("frame"); - } - // we will allow for 1 more level - // i.e. things of the form so *.frame.r.? - if (!frame && packagePaths.length > 3) { - packageName = packagePaths[packagePaths.length - 2]; - frame = packagePaths[packagePaths.length - 3].equalsIgnoreCase("frame"); - } - String reactorName = name; - final String REACTOR_KEY = "REACTOR"; - if (reactorName.toUpperCase().endsWith(REACTOR_KEY)) { - reactorName = reactorName.substring(0, reactorName.length() - REACTOR_KEY.length()); - } + // ReactorNames holds these rules so the OpenAPI export reads reactors the same way + String reactorName = ReactorNames.pixelName(name); + String frameType = ReactorNames.frameType(packageName); - if (frame) { - if (packageName.equalsIgnoreCase("rdbms")) { + if (frameType != null) { + if (frameType.equalsIgnoreCase("rdbms")) { h2FrameHash.put(reactorName, actualClass); - } else if (packageName.equalsIgnoreCase("r")) { + } else if (frameType.equalsIgnoreCase("r")) { rFrameHash.put(reactorName, actualClass); - } else if (packageName.equalsIgnoreCase("py")) { + } else if (frameType.equalsIgnoreCase("py")) { pandasFrameHash.put(reactorName, actualClass); - } else if (packageName.equalsIgnoreCase("tinker") || packageName.equalsIgnoreCase("graph")) { + } else { + // tinker or graph tinkerFrameHash.put(reactorName, actualClass); - } else {// nullify the package name - packageName = null; - // general reactor - // example - frame filters - reactorHash.put(reactorName, actualClass); - } - if (packageName != null) { - reactorName = packageName + "_" + reactorName; } + reactorName = frameType + "_" + reactorName; } else { + // general reactor, including those under a frame package that is + // not a frame type, such as frame filters reactorHash.put(reactorName, actualClass); } nmList.add(reactorName); diff --git a/src/prerna/reactor/ReactorNames.java b/src/prerna/reactor/ReactorNames.java new file mode 100644 index 00000000000..9629a8cfbae --- /dev/null +++ b/src/prerna/reactor/ReactorNames.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor; + +import java.util.Set; + +/** + * How a reactor class is named in Pixel and whether it belongs to one frame + * type. {@link ReactorFactory} registers reactors with these rules, and the + * OpenAPI export reads contracts with them, so both always agree. + */ +public final class ReactorNames { + + private static final String REACTOR_SUFFIX = "REACTOR"; + private static final Set FRAME_TYPES = Set.of("rdbms", "r", "py", "tinker", "graph"); + + private ReactorNames() { + } + + /** The name a reactor is called by in Pixel: its class name without "Reactor". */ + public static String pixelName(String simpleClassName) { + if (simpleClassName.toUpperCase().endsWith(REACTOR_SUFFIX)) { + return simpleClassName.substring(0, simpleClassName.length() - REACTOR_SUFFIX.length()); + } + return simpleClassName; + } + + /** + * The frame type a reactor in this package is specific to, as written in the + * package (such as {@code r} for {@code prerna.reactor.frame.r}), or null for + * a general reactor. A package one level below {@code frame}, or two + * ({@code *.frame.r.util}), names its frame type. + */ + public static String frameType(String packageName) { + String[] packagePaths = packageName.split("\\."); + String last = packagePaths[packagePaths.length - 1]; + boolean frame = false; + if (packagePaths.length > 2) { + frame = packagePaths[packagePaths.length - 2].equalsIgnoreCase("frame"); + } + // we will allow for 1 more level + // i.e. things of the form so *.frame.r.? + if (!frame && packagePaths.length > 3) { + last = packagePaths[packagePaths.length - 2]; + frame = packagePaths[packagePaths.length - 3].equalsIgnoreCase("frame"); + } + if (frame && FRAME_TYPES.contains(last.toLowerCase())) { + return last; + } + // not under frame, or a general reactor under it (such as frame filters) + return null; + } +} diff --git a/src/prerna/reactor/StopPixelExecutionReactor.java b/src/prerna/reactor/StopPixelExecutionReactor.java index 0392f36d203..c2c92307281 100644 --- a/src/prerna/reactor/StopPixelExecutionReactor.java +++ b/src/prerna/reactor/StopPixelExecutionReactor.java @@ -29,7 +29,7 @@ import java.util.Objects; -import io.swagger.v3.oas.annotations.media.Schema; +import prerna.openapi.PixelOutput; import prerna.auth.User; import prerna.auth.utils.SecurityAdminUtils; import prerna.reactor.agent.AgentCancelHook; @@ -37,21 +37,30 @@ import prerna.sablecc2.comm.PixelJobManager; import prerna.sablecc2.comm.PixelJobManager.InterruptResult; import prerna.sablecc2.comm.PixelJobRunner; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.ReactorKeysEnum; +import prerna.sablecc2.om.nounmeta.NounMetadata; import prerna.tcp.client.SocketClient; -public class StopPixelExecutionReactor extends AbstractReactor2 { +@PixelOutput(value = String.class, description = "Whether the job was not found, asked to cancel, or already completed") +public class StopPixelExecutionReactor extends AbstractReactor { - public record Input( - @Schema(description = "The id for the job. If running the pixel synchronously, the job id will be the same as the insight id.") String id) { + public StopPixelExecutionReactor() { + this.keysToGet = new String[] { ReactorKeysEnum.ID.getKey() }; + this.keyRequired = new int[] { 1 }; } @Override - protected String run(Input input) { - String jobId = input.id(); + public NounMetadata execute() { + this.organizeKeys(); + + String jobId = this.keyValue.get(ReactorKeysEnum.ID.getKey()); PixelJobManager jobManager = PixelJobManager.getManager(); PixelJobRunner jobRunner = jobManager.getJob(jobId); if (jobRunner == null) { - return "Pixel job " + jobId + " not found"; + return new NounMetadata("Pixel job " + jobId + " not found", PixelDataType.CONST_STRING, + PixelOperationType.OPERATION); } User user = this.insight.getUser(); @@ -105,11 +114,19 @@ protected String run(Input input) { } else { message = "Pixel job " + jobId + " not found"; } - return message; + return new NounMetadata(message, PixelDataType.CONST_STRING, PixelOperationType.OPERATION); } @Override public String getReactorDescription() { return "Stop the current execution of a pixel job"; } + + @Override + protected String getDescriptionForKey(String key) { + if (key.equalsIgnoreCase(ReactorKeysEnum.ID.getKey())) { + return "The id for the job. If running the pixel synchronously, the job id will be the same as the insight id."; + } + return super.getDescriptionForKey(key); + } } diff --git a/src/prerna/reactor/insights/GetCurrentInsightIdReactor.java b/src/prerna/reactor/insights/GetCurrentInsightIdReactor.java index c77ddcfd4d8..7abd704dfb2 100644 --- a/src/prerna/reactor/insights/GetCurrentInsightIdReactor.java +++ b/src/prerna/reactor/insights/GetCurrentInsightIdReactor.java @@ -27,19 +27,17 @@ *******************************************************************************/ package prerna.reactor.insights; -import prerna.reactor.AbstractReactor2; -import prerna.reactor.NoInput; +import prerna.openapi.PixelOutput; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.nounmeta.NounMetadata; -public class GetCurrentInsightIdReactor extends AbstractReactor2 { +@PixelOutput(value = String.class, description = "The id of the current insight") +public class GetCurrentInsightIdReactor extends AbstractReactor { @Override - protected String run(NoInput input) { - return this.insight.getInsightId(); - } - - @Override - public String getReactorDescription() { - return "Returns the id of the current insight"; + public NounMetadata execute() { + return new NounMetadata(this.insight.getInsightId(), PixelDataType.CONST_STRING); } } diff --git a/test/prerna/reactor/AbstractReactor2Test.java b/test/prerna/reactor/AbstractReactor2Test.java deleted file mode 100644 index 9502d9b2bde..00000000000 --- a/test/prerna/reactor/AbstractReactor2Test.java +++ /dev/null @@ -1,275 +0,0 @@ -/******************************************************************************* - * Copyright 2015 Defense Health Agency (DHA) - * - * If your use of this software does not include any GPLv2 components: - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ---------------------------------------------------------------------------- - * If your use of this software includes any GPLv2 components: - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 2 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *******************************************************************************/ -package prerna.reactor; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.Map; - -import org.json.JSONObject; -import org.junit.jupiter.api.Test; - -import io.swagger.v3.oas.annotations.media.Schema; -import prerna.openapi.Internal; -import prerna.sablecc2.om.PixelDataType; -import prerna.sablecc2.om.PixelOperationType; -import prerna.sablecc2.om.nounmeta.NounMetadata; - -class AbstractReactor2Test { - - enum Color { - RED, BLUE - } - - /** Echoes its typed input back as a map, to check how each type is read. */ - static class TypesReactor extends AbstractReactor2> { - - record Input(@Schema(description = "A name") String name, @Schema(description = "A count") Integer count, - @Schema(description = "A ratio") double ratio, @Schema(description = "A flag") Boolean flag, - @Schema(description = "A color") Color color, @Schema(description = "Some tags") List tags, - @Schema(description = "A note", requiredMode = Schema.RequiredMode.NOT_REQUIRED) String note) { - } - - @Override - protected Map run(Input input) { - Map out = new java.util.HashMap<>(); - out.put("name", input.name()); - out.put("count", input.count()); - out.put("ratio", input.ratio()); - out.put("flag", input.flag()); - out.put("color", input.color()); - out.put("tags", input.tags()); - out.put("note", input.note()); - return out; - } - } - - static class OneInputReactor extends AbstractReactor2 { - - record Input(@Schema(description = "How many") Integer count) { - } - - @Override - protected Integer run(Input input) { - return input.count() * 2; - } - - @Override - protected List getOperationTypes() { - return List.of(PixelOperationType.SUCCESS); - } - } - - static class OptionalPrimitiveReactor extends AbstractReactor2 { - - record Input(@Schema(description = "Bad", requiredMode = Schema.RequiredMode.NOT_REQUIRED) int count) { - } - - @Override - protected String run(Input input) { - return ""; - } - } - - @Internal("Testing only") - static class InternalReactor extends AbstractReactor2 { - @Override - protected String run(NoInput input) { - return "internal"; - } - } - - static class UndeclaredDynamicReactor extends AbstractReactor2 { - @Override - protected Object run(NoInput input) { - return null; - } - } - - private static void named(AbstractReactor reactor, String key, Object... values) { - reactor.curNoun(key); - for (Object value : values) { - if (value instanceof String s) { - reactor.getCurRow().addLiteral(s); - } else if (value instanceof Integer i) { - reactor.getCurRow().addInteger(i); - } else if (value instanceof Double d) { - reactor.getCurRow().addDecimal(d); - } else if (value instanceof Boolean b) { - reactor.getCurRow().addBoolean(b); - } - } - reactor.closeNoun(key); - } - - private static TypesReactor typesReactorWithRequiredInputs() { - return typesReactorWithColor("BLUE"); - } - - private static TypesReactor typesReactorWithColor(String color) { - TypesReactor reactor = new TypesReactor(); - reactor.In(); - named(reactor, "name", "Ann"); - named(reactor, "count", 3); - named(reactor, "ratio", 0.5); - named(reactor, "flag", true); - named(reactor, "color", color); - named(reactor, "tags", "a", "b"); - return reactor; - } - - @Test - void declaresKeysRequirednessAndDescriptionsFromTheRecord() { - TypesReactor reactor = new TypesReactor(); - - assertArrayEquals(new String[] { "name", "count", "ratio", "flag", "color", "tags", "note" }, - reactor.keysToGet); - JSONObject tool = reactor.asMcpTool(); - JSONObject properties = tool.getJSONObject("inputSchema").getJSONObject("properties"); - assertEquals("A count", properties.getJSONObject("count").getString("description")); - assertEquals("integer", properties.getJSONObject("count").getString("type")); - assertEquals("number", properties.getJSONObject("ratio").getString("type")); - assertEquals("boolean", properties.getJSONObject("flag").getString("type")); - assertEquals("array", properties.getJSONObject("tags").getString("type")); - assertEquals(List.of("name", "count", "ratio", "flag", "color", "tags"), - tool.getJSONObject("inputSchema").getJSONArray("required").toList()); - } - - @Test - @SuppressWarnings("unchecked") - void readsEveryInputAsItsDeclaredType() { - NounMetadata noun = typesReactorWithRequiredInputs().execute(); - Map out = (Map) noun.getValue(); - - assertEquals(PixelDataType.MAP, noun.getNounType()); - assertEquals("Ann", out.get("name")); - assertEquals(3, out.get("count")); - assertEquals(0.5, out.get("ratio")); - assertEquals(true, out.get("flag")); - assertEquals(Color.BLUE, out.get("color")); - assertEquals(List.of("a", "b"), out.get("tags")); - assertNull(out.get("note")); - assertEquals(List.of(PixelOperationType.OPERATION), noun.getOpType()); - } - - @Test - void rejectsAMissingRequiredInputTheWayOrganizeKeysDoes() { - TypesReactor reactor = new TypesReactor(); - reactor.In(); - named(reactor, "name", "Ann"); - - IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); - assertTrue(error.getMessage().startsWith("Required input(s) missing:"), error.getMessage()); - } - - @Test - void rejectsAValueOfTheWrongType() { - TypesReactor reactor = typesReactorWithColor("GREEN"); - - IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); - assertTrue(error.getMessage().contains("color must be one of [RED, BLUE]"), error.getMessage()); - } - - @Test - void acceptsASingleInputByPositionAndWrapsTheOutputWithItsOperationTypes() { - OneInputReactor reactor = new OneInputReactor(); - reactor.In(); - reactor.getCurRow().addInteger(21); - - NounMetadata noun = reactor.execute(); - - assertEquals(42, noun.getValue()); - assertEquals(PixelDataType.CONST_INT, noun.getNounType()); - assertEquals(List.of(PixelOperationType.SUCCESS), noun.getOpType()); - } - - @Test - void readsTheContractFromTheClassWithoutConstructingIt() { - assertEquals(StopPixelExecutionReactor.Input.class, - AbstractReactor2.inputType(StopPixelExecutionReactor.class)); - assertEquals(String.class, AbstractReactor2.outputType(StopPixelExecutionReactor.class)); - assertEquals(NoInput.class, AbstractReactor2.inputType(prerna.reactor.insights.GetCurrentInsightIdReactor.class)); - assertTrue(AbstractReactor2.isDynamicOutput(AbstractReactor2.outputType(EchoReactor.class))); - assertThrows(IllegalArgumentException.class, () -> AbstractReactor2.inputType(AbstractReactor.class)); - } - - @Test - void failsFastOnContractsItCannotHonor() { - assertThrows(IllegalStateException.class, OptionalPrimitiveReactor::new); - assertThrows(IllegalStateException.class, UndeclaredDynamicReactor::new); - } - - @Test - void keepsThePilotReactorsContracts() { - StopPixelExecutionReactor stop = new StopPixelExecutionReactor(); - assertArrayEquals(new String[] { "id" }, stop.keysToGet); - assertEquals("StopPixelExecution", stop.asMcpTool().getString("name")); - assertTrue(stop.getHelp().contains("If running the pixel synchronously")); - - EchoReactor echo = new EchoReactor(); - assertArrayEquals(new String[] { "value" }, echo.keysToGet); - } - - @Test - void internalIsReadableFromTheClassAndDoesNotChangeExecution() { - Internal internal = InternalReactor.class.getAnnotation(Internal.class); - assertEquals("Testing only", internal.value()); - - InternalReactor reactor = new InternalReactor(); - reactor.In(); - assertEquals("internal", reactor.execute().getValue()); - } - - @Test - void echoStillReturnsTheNounItWasGiven() { - EchoReactor named = new EchoReactor(); - named.In(); - named(named, "value", 7); - NounMetadata namedOut = named.execute(); - assertEquals(7, namedOut.getValue()); - assertEquals(PixelDataType.CONST_INT, namedOut.getNounType()); - - EchoReactor positional = new EchoReactor(); - positional.In(); - positional.getCurRow().addLiteral("hi"); - NounMetadata positionalOut = positional.execute(); - assertSame(positional.getCurRow().getNoun(0), positionalOut); - - EchoReactor empty = new EchoReactor(); - empty.In(); - NounMetadata emptyOut = empty.execute(); - assertEquals("", emptyOut.getValue()); - assertEquals(PixelDataType.CONST_STRING, emptyOut.getNounType()); - } -} diff --git a/test/prerna/reactor/ReactorContractTest.java b/test/prerna/reactor/ReactorContractTest.java new file mode 100644 index 00000000000..1556f09fe3e --- /dev/null +++ b/test/prerna/reactor/ReactorContractTest.java @@ -0,0 +1,203 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import prerna.openapi.Internal; +import prerna.openapi.PixelInput; +import prerna.openapi.PixelOutput; +import prerna.reactor.insights.GetCurrentInsightIdReactor; +import prerna.sablecc2.om.ReactorKeysEnum; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +class ReactorContractTest { + + /** Declares keys the way most reactors do, with no annotations. */ + static class PlainReactor extends AbstractReactor { + PlainReactor() { + this.keysToGet = new String[] { "name,alias", ReactorKeysEnum.LIMIT.getKey(), "notes" }; + this.keyRequired = new int[] { 1, 0, 0 }; + } + + @Override + public NounMetadata execute() { + return null; + } + + @Override + protected String getDescriptionForKey(String key) { + return key.equals("notes") ? null : super.getDescriptionForKey(key); + } + } + + /** No keyRequired: organizeKeys() requires nothing. */ + static class NoRequiredReactor extends AbstractReactor { + NoRequiredReactor() { + this.keysToGet = new String[] { "a", "b" }; + } + + @Override + public NounMetadata execute() { + return null; + } + + @Override + public String getReactorDescription() { + return "Has no required keys"; + } + } + + @PixelInput(name = "a", type = List.class, items = String.class, description = "Some names", required = PixelInput.Requirement.REQUIRED) + @PixelInput(name = "b", required = PixelInput.Requirement.OPTIONAL) + @PixelOutput(value = List.class, items = Integer.class, description = "Some counts") + @Internal("Testing only") + static class AnnotatedReactor extends NoRequiredReactor { + } + + @PixelInput(name = "missing", type = String.class) + static class BadOverrideReactor extends NoRequiredReactor { + } + + static class TwiceReactor extends AbstractReactor { + TwiceReactor() { + this.keysToGet = new String[] { "a", "a,alias" }; + } + + @Override + public NounMetadata execute() { + return null; + } + } + + private static ReactorContract.Input input(ReactorContract contract, String name) { + return contract.inputs().stream().filter(i -> i.name().equals(name)).findFirst().orElseThrow(); + } + + @Test + void infersInputsFromKeysRequirednessTypesAndDescriptions() throws Exception { + ReactorContract contract = ReactorContract.of(PlainReactor.class); + + assertEquals("Plain", contract.name()); + assertEquals(List.of("name", "limit", "notes"), contract.inputs().stream().map(ReactorContract.Input::name).toList()); + assertTrue(input(contract, "name").required()); + assertFalse(input(contract, "limit").required()); + assertEquals(Integer.class, input(contract, "limit").type(), "LIMIT is an integer for MCP"); + assertEquals(String.class, input(contract, "name").type()); + assertTrue(input(contract, "name").typeInferred()); + + assertEquals("The notes input", input(contract, "notes").description()); + assertTrue(input(contract, "notes").descriptionInferred()); + assertEquals("Plain reactor", contract.description()); + assertTrue(contract.descriptionInferred()); + assertNull(contract.output()); + assertTrue(contract.inferred()); + } + + @Test + void treatsMissingKeyRequiredAsNothingRequiredLikeOrganizeKeys() throws Exception { + ReactorContract contract = ReactorContract.of(NoRequiredReactor.class); + + assertFalse(input(contract, "a").required()); + assertFalse(input(contract, "b").required()); + assertEquals("Has no required keys", contract.description()); + assertFalse(contract.descriptionInferred()); + } + + @Test + void appliesPixelInputPixelOutputAndInternal() throws Exception { + ReactorContract contract = ReactorContract.of(AnnotatedReactor.class); + + ReactorContract.Input a = input(contract, "a"); + assertEquals(List.class, a.type()); + assertEquals(String.class, a.items()); + assertEquals("Some names", a.description()); + assertTrue(a.required()); + assertFalse(a.typeInferred()); + + ReactorContract.Input b = input(contract, "b"); + assertFalse(b.required()); + assertTrue(b.typeInferred(), "a @PixelInput without a type keeps the inferred one"); + + assertEquals(List.class, contract.output().type()); + assertEquals(Integer.class, contract.output().items()); + assertEquals("Some counts", contract.output().description()); + assertEquals("Testing only", contract.internal().value()); + } + + @Test + void rejectsAPixelInputForAKeyTheReactorDoesNotDeclare() { + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> ReactorContract.of(BadOverrideReactor.class)); + assertTrue(error.getMessage().contains("[missing]"), error.getMessage()); + } + + @Test + void rejectsAKeyDeclaredTwice() { + assertThrows(IllegalArgumentException.class, () -> ReactorContract.of(TwiceReactor.class)); + } + + @Test + void namesAndFrameTypesFollowReactorFactory() { + assertEquals("StopPixelExecution", ReactorNames.pixelName("StopPixelExecutionReactor")); + assertEquals("Plain", ReactorNames.pixelName("Plain")); + assertEquals("r", ReactorNames.frameType("prerna.reactor.frame.r")); + assertEquals("py", ReactorNames.frameType("prerna.reactor.frame.py.util")); + assertEquals("rdbms", ReactorNames.frameType("prerna.reactor.frame.rdbms")); + assertNull(ReactorNames.frameType("prerna.reactor.frame.filter"), "frame filters are general"); + assertNull(ReactorNames.frameType("prerna.reactor.insights")); + assertTrue(ReactorContract.isGeneral(GetCurrentInsightIdReactor.class)); + } + + @Test + void readsThePilotReactors() throws Exception { + ReactorContract stop = ReactorContract.of(StopPixelExecutionReactor.class); + assertEquals(List.of("id"), stop.inputs().stream().map(ReactorContract.Input::name).toList()); + assertTrue(input(stop, "id").required()); + assertEquals(String.class, input(stop, "id").type()); + assertEquals(String.class, stop.output().type()); + assertEquals("Stop the current execution of a pixel job", stop.description()); + + ReactorContract echo = ReactorContract.of(EchoReactor.class); + assertEquals(Object.class, input(echo, "value").type()); + assertFalse(input(echo, "value").required()); + assertNull(echo.output()); + + ReactorContract insight = ReactorContract.of(GetCurrentInsightIdReactor.class); + assertTrue(insight.inputs().isEmpty()); + assertEquals(String.class, insight.output().type()); + } +}