From bd7dbdbc33b10443a822e8b8b30511f0f2fad518 Mon Sep 17 00:00:00 2001 From: Gene Gleyzer Date: Tue, 19 Nov 2024 09:28:09 -0500 Subject: [PATCH 1/2] Create "webcli" module --- gradle/libs.versions.toml | 1 + lib_cli/src/main/x/cli.x | 390 ++---------------- lib_cli/src/main/x/cli/Runner.x | 333 +++++++++++++++ lib_cli/src/main/x/cli/Scanner.x | 60 +++ lib_web/src/main/x/web/Client.x | 7 +- lib_webcli/build.gradle.kts | 18 + lib_webcli/src/main/x/webcli.x | 173 ++++++++ manualTests/src/main/x/cliTest.x | 34 ++ manualTests/src/main/x/webTests/HelloClient.x | 36 +- xdk/build.gradle.kts | 1 + xdk/settings.gradle.kts | 1 + 11 files changed, 674 insertions(+), 380 deletions(-) create mode 100644 lib_cli/src/main/x/cli/Runner.x create mode 100644 lib_cli/src/main/x/cli/Scanner.x create mode 100644 lib_webcli/build.gradle.kts create mode 100644 lib_webcli/src/main/x/webcli.x create mode 100644 manualTests/src/main/x/cliTest.x diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0125d474b..e618bdf9d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,6 +56,7 @@ xdk-net = { group = "org.xtclang", name = "lib-net", version.ref = "xdk" } xdk-oodb = { group = "org.xtclang", name = "lib-oodb", version.ref = "xdk" } xdk-web = { group = "org.xtclang", name = "lib-web", version.ref = "xdk" } xdk-webauth = { group = "org.xtclang", name = "lib-webauth", version.ref = "xdk" } +xdk-webcli = { group = "org.xtclang", name = "lib-webcli", version.ref = "xdk" } xdk-xenia = { group = "org.xtclang", name = "lib-xenia", version.ref = "xdk" } javatools = { group = "org.xtclang", name = "javatools", version.ref = "xdk" } diff --git a/lib_cli/src/main/x/cli.x b/lib_cli/src/main/x/cli.x index abf12d834c..8d8398e259 100644 --- a/lib_cli/src/main/x/cli.x +++ b/lib_cli/src/main/x/cli.x @@ -32,383 +32,57 @@ module cli.xtclang.org { @Inject Console console; - mixin TerminalApp(String description = "", - String commandPrompt = "> ", - String messagePrefix = "# ", - ) + /** + * The mixin into a module. + */ + mixin TerminalApp() into module { - typedef Map as Catalog; - - /** - * The entry point. - */ - void run(String[] args) { - Catalog catalog = buildCatalog(this); - if (args.size == 0) { - runLoop(catalog); - } else { - runOnce(catalog, args); - } - } - - /** - * Run a single command. - */ - void runOnce(Catalog catalog, String[] args) { - runCommand(args, catalog); - } - - /** - * Read commands from the console and run them. - */ - void runLoop(Catalog catalog) { - while (True) { - String command = console.readLine(commandPrompt); - - (Boolean ok, String[] args) = parseCommand(command); - if (ok) { - if (!runCommand(args, catalog)) { - return; - } - } else { - console.print($"Error in command: {args[0]}"); - } - } - } - /** - * Parse the command string into a series of pieces. + * The mixin constructor. * - * @return True iff the parsing encountered no issues - * @return an array of parsed arguments, otherwise a description of the parsing error + * @param description the CLI tool description + * @param commandPrompt the command prompt + * @param messagePrefix the prefix for messages printed by the CLI tool itself via [print] + * method */ - (Boolean, String[]) parseCommand(String command) { - Int offset = 0; - Int length = command.size; - String[] args = new String[]; - StringBuffer buf = new StringBuffer(); - Boolean quoted = False; - Boolean escaped = False; - while (offset < length) { - switch (Char ch = command[offset++]) { - case ' ': - if (quoted) { - if (escaped) { - buf.add('\\'); // it was not an escape - escaped = False; - } - buf.add(' '); - } else if (buf.size > 0) { - args.add(buf.toString()); - buf = new StringBuffer(); - } - break; - - case '"': - if (escaped) { - buf.add('\"'); - escaped = False; - } else if (quoted) { - // this needs to be the end of the command or there needs to be a space - if (offset < length && command[offset] != ' ') { - return False, ["A space is required after a quoted argument"]; - } + construct(String description = "", + String commandPrompt = "> ", + String messagePrefix = "# " + ) { - args.add(buf.toString()); - buf = new StringBuffer(); - quoted = False; - } else if (buf.size == 0) { - quoted = True; - } else { - return False, ["Unexpected quote character"]; - } - break; - - case '\\': - if (escaped) { - buf.add('\\'); - escaped = False; - } else if (quoted) { - escaped = True; - } else { - return False, ["Unexpected escape outside of quoted text"]; - } - break; - - case '0': - case 'b': - case 'd': - case 'e': - case 'f': - case 'n': - case 'r': - case 't': - case 'v': - case 'z': - case '\'': - if (escaped) { - buf.append(switch (ch) { - case '0': '\0'; - case 'b': '\b'; - case 'd': '\d'; - case 'e': '\e'; - case 'f': '\f'; - case 'n': '\n'; - case 'r': '\r'; - case 't': '\t'; - case 'v': '\v'; - case 'z': '\z'; - case '\'': '\''; - default: assert; - }); - escaped = False; - } else { - buf.add(ch); - } - break; - - default: - if (escaped) { - // it wasn't an escape - buf.add('\\'); - escaped = False; - } - buf.add(ch); - break; - } - } - - if (quoted) { - return False, ["Missing a closing quote"]; - } - - if (buf.size > 0) { - args.add(buf.toString()); - } - - return True, args; + this.messagePrefix = messagePrefix; + Runner.init(description, commandPrompt); } - /** - * Find the specified command in the catalog. - */ - conditional CmdInfo findCommand(String command, Catalog catalog) { - for ((String name, CmdInfo info) : catalog) { - if (command.startsWith(name)) { - return True, info; - } - } - return False; - } - - /** - * Run the specified command. - * - * @return False if the command is "quit"; True otherwise - */ - Boolean runCommand(String[] command, Catalog catalog) { - Int parts = command.size; - if (parts == 0) { - return True; - } - - String head = command[0]; - switch (head) { - case "": - return True; - case "quit": - return False; - case "help": - printHelp(parts == 1 ? "" : command[1], catalog); - return True; - } - - if (CmdInfo info := findCommand(head, catalog)) { - try { - Method method = info.method; - Parameter[] params = method.params; - if (method.requiredParamCount <= parts-1 <= params.size) { - Tuple args = Tuple:(); - for (Int i : 1 ..< parts) { - String argStr = command[i]; - Parameter param = params[i-1]; - Type paramType = param.ParamType; - if (paramType.is(Type)) { - paramType.DataType argValue = new paramType.DataType(argStr); - args = args.add(argValue); - } else { - console.print($| Unsupported type "{paramType}" for parameter \ - |"{param}" - ); - return True; - } - } - - Tuple result = method.invoke(info.target, args); - - switch (result.size) { - case 0: - console.print(); - break; - case 1: - console.print(result[0]); - break; - default: - for (Int i : 0 ..< result.size) { - console.print($"[{i}]={result[i]}"); - } - break; - } - } else { - if (method.defaultParamCount == 0) { - console.print($" Required {params.size} arguments"); - - } else { - console.print($| Number of arguments should be between \ - |{method.requiredParamCount} and {params.size} - ); - } - } - } catch (Exception e) { - console.print($" Error: {e.message}"); - } - } else { - console.print($" Unknown command: {head.quoted()}"); - } - return True; - } + protected String messagePrefix; /** - * Print the instructions for the specified command or all the commands. + * The entry point. */ - void printHelp(String command, Catalog catalog) { - if (command == "") { - console.print($|{description == "" ? &this.actualClass.toString() : description} - | - |Commands are: - ); - Int maxName = catalog.keys.map(s -> s.size) - .reduce(0, (s1, s2) -> s1.maxOf(s2)); - for ((String name, CmdInfo info) : catalog) { - console.print($" {name.leftJustify(maxName+1)} {info.method.descr}"); - } - } else if (CmdInfo info := findCommand(command, catalog)) { - Command method = info.method; - console.print($| {method.descr == "" ? info.method.name : method.descr} - ); - - Parameter[] params = method.params; - Int paramCount = params.size; - if (paramCount > 0) { - console.print("Parameters:"); - - String[] names = params.map(p -> { - assert String name := p.hasName(); - return p.defaultValue() ? $"{name} (opt)" : name; - }).toArray(); - - Int maxName = names.map(n -> n.size) - .reduce(0, (s1, s2) -> s1.maxOf(s2)); - for (Int i : 0 ..< paramCount) { - Parameter param = params[i]; - console.print($| {names[i].leftJustify(maxName)} \ - |{param.is(Desc) ? param.text : ""} - ); - } - } - } else { - console.print($" Unknown command: {command.quoted()}"); - } - } - - void printResult(Tuple result) { - Int count = result.size; - switch (count) { - case 0: - break; - - case 1: - console.print($" {result[0]}"); - break; - - default: - for (Int i : 0 ..< count) { - console.print($" [i]={result[i]}"); - } - break; - } - } + void run(String[] args) = Runner.run(this, args); /** - * This method is meant to be used by the CLI classes to differentiate the output of the - * framework itself and of its users. + * This method is meant to be used by the CLI app classes to differentiate the output of + * the framework itself from the output by the user code. */ - void print(String s) { - console.print($"{messagePrefix} {s}"); - } + void print(Object o) = console.print($"{messagePrefix} {o}"); } + /** + * The mixin into a command method. + * + * @param cmd the command name + * @param descr the command description + */ mixin Command(String cmd = "", String descr = "") into Method; + /** + * The mixin into a command method parameter. + * + * @param text the parameter description + */ mixin Desc(String? text = Null) into Parameter; - - static Map buildCatalog(TerminalApp app) { - Map cmdInfos = new ListMap(); - - scanCommands(() -> app, &app.actualClass, cmdInfos); - scanClasses(app.classes, cmdInfos); - return cmdInfos; - } - - static void scanCommands(function Object() instance, Class clz, Map catalog) { - Type type = clz.PublicType; - - for (Method method : type.methods) { - if (method.is(Command)) { - String cmd = method.cmd == "" ? method.name : method.cmd; - if (catalog.contains(cmd)) { - throw new IllegalState($|A duplicate command "{cmd}" by the method "{method}" - ); - } - catalog.put(cmd, new CmdInfo(instance(), method)); - } - } - } - - static void scanClasses(Class[] classes, Map catalog) { - - static class Instance(Class clz) { - @Lazy Object get.calc() { - if (Object single := clz.isSingleton()) { - return single; - } - Type type = clz.PublicType; - if (function Object () constructor := type.defaultConstructor()) { - return constructor(); - } - throw new IllegalState($|default constructor is missing for "{clz}" - ); - } - } - - for (Class clz : classes) { - if (clz.annotatedBy(Abstract)) { - continue; - } - - Instance instance = new Instance(clz); - - scanCommands(() -> instance.get, clz, catalog); - } - } - - class CmdInfo(Object target, Command method) { - @Override - String toString() { - return method.toString(); - } - } } \ No newline at end of file diff --git a/lib_cli/src/main/x/cli/Runner.x b/lib_cli/src/main/x/cli/Runner.x new file mode 100644 index 0000000000..ffa5685383 --- /dev/null +++ b/lib_cli/src/main/x/cli/Runner.x @@ -0,0 +1,333 @@ +/** + * The Runner singleton. + */ +import Scanner.CmdInfo; + +static service Runner { + + typedef Map as Catalog; + + @Inject Console console; + + String description = ""; + String commandPrompt = "> "; + String messagePrefix = "# "; + + /** + * Initialization. + */ + void init(String description, String commandPrompt) { + this.description = description; + this.commandPrompt = commandPrompt; + } + + /** + * The entry point. + */ + void run(TerminalApp app, String[] args = [], Boolean suppressHeader = False) { + Catalog catalog = Scanner.buildCatalog(app); + + if (args.size == 0) { + if (description.empty) { + description = &app.actualClass.name; + } + + if (!suppressHeader) { + app.print(description); + } + + runLoop(catalog); + } else { + runOnce(catalog, args); + } + } + + /** + * Run a single command. + */ + void runOnce(Catalog catalog, String[] args) { + runCommand(args, catalog); + } + + /** + * Read commands from the console and run them. + */ + void runLoop(Catalog catalog) { + while (True) { + String command = console.readLine(commandPrompt); + + (Boolean ok, String[] args) = parseCommand(command); + if (ok) { + if (!runCommand(args, catalog)) { + return; + } + } else { + console.print($"Error in command: {args[0]}"); + } + } + } + + /** + * Parse the command string into a series of pieces. + * + * @return True iff the parsing encountered no issues + * @return an array of parsed arguments, otherwise a description of the parsing error + */ + (Boolean, String[]) parseCommand(String command) { + Int offset = 0; + Int length = command.size; + String[] args = new String[]; + StringBuffer buf = new StringBuffer(); + Boolean quoted = False; + Boolean escaped = False; + while (offset < length) { + switch (Char ch = command[offset++]) { + case ' ': + if (quoted) { + if (escaped) { + buf.add('\\'); // it was not an escape + escaped = False; + } + buf.add(' '); + } else if (buf.size > 0) { + args.add(buf.toString()); + buf = new StringBuffer(); + } + break; + + case '"': + if (escaped) { + buf.add('\"'); + escaped = False; + } else if (quoted) { + // this needs to be the end of the command or there needs to be a space + if (offset < length && command[offset] != ' ') { + return False, ["A space is required after a quoted argument"]; + } + + args.add(buf.toString()); + buf = new StringBuffer(); + quoted = False; + } else if (buf.size == 0) { + quoted = True; + } else { + return False, ["Unexpected quote character"]; + } + break; + + case '\\': + if (escaped) { + buf.add('\\'); + escaped = False; + } else if (quoted) { + escaped = True; + } else { + return False, ["Unexpected escape outside of quoted text"]; + } + break; + + case '0': + case 'b': + case 'd': + case 'e': + case 'f': + case 'n': + case 'r': + case 't': + case 'v': + case 'z': + case '\'': + if (escaped) { + buf.append(switch (ch) { + case '0': '\0'; + case 'b': '\b'; + case 'd': '\d'; + case 'e': '\e'; + case 'f': '\f'; + case 'n': '\n'; + case 'r': '\r'; + case 't': '\t'; + case 'v': '\v'; + case 'z': '\z'; + case '\'': '\''; + default: assert; + }); + escaped = False; + } else { + buf.add(ch); + } + break; + + default: + if (escaped) { + // it wasn't an escape + buf.add('\\'); + escaped = False; + } + buf.add(ch); + break; + } + } + + if (quoted) { + return False, ["Missing a closing quote"]; + } + + if (buf.size > 0) { + args.add(buf.toString()); + } + + return True, args; + } + + /** + * Find the specified command in the catalog. + */ + conditional CmdInfo findCommand(String command, Catalog catalog) { + for ((String name, CmdInfo info) : catalog) { + if (command.startsWith(name)) { + return True, info; + } + } + return False; + } + + /** + * Run the specified command. + * + * @return False if the command is "quit"; True otherwise + */ + Boolean runCommand(String[] command, Catalog catalog) { + Int parts = command.size; + if (parts == 0) { + return True; + } + + String head = command[0]; + switch (head) { + case "": + return True; + case "quit": + return False; + case "help": + printHelp(parts == 1 ? "" : command[1], catalog); + return True; + } + + if (CmdInfo info := findCommand(head, catalog)) { + try { + Method method = info.method; + Parameter[] params = method.params; + if (method.requiredParamCount <= parts-1 <= params.size) { + Tuple args = Tuple:(); + for (Int i : 1 ..< parts) { + String argStr = command[i]; + Parameter param = params[i-1]; + Type paramType = param.ParamType; + if (paramType.is(Type)) { + paramType.DataType argValue = new paramType.DataType(argStr); + args = args.add(argValue); + } else { + console.print($| Unsupported type "{paramType}" for parameter \ + |"{param}" + ); + return True; + } + } + + Tuple result = method.invoke(info.target, args); + + switch (result.size) { + case 0: + console.print(); + break; + case 1: + console.print(result[0]); + break; + default: + for (Int i : 0 ..< result.size) { + console.print($"[{i}]={result[i]}"); + } + break; + } + } else { + if (method.defaultParamCount == 0) { + console.print($" Required {params.size} arguments"); + + } else { + console.print($| Number of arguments should be between \ + |{method.requiredParamCount} and {params.size} + ); + } + } + } catch (Exception e) { + console.print($" Error: {e.message}"); + } + } else { + console.print($" Unknown command: {head.quoted()}"); + } + return True; + } + + /** + * Print the instructions for the specified command or all the commands. + */ + void printHelp(String command, Catalog catalog) { + if (command == "") { + console.print($|{description} + | + |Commands are: + ); + Int maxName = catalog.keys.map(s -> s.size) + .reduce(0, (s1, s2) -> s1.maxOf(s2)); + for ((String name, CmdInfo info) : catalog) { + console.print($" {name.leftJustify(maxName+1)} {info.method.descr}"); + } + } else if (CmdInfo info := findCommand(command, catalog)) { + Command method = info.method; + console.print($| {method.descr == "" ? info.method.name : method.descr} + ); + + Parameter[] params = method.params; + Int paramCount = params.size; + if (paramCount > 0) { + console.print("Parameters:"); + + String[] names = params.map(p -> { + assert String name := p.hasName(); + return p.defaultValue() ? $"{name} (opt)" : name; + }).toArray(); + + Int maxName = names.map(n -> n.size) + .reduce(0, (s1, s2) -> s1.maxOf(s2)); + for (Int i : 0 ..< paramCount) { + Parameter param = params[i]; + console.print($| {names[i].leftJustify(maxName)} \ + |{param.is(Desc) ? param.text : ""} + ); + } + } + } else { + console.print($" Unknown command: {command.quoted()}"); + } + } + + void printResult(Tuple result) { + Int count = result.size; + switch (count) { + case 0: + break; + + case 1: + console.print($" {result[0]}"); + break; + + default: + for (Int i : 0 ..< count) { + console.print($" [i]={result[i]}"); + } + break; + } + } +} + diff --git a/lib_cli/src/main/x/cli/Scanner.x b/lib_cli/src/main/x/cli/Scanner.x new file mode 100644 index 0000000000..873c0ec444 --- /dev/null +++ b/lib_cli/src/main/x/cli/Scanner.x @@ -0,0 +1,60 @@ +/** + * Helper methods that scan the TerminalApp for runnable commands. + */ +class Scanner { + static Map buildCatalog(TerminalApp app) { + Map cmdInfos = new ListMap(); + + scanCommands(() -> app, &app.actualClass, cmdInfos); + scanClasses(app.classes, cmdInfos); + return cmdInfos; + } + + static void scanCommands(function Object() instance, Class clz, Map catalog) { + Type type = clz.PublicType; + + for (Method method : type.methods) { + if (method.is(Command)) { + String cmd = method.cmd == "" ? method.name : method.cmd; + if (catalog.contains(cmd)) { + throw new IllegalState($|A duplicate command "{cmd}" by the method "{method}" + ); + } + catalog.put(cmd, new CmdInfo(instance(), method)); + } + } + } + + static void scanClasses(Class[] classes, Map catalog) { + static class Instance(Class clz) { + @Lazy Object get.calc() { + if (Object single := clz.isSingleton()) { + return single; + } + Type type = clz.PublicType; + if (function Object () constructor := type.defaultConstructor()) { + return constructor(); + } + throw new IllegalState($|default constructor is missing for "{clz}" + ); + } + } + + for (Class clz : classes) { + if (clz.annotatedBy(Abstract)) { + continue; + } + + Instance instance = new Instance(clz); + + scanCommands(() -> instance.get, clz, catalog); + } + } + + static class CmdInfo(Object target, Command method) { + @Override + String toString() { + return method.toString(); + } + } +} diff --git a/lib_web/src/main/x/web/Client.x b/lib_web/src/main/x/web/Client.x index 3c90391da6..59c4810cc7 100644 --- a/lib_web/src/main/x/web/Client.x +++ b/lib_web/src/main/x/web/Client.x @@ -94,7 +94,7 @@ interface Client { * * @return the resulting [Response] object */ - ResponseIn put(String | Uri uri, Object content, MediaType? mediaType=Null) { + ResponseIn put(String | Uri uri, Object content, MediaType? mediaType = Null) { RequestOut request = createRequest(PUT, uri.is(String) ? new Uri(uri) : uri, content, mediaType); return send^(request); } @@ -109,7 +109,7 @@ interface Client { * * @return the resulting [Response] object */ - ResponseIn post(String | Uri uri, Object content, MediaType? mediaType=Null) { + ResponseIn post(String | Uri uri, Object content, MediaType? mediaType = Null) { RequestOut request = createRequest(POST, uri.is(String) ? new Uri(uri) : uri, content, mediaType); return send^(request); } @@ -140,7 +140,8 @@ interface Client { * * @return a new Request object */ - RequestOut createRequest(HttpMethod method, Uri uri, Object? content=Null, MediaType? mediaType=Null) { + RequestOut createRequest(HttpMethod method, Uri uri, Object? content = Null, + MediaType? mediaType = Null) { SimpleRequest request = new SimpleRequest(this, method, uri); defaultHeaders.entries.forEach(entry -> request.add(entry)); diff --git a/lib_webcli/build.gradle.kts b/lib_webcli/build.gradle.kts new file mode 100644 index 0000000000..2a7f135cf2 --- /dev/null +++ b/lib_webcli/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("org.xtclang.build.xdk.versioning") + alias(libs.plugins.xtc) +} + +dependencies { + xdkJavaTools(libs.javatools) + xtcModule(libs.xdk.ecstasy) + xtcModule(libs.xdk.aggregate) + xtcModule(libs.xdk.collections) + xtcModule(libs.xdk.convert) + xtcModule(libs.xdk.crypto) + xtcModule(libs.xdk.cli) + xtcModule(libs.xdk.json) + xtcModule(libs.xdk.net) + xtcModule(libs.xdk.web) +} + diff --git a/lib_webcli/src/main/x/webcli.x b/lib_webcli/src/main/x/webcli.x new file mode 100644 index 0000000000..fe60ffb250 --- /dev/null +++ b/lib_webcli/src/main/x/webcli.x @@ -0,0 +1,173 @@ +/** + * Command Line Interface support for Web applications. + * + * To use the WebCLI library, the application code needs to do the following: + * - annotate the module as a `WebCLI`, for example: + * @TerminalApp("My REST client") + * module MyCurl { + * package webcli import webcli.xtclang.org; + * + * import webcli.*; + * ... + * } + * + * - annotate any methods to be executed as a command with the `Command` annotation, for example: + * + * @Command("title", "Get the app title") + * String org() = Gateway.sendRequest(GET, "main/title"); + * + */ +module webcli.xtclang.org { + + package cli import cli.xtclang.org; + package json import json.xtclang.org; + package web import web.xtclang.org; + + typedef cli.Command as Command; + typedef cli.Desc as Desc; + + @Inject Console console; + + mixin TerminalApp(String description = "", + String commandPrompt = "> ", + String messagePrefix = "# ", + ) + extends cli.TerminalApp(description, commandPrompt, messagePrefix) { + + import web.MediaType; + + /** + * The entry point. + */ + @Override + void run(String[] args) = Gateway.run(this, args, Password); + + /** + * Send a GET request. + */ + String get(String path) = Gateway.sendRequest(GET, path); + + /** + * Send a PUT request. + */ + String put(String path, Object content, MediaType? mediaType = Null) = + Gateway.sendRequest(PUT, path, content, mediaType); + + /** + * Send a POST request. + */ + String post(String path, Object content, MediaType? mediaType = Null) = + Gateway.sendRequest(GET, path, content, mediaType); + + /** + * Send a DELETE request. + */ + String delete(String path) = Gateway.sendRequest(DELETE, path); + + } + + static service Gateway { + import cli.Runner; + + import json.Doc; + import json.Parser; + import json.Printer; + + import web.Body; + import web.Client; + import web.Client.PasswordCallback; + import web.HttpClient; + import web.HttpStatus; + import web.HttpMethod; + import web.MediaType; + import web.ResponseIn; + import web.RequestOut; + import web.Uri; + + + private @Unassigned Client client; + private @Unassigned Uri uri; + private PasswordCallback? callback; + + enum Credentials {None, Password, Token} + void initCallback(Credentials cred = None) { + switch (cred) { + case Password: + callback = realm -> { + console.print($"Realm: {realm}"); + String name = console.readLine("User name: "); + String password = console.readLine("Password: ", suppressEcho = True); + return name, password; + }; + break; + + case Token: + TODO("Not yet implemented"); + } + } + + /** + * The entry point. + */ + void run(TerminalApp app, String[] args = [], Credentials cred = None) { + app.print(Runner.description); + initCallback(cred); + Gateway.resetClient(args.empty ? "" : args[0]); + Runner.run(app, suppressHeader = True); + } + + void resetClient(String defaultUri = "", Boolean forceTls = False) { + String uriString = console.readLine($"Enter the server uri [{defaultUri}]:"); + if (uriString.empty) { + uriString = defaultUri; + } + + Uri uri = new Uri(uriString); + if (String scheme ?= uri.scheme) { + assert !forceTls || scheme.toLowercase() == "https" + as "This tool can only operate over SSL"; + } else { + uri = new Uri($"{forceTls ? "https" : "http"}://{uriString}"); + } + + console.print($"Connecting to \"{uri}\""); + + this.client = new HttpClient(); + this.uri = uri; + } + + String send(RequestOut request) { + ResponseIn response = client.send(request, callback); + HttpStatus status = response.status; + if (status == OK) { + assert Body body ?= response.body; + Byte[] bytes = body.bytes; + if (bytes.size == 0) { + return ""; + } + + switch (body.mediaType) { + case Text: + case Html: + return bytes.unpackUtf8(); + case Json: + String jsonString = bytes.unpackUtf8(); + Doc doc = new Parser(jsonString.toReader()).parseDoc(); + return Printer.PRETTY.render(doc); + default: + return $""; + } + } else { + return response.toString(); + } + } + + String sendRequest(HttpMethod method, String path, Object? content = Null, + MediaType? mediaType = Null) { + if (!path.startsWith("/")) { + path = "/" + path; + } + return send(client.createRequest(method, uri.with(path=path), content, mediaType)); + } + } +} \ No newline at end of file diff --git a/manualTests/src/main/x/cliTest.x b/manualTests/src/main/x/cliTest.x new file mode 100644 index 0000000000..e168943fe7 --- /dev/null +++ b/manualTests/src/main/x/cliTest.x @@ -0,0 +1,34 @@ +@TerminalApp +module SimpleApp { + package cli import cli.xtclang.org; + + import cli.*; + + // ----- stateless API ------------------------------------------------------------------------- + + @Command("time", "Show current time") + Time showTime() { + @Inject Clock clock; + return clock.now; + } + + @Command("dirs", "Show home current and temp directories") + (Directory, Directory, Directory) showDirs() { + @Inject Directory curDir; + @Inject Directory homeDir; + @Inject Directory tmpDir; + return curDir, homeDir, tmpDir; + } + + // ----- stateful API -------------------------------------------------------------------------- + + service Stateful { + Int count; + + @Command("inc", "Increment the count") + Int addCount(@Desc("increment value") Int increment = 1) { + count += increment; + return count; + } + } +} \ No newline at end of file diff --git a/manualTests/src/main/x/webTests/HelloClient.x b/manualTests/src/main/x/webTests/HelloClient.x index d43e86b80a..bd4a1e2e9f 100644 --- a/manualTests/src/main/x/webTests/HelloClient.x +++ b/manualTests/src/main/x/webTests/HelloClient.x @@ -1,33 +1,31 @@ /** * The command example: * - * xec build/HelloClient.xtc http://localhost:8080 + * xec build/HelloClient.xtc http://localhost */ +@TerminalApp("Hello CLI", "hi> ") module HelloClient { - package msg import Messages; - package web import web.xtclang.org; + package webcli import webcli.xtclang.org; - @Inject Console console; + import webcli.*; - import msg.Greeting; - import web.HttpClient; + @Command("h", "Say hello") + String hello() = get("/hello"); - void run(String[] args=["http://localhost:8080"]) { - HttpClient client = new HttpClient(); + @Command("l", "Log in") + String login() = get("/l"); - String uri = args[0]; + @Command("s", "Secure access") + String secure() = get("/s"); - assert Greeting greeting := client.get(uri + "/hello").to(Greeting); - console.print(greeting); + @Command("c", "Increment count") + String count() = get("/c"); - assert String secure := client.get(uri + "/s").to(String); - console.print(secure); + @Command("e", "Echo") + String echo(@Desc("Value of `debug` to start debugger") String path = "") = get($"/e/{path}"); - for (Int i : 1 .. 4) { - assert Int count := client.get(uri + "/c").to(Int); - console.print(count); - } - - console.print(client.get(uri + "/l")); + service Users { + @Command("u", "Show user info") + String user() = HelloClient.get("/user"); } } \ No newline at end of file diff --git a/xdk/build.gradle.kts b/xdk/build.gradle.kts index b7591af2f5..f6b419289f 100644 --- a/xdk/build.gradle.kts +++ b/xdk/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { xtcModule(libs.xdk.oodb) xtcModule(libs.xdk.web) xtcModule(libs.xdk.webauth) + xtcModule(libs.xdk.webcli) xtcModule(libs.xdk.xenia) xtcModule(libs.javatools.bridge) @Suppress("UnstableApiUsage") diff --git a/xdk/settings.gradle.kts b/xdk/settings.gradle.kts index cda1b69403..04d61341e4 100644 --- a/xdk/settings.gradle.kts +++ b/xdk/settings.gradle.kts @@ -30,6 +30,7 @@ listOf( "lib_oodb", "lib_web", "lib_webauth", + "lib_webcli", "lib_xenia", "javatools_turtle", "javatools_launcher", From 176f2f57c1b6c00348d5f666add1e5fdd8f1a9d3 Mon Sep 17 00:00:00 2001 From: Gene Gleyzer Date: Tue, 26 Nov 2024 16:56:35 -0500 Subject: [PATCH 2/2] Introduce webcli module --- .../asm/constants/RecursiveTypeConstant.java | 6 ++++ .../src/main/java/org/xvm/asm/op/Var_S.java | 2 +- .../src/main/java/org/xvm/asm/op/Var_SN.java | 2 +- .../compiler/ast/AnnotatedTypeExpression.java | 19 ++++++++++-- .../org/xvm/compiler/ast/ListExpression.java | 2 +- lib_cli/src/main/x/cli/Runner.x | 12 ++++++-- lib_cli/src/main/x/cli/Scanner.x | 6 +++- lib_webcli/src/main/x/webcli.x | 29 +++++++++++-------- 8 files changed, 57 insertions(+), 21 deletions(-) diff --git a/javatools/src/main/java/org/xvm/asm/constants/RecursiveTypeConstant.java b/javatools/src/main/java/org/xvm/asm/constants/RecursiveTypeConstant.java index 25c3d8ac1c..ec4ac241bc 100644 --- a/javatools/src/main/java/org/xvm/asm/constants/RecursiveTypeConstant.java +++ b/javatools/src/main/java/org/xvm/asm/constants/RecursiveTypeConstant.java @@ -92,6 +92,12 @@ public boolean isImmutable() return getReferredToType().isImmutable(); } + @Override + public boolean isService() + { + return false; + } + @Override public boolean containsGenericParam(String sName) { diff --git a/javatools/src/main/java/org/xvm/asm/op/Var_S.java b/javatools/src/main/java/org/xvm/asm/op/Var_S.java index 1da861a3c2..d112d87e70 100644 --- a/javatools/src/main/java/org/xvm/asm/op/Var_S.java +++ b/javatools/src/main/java/org/xvm/asm/op/Var_S.java @@ -109,7 +109,7 @@ protected int complete(Frame frame, int iPC, ObjectHandle[] ahArg) boolean fImmutable = true; for (ObjectHandle hValue : ahArg) { - if (hValue.isMutable()) + if (!hValue.isPassThrough()) { fImmutable = false; break; diff --git a/javatools/src/main/java/org/xvm/asm/op/Var_SN.java b/javatools/src/main/java/org/xvm/asm/op/Var_SN.java index 3cdbf2dd73..5dd89f7af9 100644 --- a/javatools/src/main/java/org/xvm/asm/op/Var_SN.java +++ b/javatools/src/main/java/org/xvm/asm/op/Var_SN.java @@ -118,7 +118,7 @@ protected int complete(Frame frame, int iPC, ObjectHandle[] ahArg) boolean fImmutable = true; for (ObjectHandle hValue : ahArg) { - if (hValue.isMutable()) + if (!hValue.isPassThrough()) { fImmutable = false; break; diff --git a/javatools/src/main/java/org/xvm/compiler/ast/AnnotatedTypeExpression.java b/javatools/src/main/java/org/xvm/compiler/ast/AnnotatedTypeExpression.java index 2901d6cf8b..e7c4ce5dca 100644 --- a/javatools/src/main/java/org/xvm/compiler/ast/AnnotatedTypeExpression.java +++ b/javatools/src/main/java/org/xvm/compiler/ast/AnnotatedTypeExpression.java @@ -16,6 +16,7 @@ import org.xvm.asm.constants.AnnotatedTypeConstant; import org.xvm.asm.constants.IdentityConstant; import org.xvm.asm.constants.TypeConstant; +import org.xvm.asm.constants.TypedefConstant; import org.xvm.asm.constants.UnresolvedNameConstant; import org.xvm.asm.constants.UnresolvedTypeConstant; @@ -351,8 +352,22 @@ protected TypeConstant calculateType(Context ctx, ErrorListener errs) if (fResolved) { - IdentityConstant idAnno = (IdentityConstant) constAnno; - ClassStructure clzAnno = (ClassStructure) idAnno.getComponent(); + IdentityConstant idAnno = (IdentityConstant) constAnno; + if (idAnno instanceof TypedefConstant idTypedef) + { + TypeConstant typeRef = idTypedef.getReferredToType(); + if (typeRef.isSingleUnderlyingClass(false)) + { + idAnno = typeRef.getSingleUnderlyingClass(false); + } + else + { + log(errs, Severity.ERROR, Constants.VE_ANNOTATION_NOT_MIXIN, idTypedef); + return null; + } + } + + ClassStructure clzAnno = (ClassStructure) idAnno.getComponent(); if (clzAnno.getFormat() != Component.Format.MIXIN) { log(errs, Severity.ERROR, Constants.VE_ANNOTATION_NOT_MIXIN, clzAnno.getName()); diff --git a/javatools/src/main/java/org/xvm/compiler/ast/ListExpression.java b/javatools/src/main/java/org/xvm/compiler/ast/ListExpression.java index d063b09ff1..af2e5046cd 100644 --- a/javatools/src/main/java/org/xvm/compiler/ast/ListExpression.java +++ b/javatools/src/main/java/org/xvm/compiler/ast/ListExpression.java @@ -256,7 +256,7 @@ protected Expression validate(Context ctx, TypeConstant typeRequired, ErrorListe } typeActual = pool.ensureParameterizedTypeConstant(typeActual, typeElement); - if (typeElement.isImmutable()) + if (typeElement.isImmutable() || typeElement.isService()) { typeActual = pool.ensureImmutableTypeConstant(typeActual); } diff --git a/lib_cli/src/main/x/cli/Runner.x b/lib_cli/src/main/x/cli/Runner.x index ffa5685383..124eda31a5 100644 --- a/lib_cli/src/main/x/cli/Runner.x +++ b/lib_cli/src/main/x/cli/Runner.x @@ -23,16 +23,22 @@ static service Runner { /** * The entry point. + * + * @param app the app that contains classes with commands. + * @param args (optional) the arguments passed by the user via the command line + * @param suppressWelcome (optional) pass `True` to avoid printing the "welcome" message + * @param extras (optional) extra objects that contain executable commands */ - void run(TerminalApp app, String[] args = [], Boolean suppressHeader = False) { - Catalog catalog = Scanner.buildCatalog(app); + void run(TerminalApp app, String[] args = [], Boolean suppressWelcome = False, + Object[] extras = []) { + Catalog catalog = Scanner.buildCatalog(app, extras); if (args.size == 0) { if (description.empty) { description = &app.actualClass.name; } - if (!suppressHeader) { + if (!suppressWelcome) { app.print(description); } diff --git a/lib_cli/src/main/x/cli/Scanner.x b/lib_cli/src/main/x/cli/Scanner.x index 873c0ec444..693c7fcd83 100644 --- a/lib_cli/src/main/x/cli/Scanner.x +++ b/lib_cli/src/main/x/cli/Scanner.x @@ -2,10 +2,14 @@ * Helper methods that scan the TerminalApp for runnable commands. */ class Scanner { - static Map buildCatalog(TerminalApp app) { + static Map buildCatalog(TerminalApp app, Object[] extras = []) { Map cmdInfos = new ListMap(); scanCommands(() -> app, &app.actualClass, cmdInfos); + if (!extras.empty) { + extras.forEach(extra -> + scanCommands(() -> extra, &extra.actualClass, cmdInfos)); + } scanClasses(app.classes, cmdInfos); return cmdInfos; } diff --git a/lib_webcli/src/main/x/webcli.x b/lib_webcli/src/main/x/webcli.x index fe60ffb250..3403bbe747 100644 --- a/lib_webcli/src/main/x/webcli.x +++ b/lib_webcli/src/main/x/webcli.x @@ -40,7 +40,7 @@ module webcli.xtclang.org { * The entry point. */ @Override - void run(String[] args) = Gateway.run(this, args, Password); + void run(String[] args) = Gateway.run(this, args, auth=Password); /** * Send a GET request. @@ -89,14 +89,14 @@ module webcli.xtclang.org { private @Unassigned Uri uri; private PasswordCallback? callback; - enum Credentials {None, Password, Token} - void initCallback(Credentials cred = None) { - switch (cred) { + enum AuthMethod {None, Password, Token} + void initCallback(AuthMethod auth) { + switch (auth) { case Password: callback = realm -> { console.print($"Realm: {realm}"); String name = console.readLine("User name: "); - String password = console.readLine("Password: ", suppressEcho = True); + String password = console.readLine("Password: ", suppressEcho=True); return name, password; }; break; @@ -108,18 +108,23 @@ module webcli.xtclang.org { /** * The entry point. + + * @param app the app that contains classes with commands. + * @param args (optional) the arguments passed by the user via the command line + * @param auth (optional) the authentication method */ - void run(TerminalApp app, String[] args = [], Credentials cred = None) { + void run(TerminalApp app, String[] args = [], AuthMethod auth = None) { app.print(Runner.description); - initCallback(cred); + initCallback(auth); Gateway.resetClient(args.empty ? "" : args[0]); - Runner.run(app, suppressHeader = True); + Runner.run(app, suppressWelcome=True, extras=[this]); } - void resetClient(String defaultUri = "", Boolean forceTls = False) { - String uriString = console.readLine($"Enter the server uri [{defaultUri}]:"); - if (uriString.empty) { - uriString = defaultUri; + @Command("reset", "Reset the server URI") + void resetClient(@Desc("Server URI") String uriString = "", + @Desc("Specify 'true' to enforce 'https' connection") Boolean forceTls = False) { + while (uriString.empty) { + uriString = console.readLine($"Enter the server URI: "); } Uri uri = new Uri(uriString);