UPdate.
This commit is contained in:
@@ -0,0 +1,645 @@
|
||||
{# retoor <retoor@molodetz.nl> #}
|
||||
{% extends 'page.html' %}
|
||||
|
||||
{% set page_title = "Building a CLI Tool" %}
|
||||
{% set breadcrumb = [{"url": "tutorials/index.html", "title": "Tutorials"}, {"title": "CLI Tool"}] %}
|
||||
{% set prev_page = {"url": "tutorials/template-rendering.html", "title": "Template Rendering"} %}
|
||||
{% set next_page = {"url": "tutorials/pexpect.html", "title": "Process Automation"} %}
|
||||
|
||||
{% block article %}
|
||||
<h1>Building a CLI Tool</h1>
|
||||
|
||||
<p>In this tutorial, you will build a complete command-line application. You will learn to parse arguments, handle user input, run subprocesses, and create a professional CLI experience.</p>
|
||||
|
||||
<h2>What You Will Learn</h2>
|
||||
|
||||
<ul>
|
||||
<li>Parsing command-line arguments</li>
|
||||
<li>Handling user input interactively</li>
|
||||
<li>Running external commands</li>
|
||||
<li>Working with environment variables</li>
|
||||
<li>Signal handling for graceful shutdown</li>
|
||||
<li>Creating subcommands</li>
|
||||
</ul>
|
||||
|
||||
<h2>Step 1: Accessing Command-Line Arguments</h2>
|
||||
|
||||
<p>Create a file called <code>cli_tool.wren</code>:</p>
|
||||
|
||||
<pre><code>import "os" for Process
|
||||
|
||||
var args = Process.arguments
|
||||
|
||||
System.print("Script: %(Process.arguments[0])")
|
||||
System.print("Arguments: %(args.count - 1)")
|
||||
|
||||
for (i in 1...args.count) {
|
||||
System.print(" arg[%(i)]: %(args[i])")
|
||||
}</code></pre>
|
||||
|
||||
<p>Run it:</p>
|
||||
<pre><code>$ wren_cli cli_tool.wren hello world --verbose
|
||||
Script: cli_tool.wren
|
||||
Arguments: 3
|
||||
arg[1]: hello
|
||||
arg[2]: world
|
||||
arg[3]: --verbose</code></pre>
|
||||
|
||||
<h2>Step 2: Building an Argument Parser</h2>
|
||||
|
||||
<pre><code>import "os" for Process
|
||||
|
||||
class ArgParser {
|
||||
construct new() {
|
||||
_commands = {}
|
||||
_options = {}
|
||||
_flags = {}
|
||||
_positional = []
|
||||
}
|
||||
|
||||
parse(args) {
|
||||
var i = 1
|
||||
while (i < args.count) {
|
||||
var arg = args[i]
|
||||
|
||||
if (arg.startsWith("--")) {
|
||||
var key = arg[2..-1]
|
||||
if (key.contains("=")) {
|
||||
var parts = key.split("=")
|
||||
_options[parts[0]] = parts[1]
|
||||
} else if (i + 1 < args.count && !args[i + 1].startsWith("-")) {
|
||||
_options[key] = args[i + 1]
|
||||
i = i + 1
|
||||
} else {
|
||||
_flags[key] = true
|
||||
}
|
||||
} else if (arg.startsWith("-")) {
|
||||
for (char in arg[1..-1]) {
|
||||
_flags[char] = true
|
||||
}
|
||||
} else {
|
||||
_positional.add(arg)
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
option(name) { _options[name] }
|
||||
flag(name) { _flags.containsKey(name) && _flags[name] }
|
||||
positional { _positional }
|
||||
hasOption(name) { _options.containsKey(name) }
|
||||
}
|
||||
|
||||
var parser = ArgParser.new()
|
||||
parser.parse(Process.arguments)
|
||||
|
||||
System.print("Positional: %(parser.positional)")
|
||||
System.print("Verbose: %(parser.flag("verbose") || parser.flag("v"))")
|
||||
System.print("Output: %(parser.option("output") || "stdout")")</code></pre>
|
||||
|
||||
<p>Run it:</p>
|
||||
<pre><code>$ wren_cli cli_tool.wren file1.txt file2.txt -v --output=result.txt
|
||||
Positional: [file1.txt, file2.txt]
|
||||
Verbose: true
|
||||
Output: result.txt</code></pre>
|
||||
|
||||
<h2>Step 3: Creating Subcommands</h2>
|
||||
|
||||
<pre><code>import "os" for Process
|
||||
import "io" for File, Directory, Stdin
|
||||
|
||||
class CLI {
|
||||
construct new(name, version) {
|
||||
_name = name
|
||||
_version = version
|
||||
_commands = {}
|
||||
}
|
||||
|
||||
command(name, description, handler) {
|
||||
_commands[name] = {"description": description, "handler": handler}
|
||||
}
|
||||
|
||||
run(args) {
|
||||
if (args.count < 2) {
|
||||
showHelp()
|
||||
return
|
||||
}
|
||||
|
||||
var cmd = args[1]
|
||||
|
||||
if (cmd == "--help" || cmd == "-h") {
|
||||
showHelp()
|
||||
} else if (cmd == "--version" || cmd == "-V") {
|
||||
System.print("%(_name) %(_version)")
|
||||
} else if (_commands.containsKey(cmd)) {
|
||||
_commands[cmd]["handler"].call(args[2..-1])
|
||||
} else {
|
||||
System.print("Unknown command: %(cmd)")
|
||||
System.print("Run '%(_name) --help' for usage.")
|
||||
}
|
||||
}
|
||||
|
||||
showHelp() {
|
||||
System.print("%(_name) %(_version)")
|
||||
System.print("")
|
||||
System.print("Usage: %(_name) <command> [options]")
|
||||
System.print("")
|
||||
System.print("Commands:")
|
||||
for (name in _commands.keys) {
|
||||
var desc = _commands[name]["description"]
|
||||
System.print(" %(name.padRight(15)) %(desc)")
|
||||
}
|
||||
System.print("")
|
||||
System.print("Options:")
|
||||
System.print(" --help, -h Show this help")
|
||||
System.print(" --version, -V Show version")
|
||||
}
|
||||
}
|
||||
|
||||
var cli = CLI.new("mytool", "1.0.0")
|
||||
|
||||
cli.command("list", "List files in directory", Fn.new { |args|
|
||||
var path = args.count > 0 ? args[0] : "."
|
||||
var files = Directory.list(path)
|
||||
for (file in files) {
|
||||
System.print(file)
|
||||
}
|
||||
})
|
||||
|
||||
cli.command("read", "Read and display a file", Fn.new { |args|
|
||||
if (args.count == 0) {
|
||||
System.print("Usage: mytool read <file>")
|
||||
return
|
||||
}
|
||||
System.print(File.read(args[0]))
|
||||
})
|
||||
|
||||
cli.command("info", "Show file information", Fn.new { |args|
|
||||
if (args.count == 0) {
|
||||
System.print("Usage: mytool info <file>")
|
||||
return
|
||||
}
|
||||
var path = args[0]
|
||||
if (File.exists(path)) {
|
||||
System.print("File: %(path)")
|
||||
System.print("Size: %(File.size(path)) bytes")
|
||||
} else {
|
||||
System.print("File not found: %(path)")
|
||||
}
|
||||
})
|
||||
|
||||
cli.run(Process.arguments)</code></pre>
|
||||
|
||||
<h2>Step 4: Interactive Input</h2>
|
||||
|
||||
<pre><code>import "io" for Stdin
|
||||
|
||||
class Prompt {
|
||||
static ask(question) {
|
||||
System.write("%(question) ")
|
||||
return Stdin.readLine()
|
||||
}
|
||||
|
||||
static confirm(question) {
|
||||
System.write("%(question) (y/n) ")
|
||||
var answer = Stdin.readLine().lower
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
static choose(question, options) {
|
||||
System.print(question)
|
||||
var i = 1
|
||||
for (opt in options) {
|
||||
System.print(" %(i). %(opt)")
|
||||
i = i + 1
|
||||
}
|
||||
System.write("Choice: ")
|
||||
var choice = Num.fromString(Stdin.readLine())
|
||||
if (choice && choice >= 1 && choice <= options.count) {
|
||||
return options[choice - 1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
static password(question) {
|
||||
System.write("%(question) ")
|
||||
return Stdin.readLine()
|
||||
}
|
||||
}
|
||||
|
||||
var name = Prompt.ask("What is your name?")
|
||||
System.print("Hello, %(name)!")
|
||||
|
||||
if (Prompt.confirm("Do you want to continue?")) {
|
||||
var color = Prompt.choose("Pick a color:", ["Red", "Green", "Blue"])
|
||||
System.print("You chose: %(color)")
|
||||
}</code></pre>
|
||||
|
||||
<h2>Step 5: Running External Commands</h2>
|
||||
|
||||
<pre><code>import "subprocess" for Subprocess
|
||||
import "os" for Process
|
||||
|
||||
class Shell {
|
||||
static run(command) {
|
||||
var result = Subprocess.exec(command)
|
||||
return {
|
||||
"output": result.stdout,
|
||||
"error": result.stderr,
|
||||
"code": result.exitCode
|
||||
}
|
||||
}
|
||||
|
||||
static runOrFail(command) {
|
||||
var result = run(command)
|
||||
if (result["code"] != 0) {
|
||||
Fiber.abort("Command failed: %(command)\n%(result["error"])")
|
||||
}
|
||||
return result["output"]
|
||||
}
|
||||
|
||||
static which(program) {
|
||||
var result = run("which %(program)")
|
||||
return result["code"] == 0 ? result["output"].trim() : null
|
||||
}
|
||||
}
|
||||
|
||||
System.print("Git status:")
|
||||
var result = Shell.run("git status --porcelain")
|
||||
if (result["code"] == 0) {
|
||||
if (result["output"].count == 0) {
|
||||
System.print(" Working directory clean")
|
||||
} else {
|
||||
System.print(result["output"])
|
||||
}
|
||||
} else {
|
||||
System.print(" Not a git repository")
|
||||
}
|
||||
|
||||
var gitPath = Shell.which("git")
|
||||
if (gitPath) {
|
||||
System.print("Git found at: %(gitPath)")
|
||||
}</code></pre>
|
||||
|
||||
<h2>Step 6: Environment Variables</h2>
|
||||
|
||||
<pre><code>import "env" for Env
|
||||
import "os" for Process
|
||||
|
||||
class Config {
|
||||
static get(key, defaultValue) {
|
||||
return Env.get(key) || defaultValue
|
||||
}
|
||||
|
||||
static require(key) {
|
||||
var value = Env.get(key)
|
||||
if (!value) {
|
||||
Fiber.abort("Required environment variable not set: %(key)")
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
var home = Config.get("HOME", "/tmp")
|
||||
var editor = Config.get("EDITOR", "vim")
|
||||
var debug = Config.get("DEBUG", "false") == "true"
|
||||
|
||||
System.print("Home: %(home)")
|
||||
System.print("Editor: %(editor)")
|
||||
System.print("Debug mode: %(debug)")
|
||||
|
||||
System.print("\nAll environment variables:")
|
||||
for (key in Env.keys()) {
|
||||
System.print(" %(key)=%(Env.get(key))")
|
||||
}</code></pre>
|
||||
|
||||
<h2>Step 7: Signal Handling</h2>
|
||||
|
||||
<pre><code>import "signal" for Signal
|
||||
import "timer" for Timer
|
||||
|
||||
var running = true
|
||||
|
||||
Signal.handle("SIGINT", Fn.new {
|
||||
System.print("\nReceived SIGINT, shutting down...")
|
||||
running = false
|
||||
})
|
||||
|
||||
Signal.handle("SIGTERM", Fn.new {
|
||||
System.print("\nReceived SIGTERM, shutting down...")
|
||||
running = false
|
||||
})
|
||||
|
||||
System.print("Running... Press Ctrl+C to stop")
|
||||
|
||||
var counter = 0
|
||||
while (running) {
|
||||
counter = counter + 1
|
||||
System.write("\rProcessed %(counter) items...")
|
||||
Timer.sleep(100)
|
||||
}
|
||||
|
||||
System.print("\nGraceful shutdown complete. Processed %(counter) items.")</code></pre>
|
||||
|
||||
<h2>Step 8: Complete CLI Application</h2>
|
||||
|
||||
<p>Let's build a complete file utility tool:</p>
|
||||
|
||||
<pre><code>import "os" for Process
|
||||
import "io" for File, Directory, Stdin
|
||||
import "json" for Json
|
||||
import "crypto" for Crypto
|
||||
import "subprocess" for Subprocess
|
||||
import "signal" for Signal
|
||||
import "env" for Env
|
||||
|
||||
class FileUtil {
|
||||
construct new() {
|
||||
_verbose = false
|
||||
}
|
||||
|
||||
verbose=(value) { _verbose = value }
|
||||
|
||||
log(message) {
|
||||
if (_verbose) System.print("[INFO] %(message)")
|
||||
}
|
||||
|
||||
list(path, recursive) {
|
||||
var files = Directory.list(path)
|
||||
var results = []
|
||||
|
||||
for (file in files) {
|
||||
var fullPath = "%(path)/%(file)"
|
||||
results.add(fullPath)
|
||||
|
||||
if (recursive && Directory.exists(fullPath)) {
|
||||
var subFiles = list(fullPath, true)
|
||||
for (sub in subFiles) {
|
||||
results.add(sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
copy(source, dest) {
|
||||
log("Copying %(source) to %(dest)")
|
||||
File.copy(source, dest)
|
||||
}
|
||||
|
||||
hash(path, algorithm) {
|
||||
var content = File.read(path)
|
||||
if (algorithm == "md5") {
|
||||
return Crypto.md5(content)
|
||||
} else if (algorithm == "sha256") {
|
||||
return Crypto.sha256(content)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
search(path, pattern) {
|
||||
var files = list(path, true)
|
||||
var matches = []
|
||||
|
||||
for (file in files) {
|
||||
if (file.contains(pattern)) {
|
||||
matches.add(file)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
stats(path) {
|
||||
var files = list(path, true)
|
||||
var totalSize = 0
|
||||
var fileCount = 0
|
||||
var dirCount = 0
|
||||
|
||||
for (file in files) {
|
||||
if (Directory.exists(file)) {
|
||||
dirCount = dirCount + 1
|
||||
} else if (File.exists(file)) {
|
||||
fileCount = fileCount + 1
|
||||
totalSize = totalSize + File.size(file)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"files": fileCount,
|
||||
"directories": dirCount,
|
||||
"totalSize": totalSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CLI {
|
||||
construct new() {
|
||||
_name = "fileutil"
|
||||
_version = "1.0.0"
|
||||
_util = FileUtil.new()
|
||||
}
|
||||
|
||||
run(args) {
|
||||
if (args.count < 2) {
|
||||
showHelp()
|
||||
return
|
||||
}
|
||||
|
||||
var verbose = args.contains("-v") || args.contains("--verbose")
|
||||
_util.verbose = verbose
|
||||
|
||||
var cmd = args[1]
|
||||
|
||||
if (cmd == "list" || cmd == "ls") {
|
||||
cmdList(args)
|
||||
} else if (cmd == "copy" || cmd == "cp") {
|
||||
cmdCopy(args)
|
||||
} else if (cmd == "hash") {
|
||||
cmdHash(args)
|
||||
} else if (cmd == "search" || cmd == "find") {
|
||||
cmdSearch(args)
|
||||
} else if (cmd == "stats") {
|
||||
cmdStats(args)
|
||||
} else if (cmd == "--help" || cmd == "-h") {
|
||||
showHelp()
|
||||
} else if (cmd == "--version" || cmd == "-V") {
|
||||
System.print("%(_name) %(_version)")
|
||||
} else {
|
||||
System.print("Unknown command: %(cmd)")
|
||||
System.print("Run '%(_name) --help' for usage.")
|
||||
}
|
||||
}
|
||||
|
||||
cmdList(args) {
|
||||
var path = "."
|
||||
var recursive = args.contains("-r") || args.contains("--recursive")
|
||||
|
||||
for (arg in args[2..-1]) {
|
||||
if (!arg.startsWith("-")) {
|
||||
path = arg
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var files = _util.list(path, recursive)
|
||||
for (file in files) {
|
||||
if (File.exists(file)) {
|
||||
var size = File.size(file)
|
||||
System.print("%(formatSize(size).padLeft(10)) %(file)")
|
||||
} else {
|
||||
System.print(" DIR %(file)/")
|
||||
}
|
||||
}
|
||||
|
||||
System.print("\n%(files.count) items")
|
||||
}
|
||||
|
||||
cmdCopy(args) {
|
||||
if (args.count < 4) {
|
||||
System.print("Usage: %(_name) copy <source> <dest>")
|
||||
return
|
||||
}
|
||||
|
||||
var source = args[2]
|
||||
var dest = args[3]
|
||||
|
||||
if (!File.exists(source)) {
|
||||
System.print("Source file not found: %(source)")
|
||||
return
|
||||
}
|
||||
|
||||
_util.copy(source, dest)
|
||||
System.print("Copied %(source) to %(dest)")
|
||||
}
|
||||
|
||||
cmdHash(args) {
|
||||
if (args.count < 3) {
|
||||
System.print("Usage: %(_name) hash <file> [--algorithm=sha256|md5]")
|
||||
return
|
||||
}
|
||||
|
||||
var file = args[2]
|
||||
var algorithm = "sha256"
|
||||
|
||||
for (arg in args) {
|
||||
if (arg.startsWith("--algorithm=")) {
|
||||
algorithm = arg[12..-1]
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.exists(file)) {
|
||||
System.print("File not found: %(file)")
|
||||
return
|
||||
}
|
||||
|
||||
var hash = _util.hash(file, algorithm)
|
||||
System.print("%(algorithm.upper): %(hash)")
|
||||
}
|
||||
|
||||
cmdSearch(args) {
|
||||
if (args.count < 3) {
|
||||
System.print("Usage: %(_name) search <pattern> [path]")
|
||||
return
|
||||
}
|
||||
|
||||
var pattern = args[2]
|
||||
var path = args.count > 3 ? args[3] : "."
|
||||
|
||||
var matches = _util.search(path, pattern)
|
||||
|
||||
if (matches.count == 0) {
|
||||
System.print("No matches found for '%(pattern)'")
|
||||
} else {
|
||||
System.print("Found %(matches.count) matches:")
|
||||
for (match in matches) {
|
||||
System.print(" %(match)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmdStats(args) {
|
||||
var path = args.count > 2 ? args[2] : "."
|
||||
var stats = _util.stats(path)
|
||||
|
||||
System.print("Statistics for %(path):")
|
||||
System.print(" Files: %(stats["files"])")
|
||||
System.print(" Directories: %(stats["directories"])")
|
||||
System.print(" Total size: %(formatSize(stats["totalSize"]))")
|
||||
}
|
||||
|
||||
formatSize(bytes) {
|
||||
if (bytes < 1024) return "%(bytes) B"
|
||||
if (bytes < 1024 * 1024) return "%(((bytes / 1024) * 10).round / 10) KB"
|
||||
if (bytes < 1024 * 1024 * 1024) return "%(((bytes / 1024 / 1024) * 10).round / 10) MB"
|
||||
return "%(((bytes / 1024 / 1024 / 1024) * 10).round / 10) GB"
|
||||
}
|
||||
|
||||
showHelp() {
|
||||
System.print("%(_name) %(_version) - File utility tool")
|
||||
System.print("")
|
||||
System.print("Usage: %(_name) <command> [options] [arguments]")
|
||||
System.print("")
|
||||
System.print("Commands:")
|
||||
System.print(" list, ls List files in directory")
|
||||
System.print(" -r, --recursive Include subdirectories")
|
||||
System.print("")
|
||||
System.print(" copy, cp Copy a file")
|
||||
System.print(" <source> <dest>")
|
||||
System.print("")
|
||||
System.print(" hash Calculate file hash")
|
||||
System.print(" --algorithm=sha256|md5")
|
||||
System.print("")
|
||||
System.print(" search Search for files by name")
|
||||
System.print(" <pattern> [path]")
|
||||
System.print("")
|
||||
System.print(" stats Show directory statistics")
|
||||
System.print(" [path]")
|
||||
System.print("")
|
||||
System.print("Global Options:")
|
||||
System.print(" -v, --verbose Verbose output")
|
||||
System.print(" -h, --help Show this help")
|
||||
System.print(" -V, --version Show version")
|
||||
}
|
||||
}
|
||||
|
||||
var cli = CLI.new()
|
||||
cli.run(Process.arguments)</code></pre>
|
||||
|
||||
<h2>Running the Tool</h2>
|
||||
|
||||
<pre><code>$ wren_cli fileutil.wren --help
|
||||
fileutil 1.0.0 - File utility tool
|
||||
|
||||
Usage: fileutil <command> [options] [arguments]
|
||||
|
||||
Commands:
|
||||
list, ls List files in directory
|
||||
...
|
||||
|
||||
$ wren_cli fileutil.wren list -r src/
|
||||
1.2 KB src/main.wren
|
||||
3.4 KB src/utils.wren
|
||||
DIR src/lib/
|
||||
2.1 KB src/lib/helper.wren
|
||||
|
||||
4 items
|
||||
|
||||
$ wren_cli fileutil.wren hash README.md
|
||||
SHA256: a3f2e8b9c4d5...</code></pre>
|
||||
|
||||
<div class="admonition tip">
|
||||
<div class="admonition-title">Tip</div>
|
||||
<p>Create a shell alias or wrapper script to run your Wren CLI tools more conveniently: <code>alias fileutil='wren_cli /path/to/fileutil.wren'</code></p>
|
||||
</div>
|
||||
|
||||
<h2>Next Steps</h2>
|
||||
|
||||
<ul>
|
||||
<li>Add configuration file support with <a href="../api/json.html">JSON</a></li>
|
||||
<li>Implement colored output for better UX</li>
|
||||
<li>Add tab completion hints</li>
|
||||
<li>See the <a href="../api/os.html">OS</a> and <a href="../api/subprocess.html">Subprocess</a> API references</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,628 @@
|
||||
{# retoor <retoor@molodetz.nl> #}
|
||||
{% extends 'page.html' %}
|
||||
|
||||
{% set page_title = "Database Application" %}
|
||||
{% set breadcrumb = [{"url": "tutorials/index.html", "title": "Tutorials"}, {"title": "Database App"}] %}
|
||||
{% set prev_page = {"url": "tutorials/websocket-chat.html", "title": "WebSocket Chat"} %}
|
||||
{% set next_page = {"url": "tutorials/template-rendering.html", "title": "Template Rendering"} %}
|
||||
|
||||
{% block article %}
|
||||
<h1>Database Application</h1>
|
||||
|
||||
<p>In this tutorial, you will build a complete task management application using SQLite for persistent storage. You will learn to create tables, perform CRUD operations, and build a command-line interface.</p>
|
||||
|
||||
<h2>What You Will Learn</h2>
|
||||
|
||||
<ul>
|
||||
<li>Creating and managing SQLite databases</li>
|
||||
<li>Designing database schemas</li>
|
||||
<li>Performing CRUD operations (Create, Read, Update, Delete)</li>
|
||||
<li>Using parameterized queries to prevent SQL injection</li>
|
||||
<li>Building a command-line interface</li>
|
||||
</ul>
|
||||
|
||||
<h2>Step 1: Setting Up the Database</h2>
|
||||
|
||||
<p>Create a file called <code>task_app.wren</code>:</p>
|
||||
|
||||
<pre><code>import "sqlite" for Sqlite
|
||||
|
||||
var db = Sqlite.open("tasks.db")
|
||||
|
||||
db.execute("
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
priority INTEGER DEFAULT 1,
|
||||
completed INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
due_date DATE
|
||||
)
|
||||
")
|
||||
|
||||
System.print("Database initialized!")
|
||||
db.close()</code></pre>
|
||||
|
||||
<h2>Step 2: Creating a Task Model</h2>
|
||||
|
||||
<p>Let's create a class to manage task operations:</p>
|
||||
|
||||
<pre><code>import "sqlite" for Sqlite
|
||||
import "datetime" for DateTime
|
||||
|
||||
class TaskManager {
|
||||
construct new(dbPath) {
|
||||
_db = Sqlite.open(dbPath)
|
||||
initDatabase()
|
||||
}
|
||||
|
||||
initDatabase() {
|
||||
_db.execute("
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
priority INTEGER DEFAULT 1,
|
||||
completed INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
due_date DATE
|
||||
)
|
||||
")
|
||||
}
|
||||
|
||||
create(title, description, priority, dueDate) {
|
||||
_db.execute(
|
||||
"INSERT INTO tasks (title, description, priority, due_date) VALUES (?, ?, ?, ?)",
|
||||
[title, description, priority, dueDate]
|
||||
)
|
||||
return _db.lastInsertId
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return _db.execute("SELECT * FROM tasks ORDER BY priority DESC, due_date ASC")
|
||||
}
|
||||
|
||||
findById(id) {
|
||||
var results = _db.execute("SELECT * FROM tasks WHERE id = ?", [id])
|
||||
return results.count > 0 ? results[0] : null
|
||||
}
|
||||
|
||||
findPending() {
|
||||
return _db.execute("SELECT * FROM tasks WHERE completed = 0 ORDER BY priority DESC")
|
||||
}
|
||||
|
||||
findCompleted() {
|
||||
return _db.execute("SELECT * FROM tasks WHERE completed = 1 ORDER BY created_at DESC")
|
||||
}
|
||||
|
||||
update(id, title, description, priority, dueDate) {
|
||||
_db.execute(
|
||||
"UPDATE tasks SET title = ?, description = ?, priority = ?, due_date = ? WHERE id = ?",
|
||||
[title, description, priority, dueDate, id]
|
||||
)
|
||||
}
|
||||
|
||||
complete(id) {
|
||||
_db.execute("UPDATE tasks SET completed = 1 WHERE id = ?", [id])
|
||||
}
|
||||
|
||||
uncomplete(id) {
|
||||
_db.execute("UPDATE tasks SET completed = 0 WHERE id = ?", [id])
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
_db.execute("DELETE FROM tasks WHERE id = ?", [id])
|
||||
}
|
||||
|
||||
search(query) {
|
||||
return _db.execute(
|
||||
"SELECT * FROM tasks WHERE title LIKE ? OR description LIKE ?",
|
||||
["\%%(query)\%", "\%%(query)\%"]
|
||||
)
|
||||
}
|
||||
|
||||
close() {
|
||||
_db.close()
|
||||
}
|
||||
}
|
||||
|
||||
var tasks = TaskManager.new("tasks.db")
|
||||
|
||||
var id = tasks.create("Learn Wren-CLI", "Complete the database tutorial", 3, "2024-12-31")
|
||||
System.print("Created task with ID: %(id)")
|
||||
|
||||
tasks.close()</code></pre>
|
||||
|
||||
<h2>Step 3: Building the CLI Interface</h2>
|
||||
|
||||
<p>Now let's create an interactive command-line interface:</p>
|
||||
|
||||
<pre><code>import "sqlite" for Sqlite
|
||||
import "io" for Stdin
|
||||
|
||||
class TaskManager {
|
||||
construct new(dbPath) {
|
||||
_db = Sqlite.open(dbPath)
|
||||
initDatabase()
|
||||
}
|
||||
|
||||
initDatabase() {
|
||||
_db.execute("
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
priority INTEGER DEFAULT 1,
|
||||
completed INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
")
|
||||
}
|
||||
|
||||
create(title, description, priority) {
|
||||
_db.execute(
|
||||
"INSERT INTO tasks (title, description, priority) VALUES (?, ?, ?)",
|
||||
[title, description, priority]
|
||||
)
|
||||
return _db.lastInsertId
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return _db.execute("SELECT * FROM tasks ORDER BY completed ASC, priority DESC")
|
||||
}
|
||||
|
||||
complete(id) {
|
||||
_db.execute("UPDATE tasks SET completed = 1 WHERE id = ?", [id])
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
_db.execute("DELETE FROM tasks WHERE id = ?", [id])
|
||||
}
|
||||
|
||||
close() {
|
||||
_db.close()
|
||||
}
|
||||
}
|
||||
|
||||
class TaskApp {
|
||||
construct new() {
|
||||
_tasks = TaskManager.new("tasks.db")
|
||||
_running = true
|
||||
}
|
||||
|
||||
run() {
|
||||
System.print("=== Task Manager ===\n")
|
||||
showHelp()
|
||||
|
||||
while (_running) {
|
||||
System.write("\n> ")
|
||||
var input = Stdin.readLine()
|
||||
|
||||
if (input == null) break
|
||||
|
||||
var parts = input.split(" ")
|
||||
var command = parts.count > 0 ? parts[0] : ""
|
||||
|
||||
if (command == "list") {
|
||||
listTasks()
|
||||
} else if (command == "add") {
|
||||
addTask()
|
||||
} else if (command == "done") {
|
||||
if (parts.count > 1) {
|
||||
completeTask(Num.fromString(parts[1]))
|
||||
} else {
|
||||
System.print("Usage: done <id>")
|
||||
}
|
||||
} else if (command == "delete") {
|
||||
if (parts.count > 1) {
|
||||
deleteTask(Num.fromString(parts[1]))
|
||||
} else {
|
||||
System.print("Usage: delete <id>")
|
||||
}
|
||||
} else if (command == "help") {
|
||||
showHelp()
|
||||
} else if (command == "quit" || command == "exit") {
|
||||
_running = false
|
||||
} else if (command.count > 0) {
|
||||
System.print("Unknown command: %(command)")
|
||||
}
|
||||
}
|
||||
|
||||
_tasks.close()
|
||||
System.print("Goodbye!")
|
||||
}
|
||||
|
||||
showHelp() {
|
||||
System.print("Commands:")
|
||||
System.print(" list - Show all tasks")
|
||||
System.print(" add - Add a new task")
|
||||
System.print(" done <id> - Mark task as complete")
|
||||
System.print(" delete <id> - Delete a task")
|
||||
System.print(" help - Show this help")
|
||||
System.print(" quit - Exit the application")
|
||||
}
|
||||
|
||||
listTasks() {
|
||||
var tasks = _tasks.findAll()
|
||||
|
||||
if (tasks.count == 0) {
|
||||
System.print("No tasks found.")
|
||||
return
|
||||
}
|
||||
|
||||
System.print("\nID | Pri | Status | Title")
|
||||
System.print("----|-----|--------|------")
|
||||
|
||||
for (task in tasks) {
|
||||
var status = task["completed"] == 1 ? "[X]" : "[ ]"
|
||||
var priority = ["Low", "Med", "High"][task["priority"] - 1]
|
||||
System.print("%(task["id"]) | %(priority) | %(status) | %(task["title"])")
|
||||
}
|
||||
}
|
||||
|
||||
addTask() {
|
||||
System.write("Title: ")
|
||||
var title = Stdin.readLine()
|
||||
|
||||
System.write("Description (optional): ")
|
||||
var description = Stdin.readLine()
|
||||
|
||||
System.write("Priority (1=Low, 2=Medium, 3=High): ")
|
||||
var priorityStr = Stdin.readLine()
|
||||
var priority = Num.fromString(priorityStr) || 1
|
||||
|
||||
if (priority < 1) priority = 1
|
||||
if (priority > 3) priority = 3
|
||||
|
||||
var id = _tasks.create(title, description, priority)
|
||||
System.print("Task created with ID: %(id)")
|
||||
}
|
||||
|
||||
completeTask(id) {
|
||||
_tasks.complete(id)
|
||||
System.print("Task %(id) marked as complete.")
|
||||
}
|
||||
|
||||
deleteTask(id) {
|
||||
_tasks.delete(id)
|
||||
System.print("Task %(id) deleted.")
|
||||
}
|
||||
}
|
||||
|
||||
var app = TaskApp.new()
|
||||
app.run()</code></pre>
|
||||
|
||||
<h2>Step 4: Adding Advanced Features</h2>
|
||||
|
||||
<p>Let's enhance our application with categories and due dates:</p>
|
||||
|
||||
<pre><code>import "sqlite" for Sqlite
|
||||
import "io" for Stdin
|
||||
import "datetime" for DateTime
|
||||
|
||||
class TaskManager {
|
||||
construct new(dbPath) {
|
||||
_db = Sqlite.open(dbPath)
|
||||
initDatabase()
|
||||
}
|
||||
|
||||
initDatabase() {
|
||||
_db.execute("
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT DEFAULT '#808080'
|
||||
)
|
||||
")
|
||||
|
||||
_db.execute("
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category_id INTEGER,
|
||||
priority INTEGER DEFAULT 1,
|
||||
completed INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
due_date DATE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id)
|
||||
)
|
||||
")
|
||||
|
||||
var categories = _db.execute("SELECT COUNT(*) as count FROM categories")
|
||||
if (categories[0]["count"] == 0) {
|
||||
_db.execute("INSERT INTO categories (name, color) VALUES ('Work', '#FF6B6B')")
|
||||
_db.execute("INSERT INTO categories (name, color) VALUES ('Personal', '#4ECDC4')")
|
||||
_db.execute("INSERT INTO categories (name, color) VALUES ('Shopping', '#45B7D1')")
|
||||
}
|
||||
}
|
||||
|
||||
createTask(title, description, categoryId, priority, dueDate) {
|
||||
_db.execute(
|
||||
"INSERT INTO tasks (title, description, category_id, priority, due_date) VALUES (?, ?, ?, ?, ?)",
|
||||
[title, description, categoryId, priority, dueDate]
|
||||
)
|
||||
return _db.lastInsertId
|
||||
}
|
||||
|
||||
findAllTasks() {
|
||||
return _db.execute("
|
||||
SELECT t.*, c.name as category_name
|
||||
FROM tasks t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
ORDER BY t.completed ASC, t.priority DESC, t.due_date ASC
|
||||
")
|
||||
}
|
||||
|
||||
findTasksByCategory(categoryId) {
|
||||
return _db.execute("
|
||||
SELECT t.*, c.name as category_name
|
||||
FROM tasks t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
WHERE t.category_id = ?
|
||||
ORDER BY t.completed ASC, t.priority DESC
|
||||
", [categoryId])
|
||||
}
|
||||
|
||||
findOverdueTasks() {
|
||||
return _db.execute("
|
||||
SELECT t.*, c.name as category_name
|
||||
FROM tasks t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
WHERE t.completed = 0 AND t.due_date < DATE('now')
|
||||
ORDER BY t.due_date ASC
|
||||
")
|
||||
}
|
||||
|
||||
findDueToday() {
|
||||
return _db.execute("
|
||||
SELECT t.*, c.name as category_name
|
||||
FROM tasks t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
WHERE t.completed = 0 AND t.due_date = DATE('now')
|
||||
ORDER BY t.priority DESC
|
||||
")
|
||||
}
|
||||
|
||||
findCategories() {
|
||||
return _db.execute("SELECT * FROM categories ORDER BY name")
|
||||
}
|
||||
|
||||
createCategory(name, color) {
|
||||
_db.execute("INSERT INTO categories (name, color) VALUES (?, ?)", [name, color])
|
||||
return _db.lastInsertId
|
||||
}
|
||||
|
||||
completeTask(id) {
|
||||
_db.execute("UPDATE tasks SET completed = 1 WHERE id = ?", [id])
|
||||
}
|
||||
|
||||
deleteTask(id) {
|
||||
_db.execute("DELETE FROM tasks WHERE id = ?", [id])
|
||||
}
|
||||
|
||||
getStats() {
|
||||
var total = _db.execute("SELECT COUNT(*) as count FROM tasks")[0]["count"]
|
||||
var completed = _db.execute("SELECT COUNT(*) as count FROM tasks WHERE completed = 1")[0]["count"]
|
||||
var pending = total - completed
|
||||
var overdue = _db.execute("SELECT COUNT(*) as count FROM tasks WHERE completed = 0 AND due_date < DATE('now')")[0]["count"]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"pending": pending,
|
||||
"overdue": overdue
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
_db.close()
|
||||
}
|
||||
}
|
||||
|
||||
class TaskApp {
|
||||
construct new() {
|
||||
_tasks = TaskManager.new("tasks.db")
|
||||
_running = true
|
||||
}
|
||||
|
||||
run() {
|
||||
System.print("=== Task Manager v2 ===\n")
|
||||
|
||||
while (_running) {
|
||||
showMenu()
|
||||
System.write("\nChoice: ")
|
||||
var choice = Stdin.readLine()
|
||||
|
||||
if (choice == "1") {
|
||||
listTasks()
|
||||
} else if (choice == "2") {
|
||||
addTask()
|
||||
} else if (choice == "3") {
|
||||
completeTask()
|
||||
} else if (choice == "4") {
|
||||
showOverdue()
|
||||
} else if (choice == "5") {
|
||||
showStats()
|
||||
} else if (choice == "6") {
|
||||
manageCategories()
|
||||
} else if (choice == "0") {
|
||||
_running = false
|
||||
}
|
||||
}
|
||||
|
||||
_tasks.close()
|
||||
System.print("\nGoodbye!")
|
||||
}
|
||||
|
||||
showMenu() {
|
||||
System.print("\n--- Main Menu ---")
|
||||
System.print("1. List all tasks")
|
||||
System.print("2. Add new task")
|
||||
System.print("3. Complete task")
|
||||
System.print("4. Show overdue")
|
||||
System.print("5. Statistics")
|
||||
System.print("6. Categories")
|
||||
System.print("0. Exit")
|
||||
}
|
||||
|
||||
listTasks() {
|
||||
var tasks = _tasks.findAllTasks()
|
||||
|
||||
if (tasks.count == 0) {
|
||||
System.print("\nNo tasks found.")
|
||||
return
|
||||
}
|
||||
|
||||
System.print("\n--- All Tasks ---")
|
||||
for (task in tasks) {
|
||||
var status = task["completed"] == 1 ? "[X]" : "[ ]"
|
||||
var category = task["category_name"] || "None"
|
||||
var due = task["due_date"] || "No due date"
|
||||
|
||||
System.print("%(task["id"]). %(status) %(task["title"])")
|
||||
System.print(" Category: %(category) | Due: %(due)")
|
||||
}
|
||||
}
|
||||
|
||||
addTask() {
|
||||
System.print("\n--- Add Task ---")
|
||||
|
||||
System.write("Title: ")
|
||||
var title = Stdin.readLine()
|
||||
if (title.count == 0) return
|
||||
|
||||
System.write("Description: ")
|
||||
var description = Stdin.readLine()
|
||||
|
||||
var categories = _tasks.findCategories()
|
||||
System.print("\nCategories:")
|
||||
for (cat in categories) {
|
||||
System.print(" %(cat["id"]). %(cat["name"])")
|
||||
}
|
||||
System.write("Category ID (or 0 for none): ")
|
||||
var categoryId = Num.fromString(Stdin.readLine())
|
||||
if (categoryId == 0) categoryId = null
|
||||
|
||||
System.write("Priority (1-3): ")
|
||||
var priority = Num.fromString(Stdin.readLine()) || 1
|
||||
|
||||
System.write("Due date (YYYY-MM-DD or empty): ")
|
||||
var dueDate = Stdin.readLine()
|
||||
if (dueDate.count == 0) dueDate = null
|
||||
|
||||
var id = _tasks.createTask(title, description, categoryId, priority, dueDate)
|
||||
System.print("\nTask created with ID: %(id)")
|
||||
}
|
||||
|
||||
completeTask() {
|
||||
System.write("\nTask ID to complete: ")
|
||||
var id = Num.fromString(Stdin.readLine())
|
||||
if (id) {
|
||||
_tasks.completeTask(id)
|
||||
System.print("Task %(id) marked as complete!")
|
||||
}
|
||||
}
|
||||
|
||||
showOverdue() {
|
||||
var tasks = _tasks.findOverdueTasks()
|
||||
|
||||
if (tasks.count == 0) {
|
||||
System.print("\nNo overdue tasks!")
|
||||
return
|
||||
}
|
||||
|
||||
System.print("\n--- Overdue Tasks ---")
|
||||
for (task in tasks) {
|
||||
System.print("%(task["id"]). %(task["title"]) (Due: %(task["due_date"]))")
|
||||
}
|
||||
}
|
||||
|
||||
showStats() {
|
||||
var stats = _tasks.getStats()
|
||||
|
||||
System.print("\n--- Statistics ---")
|
||||
System.print("Total tasks: %(stats["total"])")
|
||||
System.print("Completed: %(stats["completed"])")
|
||||
System.print("Pending: %(stats["pending"])")
|
||||
System.print("Overdue: %(stats["overdue"])")
|
||||
|
||||
if (stats["total"] > 0) {
|
||||
var pct = (stats["completed"] / stats["total"] * 100).round
|
||||
System.print("Completion rate: %(pct)\%")
|
||||
}
|
||||
}
|
||||
|
||||
manageCategories() {
|
||||
var categories = _tasks.findCategories()
|
||||
|
||||
System.print("\n--- Categories ---")
|
||||
for (cat in categories) {
|
||||
System.print("%(cat["id"]). %(cat["name"])")
|
||||
}
|
||||
|
||||
System.write("\nAdd new category? (y/n): ")
|
||||
if (Stdin.readLine().lower == "y") {
|
||||
System.write("Category name: ")
|
||||
var name = Stdin.readLine()
|
||||
if (name.count > 0) {
|
||||
_tasks.createCategory(name, "#808080")
|
||||
System.print("Category created!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var app = TaskApp.new()
|
||||
app.run()</code></pre>
|
||||
|
||||
<h2>Running the Application</h2>
|
||||
|
||||
<pre><code>$ wren_cli task_app.wren
|
||||
=== Task Manager v2 ===
|
||||
|
||||
--- Main Menu ---
|
||||
1. List all tasks
|
||||
2. Add new task
|
||||
3. Complete task
|
||||
4. Show overdue
|
||||
5. Statistics
|
||||
6. Categories
|
||||
0. Exit
|
||||
|
||||
Choice: 2
|
||||
|
||||
--- Add Task ---
|
||||
Title: Complete database tutorial
|
||||
Description: Learn SQLite with Wren-CLI
|
||||
|
||||
Categories:
|
||||
1. Work
|
||||
2. Personal
|
||||
3. Shopping
|
||||
Category ID (or 0 for none): 1
|
||||
Priority (1-3): 3
|
||||
Due date (YYYY-MM-DD or empty): 2024-12-31
|
||||
|
||||
Task created with ID: 1</code></pre>
|
||||
|
||||
<div class="admonition tip">
|
||||
<div class="admonition-title">Tip</div>
|
||||
<p>Always use parameterized queries with <code>?</code> placeholders instead of string concatenation. This prevents SQL injection vulnerabilities.</p>
|
||||
</div>
|
||||
|
||||
<div class="admonition note">
|
||||
<div class="admonition-title">Note</div>
|
||||
<p>Use <code>":memory:"</code> as the database path for testing without creating a file on disk.</p>
|
||||
</div>
|
||||
|
||||
<h2>Next Steps</h2>
|
||||
|
||||
<ul>
|
||||
<li>Add data export to JSON or CSV</li>
|
||||
<li>Implement task reminders with <a href="../api/timer.html">Timer</a></li>
|
||||
<li>Generate HTML reports with <a href="template-rendering.html">Jinja templates</a></li>
|
||||
<li>See the <a href="../api/sqlite.html">SQLite API reference</a></li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,519 @@
|
||||
{# retoor <retoor@molodetz.nl> #}
|
||||
{% extends 'page.html' %}
|
||||
|
||||
{% set page_title = "Building an HTTP Client" %}
|
||||
{% set breadcrumb = [{"url": "tutorials/index.html", "title": "Tutorials"}, {"title": "HTTP Client"}] %}
|
||||
{% set prev_page = {"url": "tutorials/index.html", "title": "Tutorials"} %}
|
||||
{% set next_page = {"url": "tutorials/websocket-chat.html", "title": "WebSocket Chat"} %}
|
||||
|
||||
{% block article %}
|
||||
<h1>Building an HTTP Client</h1>
|
||||
|
||||
<p>In this tutorial, you will learn how to build a REST API client using Wren-CLI's <code>http</code> and <code>json</code> modules. By the end, you will have a reusable API client class that can interact with any JSON REST API.</p>
|
||||
|
||||
<h2>What You Will Learn</h2>
|
||||
|
||||
<ul>
|
||||
<li>Making GET, POST, PUT, and DELETE requests</li>
|
||||
<li>Parsing JSON responses</li>
|
||||
<li>Handling HTTP errors</li>
|
||||
<li>Working with request headers</li>
|
||||
<li>Building a reusable API client class</li>
|
||||
</ul>
|
||||
|
||||
<h2>Step 1: Your First HTTP Request</h2>
|
||||
|
||||
<p>Let's start with a simple GET request. Create a file called <code>http_client.wren</code>:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
|
||||
var response = Http.get("https://jsonplaceholder.typicode.com/posts/1")
|
||||
|
||||
System.print("Status: %(response.statusCode)")
|
||||
System.print("Body: %(response.body)")</code></pre>
|
||||
|
||||
<p>Run it:</p>
|
||||
|
||||
<pre><code>$ wren_cli http_client.wren
|
||||
Status: 200
|
||||
Body: {
|
||||
"userId": 1,
|
||||
"id": 1,
|
||||
"title": "sunt aut facere...",
|
||||
"body": "quia et suscipit..."
|
||||
}</code></pre>
|
||||
|
||||
<h2>Step 2: Parsing JSON Responses</h2>
|
||||
|
||||
<p>Raw JSON strings are not very useful. Let's parse them into Wren objects:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
import "json" for Json
|
||||
|
||||
var response = Http.get("https://jsonplaceholder.typicode.com/posts/1")
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
var post = Json.parse(response.body)
|
||||
System.print("Title: %(post["title"])")
|
||||
System.print("User ID: %(post["userId"])")
|
||||
} else {
|
||||
System.print("Error: %(response.statusCode)")
|
||||
}</code></pre>
|
||||
|
||||
<p>The <code>HttpResponse</code> class also provides a convenient <code>json</code> property:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
|
||||
var response = Http.get("https://jsonplaceholder.typicode.com/posts/1")
|
||||
var post = response.json
|
||||
|
||||
System.print("Title: %(post["title"])")</code></pre>
|
||||
|
||||
<h2>Step 3: Fetching Lists</h2>
|
||||
|
||||
<p>APIs often return arrays of objects. Let's fetch multiple posts:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
|
||||
var response = Http.get("https://jsonplaceholder.typicode.com/posts")
|
||||
var posts = response.json
|
||||
|
||||
System.print("Found %(posts.count) posts\n")
|
||||
|
||||
for (i in 0...5) {
|
||||
var post = posts[i]
|
||||
System.print("%(post["id"]). %(post["title"])")
|
||||
}</code></pre>
|
||||
|
||||
<h2>Step 4: Making POST Requests</h2>
|
||||
|
||||
<p>To create resources, use POST with a JSON body:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
import "json" for Json
|
||||
|
||||
var newPost = {
|
||||
"title": "My New Post",
|
||||
"body": "This is the content of my post.",
|
||||
"userId": 1
|
||||
}
|
||||
|
||||
var response = Http.post(
|
||||
"https://jsonplaceholder.typicode.com/posts",
|
||||
Json.stringify(newPost),
|
||||
{"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
System.print("Status: %(response.statusCode)")
|
||||
System.print("Created post with ID: %(response.json["id"])")</code></pre>
|
||||
|
||||
<h2>Step 5: PUT and DELETE Requests</h2>
|
||||
|
||||
<p>Update and delete resources with PUT and DELETE:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
import "json" for Json
|
||||
|
||||
var updatedPost = {
|
||||
"id": 1,
|
||||
"title": "Updated Title",
|
||||
"body": "Updated content.",
|
||||
"userId": 1
|
||||
}
|
||||
|
||||
var putResponse = Http.put(
|
||||
"https://jsonplaceholder.typicode.com/posts/1",
|
||||
Json.stringify(updatedPost),
|
||||
{"Content-Type": "application/json"}
|
||||
)
|
||||
System.print("PUT Status: %(putResponse.statusCode)")
|
||||
|
||||
var deleteResponse = Http.delete("https://jsonplaceholder.typicode.com/posts/1")
|
||||
System.print("DELETE Status: %(deleteResponse.statusCode)")</code></pre>
|
||||
|
||||
<h2>Step 6: Building an API Client Class</h2>
|
||||
|
||||
<p>Let's create a reusable API client that encapsulates all these patterns:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
import "json" for Json
|
||||
|
||||
class ApiClient {
|
||||
construct new(baseUrl) {
|
||||
_baseUrl = baseUrl
|
||||
_headers = {"Content-Type": "application/json"}
|
||||
}
|
||||
|
||||
headers { _headers }
|
||||
headers=(value) { _headers = value }
|
||||
|
||||
setHeader(name, value) {
|
||||
_headers[name] = value
|
||||
}
|
||||
|
||||
get(path) {
|
||||
var response = Http.get(_baseUrl + path, _headers)
|
||||
return handleResponse(response)
|
||||
}
|
||||
|
||||
post(path, data) {
|
||||
var body = Json.stringify(data)
|
||||
var response = Http.post(_baseUrl + path, body, _headers)
|
||||
return handleResponse(response)
|
||||
}
|
||||
|
||||
put(path, data) {
|
||||
var body = Json.stringify(data)
|
||||
var response = Http.put(_baseUrl + path, body, _headers)
|
||||
return handleResponse(response)
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
var response = Http.delete(_baseUrl + path, _headers)
|
||||
return handleResponse(response)
|
||||
}
|
||||
|
||||
handleResponse(response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
if (response.body.count > 0) {
|
||||
return response.json
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
Fiber.abort("API Error: %(response.statusCode)")
|
||||
}
|
||||
}
|
||||
|
||||
var api = ApiClient.new("https://jsonplaceholder.typicode.com")
|
||||
|
||||
var posts = api.get("/posts")
|
||||
System.print("Fetched %(posts.count) posts")
|
||||
|
||||
var newPost = api.post("/posts", {
|
||||
"title": "Hello from Wren",
|
||||
"body": "Created with ApiClient",
|
||||
"userId": 1
|
||||
})
|
||||
System.print("Created post: %(newPost["id"])")</code></pre>
|
||||
|
||||
<h2>Step 7: Adding Authentication</h2>
|
||||
|
||||
<p>Many APIs require authentication. Add support for API keys and bearer tokens:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
import "json" for Json
|
||||
import "base64" for Base64
|
||||
|
||||
class ApiClient {
|
||||
construct new(baseUrl) {
|
||||
_baseUrl = baseUrl
|
||||
_headers = {"Content-Type": "application/json"}
|
||||
}
|
||||
|
||||
setApiKey(key) {
|
||||
_headers["X-API-Key"] = key
|
||||
}
|
||||
|
||||
setBearerToken(token) {
|
||||
_headers["Authorization"] = "Bearer %(token)"
|
||||
}
|
||||
|
||||
setBasicAuth(username, password) {
|
||||
var credentials = Base64.encode("%(username):%(password)")
|
||||
_headers["Authorization"] = "Basic %(credentials)"
|
||||
}
|
||||
|
||||
get(path) {
|
||||
var response = Http.get(_baseUrl + path, _headers)
|
||||
return handleResponse(response)
|
||||
}
|
||||
|
||||
handleResponse(response) {
|
||||
if (response.statusCode == 401) {
|
||||
Fiber.abort("Authentication failed")
|
||||
}
|
||||
if (response.statusCode == 403) {
|
||||
Fiber.abort("Access forbidden")
|
||||
}
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return response.json
|
||||
}
|
||||
Fiber.abort("API Error: %(response.statusCode)")
|
||||
}
|
||||
}
|
||||
|
||||
var api = ApiClient.new("https://api.example.com")
|
||||
api.setBearerToken("your-auth-token")
|
||||
|
||||
var data = api.get("/protected/resource")</code></pre>
|
||||
|
||||
<h2>Step 8: Error Handling</h2>
|
||||
|
||||
<p>Proper error handling makes your client robust:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
import "json" for Json
|
||||
|
||||
class ApiError {
|
||||
construct new(statusCode, message) {
|
||||
_statusCode = statusCode
|
||||
_message = message
|
||||
}
|
||||
|
||||
statusCode { _statusCode }
|
||||
message { _message }
|
||||
|
||||
toString { "ApiError %(statusCode): %(message)" }
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
construct new(baseUrl) {
|
||||
_baseUrl = baseUrl
|
||||
_headers = {"Content-Type": "application/json"}
|
||||
}
|
||||
|
||||
get(path) {
|
||||
var fiber = Fiber.new {
|
||||
return Http.get(_baseUrl + path, _headers)
|
||||
}
|
||||
|
||||
var response = fiber.try()
|
||||
if (fiber.error) {
|
||||
return {"error": ApiError.new(0, fiber.error)}
|
||||
}
|
||||
|
||||
return handleResponse(response)
|
||||
}
|
||||
|
||||
handleResponse(response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return {"data": response.json}
|
||||
}
|
||||
|
||||
var message = "Unknown error"
|
||||
var fiber = Fiber.new { response.json["message"] }
|
||||
var apiMessage = fiber.try()
|
||||
if (!fiber.error && apiMessage) {
|
||||
message = apiMessage
|
||||
}
|
||||
|
||||
return {"error": ApiError.new(response.statusCode, message)}
|
||||
}
|
||||
}
|
||||
|
||||
var api = ApiClient.new("https://jsonplaceholder.typicode.com")
|
||||
var result = api.get("/posts/1")
|
||||
|
||||
if (result["error"]) {
|
||||
System.print("Error: %(result["error"])")
|
||||
} else {
|
||||
System.print("Success: %(result["data"]["title"])")
|
||||
}</code></pre>
|
||||
|
||||
<h2>Step 9: Concurrent HTTP Requests</h2>
|
||||
|
||||
<p>When you need to fetch data from multiple endpoints, sequential requests can be slow. Use <code>async</code> and <code>await</code> to run requests concurrently:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
import "scheduler" for Scheduler, Future
|
||||
import "json" for Json
|
||||
|
||||
var fetchJson = async { |url|
|
||||
var response = Http.get(url)
|
||||
return response.json
|
||||
}
|
||||
|
||||
// SEQUENTIAL: Each request waits for the previous one
|
||||
System.print("--- Sequential requests ---")
|
||||
var user = await fetchJson("https://jsonplaceholder.typicode.com/users/1")
|
||||
var posts = await fetchJson("https://jsonplaceholder.typicode.com/posts?userId=1")
|
||||
var todos = await fetchJson("https://jsonplaceholder.typicode.com/todos?userId=1")
|
||||
|
||||
System.print("User: %(user["name"])")
|
||||
System.print("Posts: %(posts.count)")
|
||||
System.print("Todos: %(todos.count)")</code></pre>
|
||||
|
||||
<p>For concurrent execution, use <code>.call()</code> to start requests without waiting, then <code>await</code> the results:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
import "scheduler" for Scheduler, Future
|
||||
import "json" for Json
|
||||
|
||||
var fetchJson = async { |url|
|
||||
var response = Http.get(url)
|
||||
return response.json
|
||||
}
|
||||
|
||||
// CONCURRENT: All requests start at once
|
||||
System.print("--- Concurrent requests ---")
|
||||
var f1 = fetchJson.call("https://jsonplaceholder.typicode.com/users/1")
|
||||
var f2 = fetchJson.call("https://jsonplaceholder.typicode.com/posts?userId=1")
|
||||
var f3 = fetchJson.call("https://jsonplaceholder.typicode.com/todos?userId=1")
|
||||
|
||||
// Wait for results (requests run in parallel)
|
||||
var user = await f1
|
||||
var posts = await f2
|
||||
var todos = await f3
|
||||
|
||||
System.print("User: %(user["name"])")
|
||||
System.print("Posts: %(posts.count)")
|
||||
System.print("Todos: %(todos.count)")</code></pre>
|
||||
|
||||
<h3>Batch Fetching with Concurrent Requests</h3>
|
||||
|
||||
<p>For fetching multiple URLs dynamically, create futures in a loop:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
import "scheduler" for Scheduler, Future
|
||||
import "json" for Json
|
||||
|
||||
var fetchJson = async { |url|
|
||||
var response = Http.get(url)
|
||||
return response.json
|
||||
}
|
||||
|
||||
var userIds = [1, 2, 3, 4, 5]
|
||||
|
||||
// Start all requests concurrently
|
||||
var futures = []
|
||||
for (id in userIds) {
|
||||
futures.add(fetchJson.call("https://jsonplaceholder.typicode.com/users/%(id)"))
|
||||
}
|
||||
|
||||
// Collect results
|
||||
var users = []
|
||||
for (f in futures) {
|
||||
users.add(await f)
|
||||
}
|
||||
|
||||
System.print("Fetched %(users.count) users:")
|
||||
for (user in users) {
|
||||
System.print(" - %(user["name"]) (%(user["email"]))")
|
||||
}</code></pre>
|
||||
|
||||
<div class="admonition tip">
|
||||
<div class="admonition-title">Direct Calling vs .call()</div>
|
||||
<p>Use <code>await fn(args)</code> for sequential execution (waits immediately). Use <code>fn.call(args)</code> to start without waiting, enabling concurrent execution.</p>
|
||||
</div>
|
||||
|
||||
<h2>Complete Example</h2>
|
||||
|
||||
<p>Here is a complete, production-ready API client:</p>
|
||||
|
||||
<pre><code>import "http" for Http
|
||||
import "json" for Json
|
||||
import "base64" for Base64
|
||||
|
||||
class ApiClient {
|
||||
construct new(baseUrl) {
|
||||
_baseUrl = baseUrl
|
||||
_headers = {"Content-Type": "application/json"}
|
||||
_timeout = 30000
|
||||
}
|
||||
|
||||
baseUrl { _baseUrl }
|
||||
headers { _headers }
|
||||
|
||||
setHeader(name, value) { _headers[name] = value }
|
||||
removeHeader(name) { _headers.remove(name) }
|
||||
|
||||
setBearerToken(token) {
|
||||
_headers["Authorization"] = "Bearer %(token)"
|
||||
}
|
||||
|
||||
setBasicAuth(username, password) {
|
||||
var credentials = Base64.encode("%(username):%(password)")
|
||||
_headers["Authorization"] = "Basic %(credentials)"
|
||||
}
|
||||
|
||||
get(path) { request("GET", path, null) }
|
||||
post(path, data) { request("POST", path, data) }
|
||||
put(path, data) { request("PUT", path, data) }
|
||||
patch(path, data) { request("PATCH", path, data) }
|
||||
delete(path) { request("DELETE", path, null) }
|
||||
|
||||
request(method, path, data) {
|
||||
var url = _baseUrl + path
|
||||
var body = data ? Json.stringify(data) : ""
|
||||
|
||||
var response
|
||||
if (method == "GET") {
|
||||
response = Http.get(url, _headers)
|
||||
} else if (method == "POST") {
|
||||
response = Http.post(url, body, _headers)
|
||||
} else if (method == "PUT") {
|
||||
response = Http.put(url, body, _headers)
|
||||
} else if (method == "PATCH") {
|
||||
response = Http.patch(url, body, _headers)
|
||||
} else if (method == "DELETE") {
|
||||
response = Http.delete(url, _headers)
|
||||
}
|
||||
|
||||
return parseResponse(response)
|
||||
}
|
||||
|
||||
parseResponse(response) {
|
||||
var result = {
|
||||
"status": response.statusCode,
|
||||
"headers": response.headers,
|
||||
"ok": response.statusCode >= 200 && response.statusCode < 300
|
||||
}
|
||||
|
||||
if (response.body.count > 0) {
|
||||
var fiber = Fiber.new { Json.parse(response.body) }
|
||||
var data = fiber.try()
|
||||
result["data"] = fiber.error ? response.body : data
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
System.print("=== API Client Demo ===\n")
|
||||
|
||||
var api = ApiClient.new("https://jsonplaceholder.typicode.com")
|
||||
|
||||
System.print("--- Fetching posts ---")
|
||||
var result = api.get("/posts")
|
||||
if (result["ok"]) {
|
||||
System.print("Found %(result["data"].count) posts")
|
||||
System.print("First post: %(result["data"][0]["title"])")
|
||||
}
|
||||
|
||||
System.print("\n--- Creating post ---")
|
||||
result = api.post("/posts", {
|
||||
"title": "Created with Wren-CLI",
|
||||
"body": "This is a test post",
|
||||
"userId": 1
|
||||
})
|
||||
if (result["ok"]) {
|
||||
System.print("Created post ID: %(result["data"]["id"])")
|
||||
}
|
||||
|
||||
System.print("\n--- Updating post ---")
|
||||
result = api.put("/posts/1", {
|
||||
"id": 1,
|
||||
"title": "Updated Title",
|
||||
"body": "Updated body",
|
||||
"userId": 1
|
||||
})
|
||||
System.print("Update status: %(result["status"])")
|
||||
|
||||
System.print("\n--- Deleting post ---")
|
||||
result = api.delete("/posts/1")
|
||||
System.print("Delete status: %(result["status"])")</code></pre>
|
||||
|
||||
<div class="admonition tip">
|
||||
<div class="admonition-title">Tip</div>
|
||||
<p>Save the <code>ApiClient</code> class in a separate file like <code>api_client.wren</code> and import it in your projects for reuse.</p>
|
||||
</div>
|
||||
|
||||
<h2>Next Steps</h2>
|
||||
|
||||
<ul>
|
||||
<li>Learn about <a href="../api/http.html">HTTP module</a> features in detail</li>
|
||||
<li>Explore <a href="../api/json.html">JSON module</a> for advanced parsing</li>
|
||||
<li>Build a <a href="websocket-chat.html">WebSocket chat application</a></li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{# retoor <retoor@molodetz.nl> #}
|
||||
{% extends 'page.html' %}
|
||||
|
||||
{% set page_title = "Tutorials" %}
|
||||
{% set breadcrumb = [{"title": "Tutorials"}] %}
|
||||
{% set prev_page = {"url": "api/math.html", "title": "math"} %}
|
||||
{% set next_page = {"url": "tutorials/http-client.html", "title": "HTTP Client"} %}
|
||||
|
||||
{% block article %}
|
||||
<h1>Tutorials</h1>
|
||||
|
||||
<p>Step-by-step tutorials that guide you through building complete applications with Wren-CLI. Each tutorial introduces new concepts and builds upon previous knowledge.</p>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="card">
|
||||
<h3><a href="http-client.html">Building an HTTP Client</a></h3>
|
||||
<p>Learn to make HTTP requests, parse JSON responses, and handle errors while building a REST API client.</p>
|
||||
<div class="card-meta">
|
||||
<span class="tag">http</span>
|
||||
<span class="tag">json</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="websocket-chat.html">WebSocket Chat Application</a></h3>
|
||||
<p>Build a real-time chat application using WebSockets with both client and server components.</p>
|
||||
<div class="card-meta">
|
||||
<span class="tag">websocket</span>
|
||||
<span class="tag">fibers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="database-app.html">Database Application</a></h3>
|
||||
<p>Create a complete CRUD application using SQLite for persistent data storage.</p>
|
||||
<div class="card-meta">
|
||||
<span class="tag">sqlite</span>
|
||||
<span class="tag">io</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="template-rendering.html">Template Rendering</a></h3>
|
||||
<p>Use Jinja templates to generate HTML pages, reports, and configuration files.</p>
|
||||
<div class="card-meta">
|
||||
<span class="tag">jinja</span>
|
||||
<span class="tag">io</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="cli-tool.html">Building a CLI Tool</a></h3>
|
||||
<p>Create a command-line application with argument parsing, user input, and subprocess management.</p>
|
||||
<div class="card-meta">
|
||||
<span class="tag">os</span>
|
||||
<span class="tag">subprocess</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="web-server.html">Building a Web Server</a></h3>
|
||||
<p>Build HTTP servers with routing, sessions, middleware, and REST APIs using the web module.</p>
|
||||
<div class="card-meta">
|
||||
<span class="tag">web</span>
|
||||
<span class="tag">http</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Learning Path</h2>
|
||||
|
||||
<p>If you are new to Wren-CLI, we recommend following the tutorials in this order:</p>
|
||||
|
||||
<ol>
|
||||
<li><strong>HTTP Client</strong> - Introduces async operations and JSON handling</li>
|
||||
<li><strong>Database Application</strong> - Covers data persistence and file I/O</li>
|
||||
<li><strong>Template Rendering</strong> - Learn the Jinja template system</li>
|
||||
<li><strong>WebSocket Chat</strong> - Advanced async patterns with fibers</li>
|
||||
<li><strong>CLI Tool</strong> - Bringing it all together in a real application</li>
|
||||
<li><strong>Web Server</strong> - Build complete web applications with the web module</li>
|
||||
</ol>
|
||||
|
||||
<h2>Prerequisites</h2>
|
||||
|
||||
<p>Before starting the tutorials, you should:</p>
|
||||
|
||||
<ul>
|
||||
<li>Have Wren-CLI <a href="../getting-started/installation.html">installed</a></li>
|
||||
<li>Understand the <a href="../language/index.html">basic syntax</a></li>
|
||||
<li>Be familiar with <a href="../language/classes.html">classes</a> and <a href="../language/methods.html">methods</a></li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,575 @@
|
||||
{# retoor <retoor@molodetz.nl> #}
|
||||
{% extends 'page.html' %}
|
||||
|
||||
{% set page_title = "Template Rendering" %}
|
||||
{% set breadcrumb = [{"url": "tutorials/index.html", "title": "Tutorials"}, {"title": "Template Rendering"}] %}
|
||||
{% set prev_page = {"url": "tutorials/database-app.html", "title": "Database App"} %}
|
||||
{% set next_page = {"url": "tutorials/cli-tool.html", "title": "CLI Tool"} %}
|
||||
|
||||
{% block article %}
|
||||
{% raw %}
|
||||
<h1>Template Rendering</h1>
|
||||
|
||||
<p>In this tutorial, you will learn to use Jinja templates to generate HTML pages, reports, and configuration files. Jinja is a powerful templating engine that separates your presentation logic from your data.</p>
|
||||
|
||||
<h2>What You Will Learn</h2>
|
||||
|
||||
<ul>
|
||||
<li>Basic template syntax (variables, expressions)</li>
|
||||
<li>Control structures (if, for)</li>
|
||||
<li>Template inheritance</li>
|
||||
<li>Filters and macros</li>
|
||||
<li>Loading templates from files</li>
|
||||
</ul>
|
||||
|
||||
<h2>Step 1: Basic Templates</h2>
|
||||
|
||||
<p>Create a file called <code>template_demo.wren</code>:</p>
|
||||
|
||||
<pre><code>import "jinja" for Environment, DictLoader
|
||||
|
||||
var env = Environment.new(DictLoader.new({
|
||||
"greeting": "Hello, {{ name }}!"
|
||||
}))
|
||||
|
||||
var template = env.getTemplate("greeting")
|
||||
var result = template.render({"name": "World"})
|
||||
|
||||
System.print(result) // Hello, World!</code></pre>
|
||||
|
||||
<h2>Step 2: Variables and Expressions</h2>
|
||||
|
||||
<p>Jinja supports various expressions inside <code>{{ }}</code>:</p>
|
||||
|
||||
<pre><code>import "jinja" for Environment, DictLoader
|
||||
|
||||
var templates = {
|
||||
"expressions": "
|
||||
Name: {{ user.name }}
|
||||
Age: {{ user.age }}
|
||||
Adult: {{ user.age >= 18 }}
|
||||
Items: {{ items | length }}
|
||||
First: {{ items[0] }}
|
||||
Upper: {{ user.name | upper }}
|
||||
"
|
||||
}
|
||||
|
||||
var env = Environment.new(DictLoader.new(templates))
|
||||
var template = env.getTemplate("expressions")
|
||||
|
||||
var result = template.render({
|
||||
"user": {"name": "Alice", "age": 25},
|
||||
"items": ["apple", "banana", "cherry"]
|
||||
})
|
||||
|
||||
System.print(result)</code></pre>
|
||||
|
||||
<p>Output:</p>
|
||||
<pre><code>Name: Alice
|
||||
Age: 25
|
||||
Adult: true
|
||||
Items: 3
|
||||
First: apple
|
||||
Upper: ALICE</code></pre>
|
||||
|
||||
<h2>Step 3: Control Structures</h2>
|
||||
|
||||
<h3>Conditionals</h3>
|
||||
|
||||
<pre><code>import "jinja" for Environment, DictLoader
|
||||
|
||||
var templates = {
|
||||
"status": "
|
||||
{% if user.active %}
|
||||
User {{ user.name }} is active.
|
||||
{% elif user.pending %}
|
||||
User {{ user.name }} is pending approval.
|
||||
{% else %}
|
||||
User {{ user.name }} is inactive.
|
||||
{% endif %}
|
||||
"
|
||||
}
|
||||
|
||||
var env = Environment.new(DictLoader.new(templates))
|
||||
var template = env.getTemplate("status")
|
||||
|
||||
System.print(template.render({"user": {"name": "Bob", "active": true}}))
|
||||
System.print(template.render({"user": {"name": "Carol", "pending": true}}))
|
||||
System.print(template.render({"user": {"name": "Dave", "active": false}}))</code></pre>
|
||||
|
||||
<h3>Loops</h3>
|
||||
|
||||
<pre><code>import "jinja" for Environment, DictLoader
|
||||
|
||||
var templates = {
|
||||
"list": "
|
||||
Shopping List:
|
||||
{% for item in items %}
|
||||
- {{ item.name }}: ${{ item.price }}
|
||||
{% endfor %}
|
||||
|
||||
Total items: {{ items | length }}
|
||||
"
|
||||
}
|
||||
|
||||
var env = Environment.new(DictLoader.new(templates))
|
||||
var template = env.getTemplate("list")
|
||||
|
||||
var result = template.render({
|
||||
"items": [
|
||||
{"name": "Apples", "price": 2.99},
|
||||
{"name": "Bread", "price": 3.50},
|
||||
{"name": "Milk", "price": 4.25}
|
||||
]
|
||||
})
|
||||
|
||||
System.print(result)</code></pre>
|
||||
|
||||
<h3>Loop Variables</h3>
|
||||
|
||||
<pre><code>import "jinja" for Environment, DictLoader
|
||||
|
||||
var templates = {
|
||||
"numbered": "
|
||||
{% for item in items %}
|
||||
{{ loop.index }}. {{ item }}{% if loop.first %} (first){% endif %}{% if loop.last %} (last){% endif %}
|
||||
{% endfor %}
|
||||
"
|
||||
}
|
||||
|
||||
var env = Environment.new(DictLoader.new(templates))
|
||||
var template = env.getTemplate("numbered")
|
||||
|
||||
var result = template.render({
|
||||
"items": ["Red", "Green", "Blue"]
|
||||
})
|
||||
|
||||
System.print(result)</code></pre>
|
||||
|
||||
<p>Output:</p>
|
||||
<pre><code>1. Red (first)
|
||||
2. Green
|
||||
3. Blue (last)</code></pre>
|
||||
|
||||
<h2>Step 4: Filters</h2>
|
||||
|
||||
<p>Filters transform values using the pipe (<code>|</code>) syntax:</p>
|
||||
|
||||
<pre><code>import "jinja" for Environment, DictLoader
|
||||
|
||||
var templates = {
|
||||
"filters": "
|
||||
{{ name | upper }}
|
||||
{{ name | lower }}
|
||||
{{ name | capitalize }}
|
||||
{{ name | title }}
|
||||
{{ price | round(2) }}
|
||||
{{ items | join(', ') }}
|
||||
{{ text | truncate(20) }}
|
||||
{{ html | escape }}
|
||||
{{ value | default('N/A') }}
|
||||
"
|
||||
}
|
||||
|
||||
var env = Environment.new(DictLoader.new(templates))
|
||||
var template = env.getTemplate("filters")
|
||||
|
||||
var result = template.render({
|
||||
"name": "hELLo WoRLD",
|
||||
"price": 19.99567,
|
||||
"items": ["a", "b", "c"],
|
||||
"text": "This is a very long string that should be truncated",
|
||||
"html": "<script>alert('xss')</script>",
|
||||
"value": null
|
||||
})
|
||||
|
||||
System.print(result)</code></pre>
|
||||
|
||||
<h2>Step 5: Template Inheritance</h2>
|
||||
|
||||
<p>Template inheritance allows you to build a base template with common structure:</p>
|
||||
|
||||
<pre><code>import "jinja" for Environment, DictLoader
|
||||
|
||||
var templates = {
|
||||
"base.html": "
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}My Site{% endblock %}</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>Home | About | Contact</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Copyright 2024
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
",
|
||||
|
||||
"home.html": "
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Home - My Site{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Welcome!</h1>
|
||||
<p>This is the home page.</p>
|
||||
{% endblock %}
|
||||
",
|
||||
|
||||
"about.html": "
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}About - My Site{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>About Us</h1>
|
||||
<p>{{ description }}</p>
|
||||
{% endblock %}
|
||||
"
|
||||
}
|
||||
|
||||
var env = Environment.new(DictLoader.new(templates))
|
||||
|
||||
System.print("=== Home Page ===")
|
||||
System.print(env.getTemplate("home.html").render({}))
|
||||
|
||||
System.print("\n=== About Page ===")
|
||||
System.print(env.getTemplate("about.html").render({
|
||||
"description": "We are a software company."
|
||||
}))</code></pre>
|
||||
|
||||
<h2>Step 6: Macros</h2>
|
||||
|
||||
<p>Macros are reusable template functions:</p>
|
||||
|
||||
<pre><code>import "jinja" for Environment, DictLoader
|
||||
|
||||
var templates = {
|
||||
"forms": "
|
||||
{% macro input(name, type='text', value='', placeholder='') %}
|
||||
<input type=\"{{ type }}\" name=\"{{ name }}\" value=\"{{ value }}\" placeholder=\"{{ placeholder }}\">
|
||||
{% endmacro %}
|
||||
|
||||
{% macro button(text, type='button', class='btn') %}
|
||||
<button type=\"{{ type }}\" class=\"{{ class }}\">{{ text }}</button>
|
||||
{% endmacro %}
|
||||
|
||||
<form>
|
||||
{{ input('username', placeholder='Enter username') }}
|
||||
{{ input('password', type='password', placeholder='Enter password') }}
|
||||
{{ button('Login', type='submit', class='btn btn-primary') }}
|
||||
</form>
|
||||
"
|
||||
}
|
||||
|
||||
var env = Environment.new(DictLoader.new(templates))
|
||||
var result = env.getTemplate("forms").render({})
|
||||
|
||||
System.print(result)</code></pre>
|
||||
|
||||
<h2>Step 7: Loading Templates from Files</h2>
|
||||
|
||||
<p>For larger projects, store templates in files:</p>
|
||||
|
||||
<pre><code>import "jinja" for Environment, FileSystemLoader
|
||||
import "io" for File
|
||||
|
||||
File.write("templates/base.html", "
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
")
|
||||
|
||||
File.write("templates/page.html", "
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ title }}</h1>
|
||||
{{ content }}
|
||||
{% endblock %}
|
||||
")
|
||||
|
||||
var env = Environment.new(FileSystemLoader.new("templates"))
|
||||
var template = env.getTemplate("page.html")
|
||||
|
||||
var html = template.render({
|
||||
"title": "My Page",
|
||||
"content": "<p>Hello from a file-based template!</p>"
|
||||
})
|
||||
|
||||
System.print(html)</code></pre>
|
||||
|
||||
<h2>Step 8: Building a Report Generator</h2>
|
||||
|
||||
<p>Let's build a practical example - generating HTML reports:</p>
|
||||
|
||||
<pre><code>import "jinja" for Environment, DictLoader
|
||||
import "io" for File
|
||||
import "sqlite" for Sqlite
|
||||
import "datetime" for DateTime
|
||||
|
||||
class ReportGenerator {
|
||||
construct new() {
|
||||
_env = Environment.new(DictLoader.new({
|
||||
"report": "
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ title }}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
h1 { color: #333; }
|
||||
table { border-collapse: collapse; width: 100\%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #4CAF50; color: white; }
|
||||
tr:nth-child(even) { background-color: #f2f2f2; }
|
||||
.summary { background: #f9f9f9; padding: 20px; margin: 20px 0; }
|
||||
.footer { margin-top: 40px; color: #666; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>Generated: {{ generated_at }}</p>
|
||||
|
||||
<div class=\"summary\">
|
||||
<h2>Summary</h2>
|
||||
<p>Total Records: {{ data | length }}</p>
|
||||
{% if total %}
|
||||
<p>Total Amount: ${{ total | round(2) }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>Details</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{% for header in headers %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in data %}
|
||||
<tr>
|
||||
{% for header in headers %}
|
||||
<td>{{ row[header] }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class=\"footer\">
|
||||
Report generated by Wren-CLI Report Generator
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"
|
||||
}))
|
||||
}
|
||||
|
||||
generate(title, headers, data, options) {
|
||||
var template = _env.getTemplate("report")
|
||||
|
||||
var total = null
|
||||
if (options && options["sumColumn"]) {
|
||||
total = 0
|
||||
for (row in data) {
|
||||
total = total + (row[options["sumColumn"]] || 0)
|
||||
}
|
||||
}
|
||||
|
||||
return template.render({
|
||||
"title": title,
|
||||
"headers": headers,
|
||||
"data": data,
|
||||
"total": total,
|
||||
"generated_at": DateTime.now().toString
|
||||
})
|
||||
}
|
||||
|
||||
save(filename, html) {
|
||||
File.write(filename, html)
|
||||
System.print("Report saved to %(filename)")
|
||||
}
|
||||
}
|
||||
|
||||
var generator = ReportGenerator.new()
|
||||
|
||||
var salesData = [
|
||||
{"Product": "Widget A", "Quantity": 150, "Price": 29.99, "Total": 4498.50},
|
||||
{"Product": "Widget B", "Quantity": 75, "Price": 49.99, "Total": 3749.25},
|
||||
{"Product": "Widget C", "Quantity": 200, "Price": 19.99, "Total": 3998.00},
|
||||
{"Product": "Widget D", "Quantity": 50, "Price": 99.99, "Total": 4999.50}
|
||||
]
|
||||
|
||||
var html = generator.generate(
|
||||
"Sales Report Q4 2024",
|
||||
["Product", "Quantity", "Price", "Total"],
|
||||
salesData,
|
||||
{"sumColumn": "Total"}
|
||||
)
|
||||
|
||||
generator.save("sales_report.html", html)
|
||||
System.print("Report generated successfully!")</code></pre>
|
||||
|
||||
<h2>Step 9: Email Templates</h2>
|
||||
|
||||
<p>Create personalized emails with templates:</p>
|
||||
|
||||
<pre><code>import "jinja" for Environment, DictLoader
|
||||
|
||||
class EmailGenerator {
|
||||
construct new() {
|
||||
_env = Environment.new(DictLoader.new({
|
||||
"welcome": "
|
||||
Subject: Welcome to {{ company }}, {{ user.name }}!
|
||||
|
||||
Dear {{ user.name }},
|
||||
|
||||
Thank you for joining {{ company }}! We are excited to have you.
|
||||
|
||||
Your account details:
|
||||
- Username: {{ user.username }}
|
||||
- Email: {{ user.email }}
|
||||
- Plan: {{ user.plan | default('Free') }}
|
||||
|
||||
{% if user.plan == 'Premium' %}
|
||||
As a Premium member, you have access to:
|
||||
{% for feature in premium_features %}
|
||||
- {{ feature }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
If you have any questions, please contact us at {{ support_email }}.
|
||||
|
||||
Best regards,
|
||||
The {{ company }} Team
|
||||
",
|
||||
|
||||
"order_confirmation": "
|
||||
Subject: Order #{{ order.id }} Confirmed
|
||||
|
||||
Dear {{ customer.name }},
|
||||
|
||||
Thank you for your order!
|
||||
|
||||
Order Details:
|
||||
{% for item in order.items %}
|
||||
- {{ item.name }} x {{ item.quantity }} @ ${{ item.price }} = ${{ item.total }}
|
||||
{% endfor %}
|
||||
|
||||
Subtotal: ${{ order.subtotal | round(2) }}
|
||||
Tax: ${{ order.tax | round(2) }}
|
||||
Total: ${{ order.total | round(2) }}
|
||||
|
||||
Shipping to:
|
||||
{{ customer.address.street }}
|
||||
{{ customer.address.city }}, {{ customer.address.state }} {{ customer.address.zip }}
|
||||
|
||||
Estimated delivery: {{ delivery_date }}
|
||||
|
||||
Thank you for shopping with us!
|
||||
"
|
||||
}))
|
||||
}
|
||||
|
||||
welcome(user, company) {
|
||||
return _env.getTemplate("welcome").render({
|
||||
"user": user,
|
||||
"company": company,
|
||||
"support_email": "support@%(company.lower).com",
|
||||
"premium_features": [
|
||||
"Unlimited storage",
|
||||
"Priority support",
|
||||
"Advanced analytics",
|
||||
"Custom integrations"
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
orderConfirmation(customer, order) {
|
||||
return _env.getTemplate("order_confirmation").render({
|
||||
"customer": customer,
|
||||
"order": order,
|
||||
"delivery_date": "3-5 business days"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var emails = EmailGenerator.new()
|
||||
|
||||
var welcomeEmail = emails.welcome(
|
||||
{
|
||||
"name": "Alice Smith",
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"plan": "Premium"
|
||||
},
|
||||
"Acme Corp"
|
||||
)
|
||||
|
||||
System.print(welcomeEmail)
|
||||
|
||||
System.print("\n---\n")
|
||||
|
||||
var orderEmail = emails.orderConfirmation(
|
||||
{
|
||||
"name": "Bob Jones",
|
||||
"address": {
|
||||
"street": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"state": "IL",
|
||||
"zip": "62701"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ORD-12345",
|
||||
"items": [
|
||||
{"name": "Blue Widget", "quantity": 2, "price": 29.99, "total": 59.98},
|
||||
{"name": "Red Gadget", "quantity": 1, "price": 49.99, "total": 49.99}
|
||||
],
|
||||
"subtotal": 109.97,
|
||||
"tax": 9.90,
|
||||
"total": 119.87
|
||||
}
|
||||
)
|
||||
|
||||
System.print(orderEmail)</code></pre>
|
||||
|
||||
<div class="admonition tip">
|
||||
<div class="admonition-title">Tip</div>
|
||||
<p>Use the <code>escape</code> filter on user-provided content to prevent XSS attacks in HTML output: <code>{{ user_input | escape }}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="admonition note">
|
||||
<div class="admonition-title">Note</div>
|
||||
<p>Jinja whitespace can be controlled with <code>{%-</code> and <code>-%}</code> to strip whitespace before or after tags.</p>
|
||||
</div>
|
||||
|
||||
<h2>Next Steps</h2>
|
||||
|
||||
<ul>
|
||||
<li>Explore all <a href="../api/jinja.html">Jinja filters and features</a></li>
|
||||
<li>Combine with <a href="database-app.html">database queries</a> for dynamic reports</li>
|
||||
<li>Build a <a href="cli-tool.html">CLI tool</a> for template processing</li>
|
||||
</ul>
|
||||
{% endraw %}
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,526 @@
|
||||
{# retoor <retoor@molodetz.nl> #}
|
||||
{% extends 'page.html' %}
|
||||
|
||||
{% set page_title = "WebSocket Chat Application" %}
|
||||
{% set breadcrumb = [{"url": "tutorials/index.html", "title": "Tutorials"}, {"title": "WebSocket Chat"}] %}
|
||||
{% set prev_page = {"url": "tutorials/http-client.html", "title": "HTTP Client"} %}
|
||||
{% set next_page = {"url": "tutorials/database-app.html", "title": "Database App"} %}
|
||||
|
||||
{% block article %}
|
||||
<h1>WebSocket Chat Application</h1>
|
||||
|
||||
<p>In this tutorial, you will build a real-time chat application using WebSockets. You will create both a server that handles multiple clients and a client that can send and receive messages.</p>
|
||||
|
||||
<h2>What You Will Learn</h2>
|
||||
|
||||
<ul>
|
||||
<li>Creating a WebSocket server</li>
|
||||
<li>Handling multiple client connections</li>
|
||||
<li>Broadcasting messages to all clients</li>
|
||||
<li>Building a WebSocket client</li>
|
||||
<li>Working with fibers for concurrent operations</li>
|
||||
</ul>
|
||||
|
||||
<h2>Part 1: The Chat Server</h2>
|
||||
|
||||
<h3>Step 1: Basic Server Setup</h3>
|
||||
|
||||
<p>Create a file called <code>chat_server.wren</code>:</p>
|
||||
|
||||
<pre><code>import "websocket" for WebSocketServer
|
||||
import "json" for Json
|
||||
|
||||
System.print("Starting chat server on port 8080...")
|
||||
|
||||
var server = WebSocketServer.new("0.0.0.0", 8080)
|
||||
var clients = []
|
||||
|
||||
while (true) {
|
||||
var client = server.accept()
|
||||
System.print("Client connected!")
|
||||
clients.add(client)
|
||||
}</code></pre>
|
||||
|
||||
<h3>Step 2: Handling Client Messages</h3>
|
||||
|
||||
<p>Now let's process messages from clients using fibers:</p>
|
||||
|
||||
<pre><code>import "websocket" for WebSocketServer, WebSocketMessage
|
||||
import "json" for Json
|
||||
|
||||
System.print("Starting chat server on port 8080...")
|
||||
|
||||
var server = WebSocketServer.new("0.0.0.0", 8080)
|
||||
var clients = []
|
||||
|
||||
var handleClient = Fn.new { |client|
|
||||
while (true) {
|
||||
var message = client.receive()
|
||||
|
||||
if (message == null) {
|
||||
System.print("Client disconnected")
|
||||
break
|
||||
}
|
||||
|
||||
if (message.opcode == WebSocketMessage.TEXT) {
|
||||
System.print("Received: %(message.payload)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
var client = server.accept()
|
||||
System.print("Client connected!")
|
||||
clients.add(client)
|
||||
|
||||
var fiber = Fiber.new { handleClient.call(client) }
|
||||
fiber.call()
|
||||
}</code></pre>
|
||||
|
||||
<h3>Step 3: Broadcasting Messages</h3>
|
||||
|
||||
<p>Let's broadcast messages to all connected clients:</p>
|
||||
|
||||
<pre><code>import "websocket" for WebSocketServer, WebSocketMessage
|
||||
import "json" for Json
|
||||
|
||||
System.print("=== Chat Server ===")
|
||||
System.print("Listening on ws://0.0.0.0:8080")
|
||||
|
||||
var server = WebSocketServer.new("0.0.0.0", 8080)
|
||||
var clients = []
|
||||
|
||||
var broadcast = Fn.new { |message, sender|
|
||||
var data = Json.stringify({
|
||||
"type": "message",
|
||||
"from": sender,
|
||||
"text": message
|
||||
})
|
||||
|
||||
for (client in clients) {
|
||||
var fiber = Fiber.new { client.send(data) }
|
||||
fiber.try()
|
||||
}
|
||||
}
|
||||
|
||||
var removeClient = Fn.new { |client|
|
||||
var index = clients.indexOf(client)
|
||||
if (index >= 0) {
|
||||
clients.removeAt(index)
|
||||
}
|
||||
}
|
||||
|
||||
var handleClient = Fn.new { |client, clientId|
|
||||
client.send(Json.stringify({
|
||||
"type": "welcome",
|
||||
"message": "Welcome to the chat!",
|
||||
"clientId": clientId
|
||||
}))
|
||||
|
||||
broadcast.call("%(clientId) joined the chat", "System")
|
||||
|
||||
while (true) {
|
||||
var message = client.receive()
|
||||
|
||||
if (message == null) {
|
||||
System.print("Client %(clientId) disconnected")
|
||||
removeClient.call(client)
|
||||
broadcast.call("%(clientId) left the chat", "System")
|
||||
break
|
||||
}
|
||||
|
||||
if (message.opcode == WebSocketMessage.TEXT) {
|
||||
var data = Json.parse(message.payload)
|
||||
System.print("[%(clientId)] %(data["text"])")
|
||||
broadcast.call(data["text"], clientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var clientCounter = 0
|
||||
|
||||
while (true) {
|
||||
var client = server.accept()
|
||||
clientCounter = clientCounter + 1
|
||||
var clientId = "User%(clientCounter)"
|
||||
|
||||
System.print("%(clientId) connected")
|
||||
clients.add(client)
|
||||
|
||||
Fiber.new { handleClient.call(client, clientId) }.call()
|
||||
}</code></pre>
|
||||
|
||||
<h2>Part 2: The Chat Client</h2>
|
||||
|
||||
<h3>Step 4: Basic Client</h3>
|
||||
|
||||
<p>Create a file called <code>chat_client.wren</code>:</p>
|
||||
|
||||
<pre><code>import "websocket" for WebSocket, WebSocketMessage
|
||||
import "json" for Json
|
||||
import "io" for Stdin
|
||||
|
||||
System.print("Connecting to chat server...")
|
||||
|
||||
var ws = WebSocket.connect("ws://localhost:8080")
|
||||
System.print("Connected!")
|
||||
|
||||
var receiveMessages = Fn.new {
|
||||
while (true) {
|
||||
var message = ws.receive()
|
||||
|
||||
if (message == null) {
|
||||
System.print("Disconnected from server")
|
||||
break
|
||||
}
|
||||
|
||||
if (message.opcode == WebSocketMessage.TEXT) {
|
||||
var data = Json.parse(message.payload)
|
||||
|
||||
if (data["type"] == "welcome") {
|
||||
System.print("\n%(data["message"])")
|
||||
System.print("You are: %(data["clientId"])\n")
|
||||
} else if (data["type"] == "message") {
|
||||
System.print("[%(data["from"])] %(data["text"])")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Fiber.new { receiveMessages.call() }.call()
|
||||
|
||||
System.print("Type messages and press Enter to send. Type 'quit' to exit.\n")
|
||||
|
||||
while (true) {
|
||||
System.write("> ")
|
||||
var input = Stdin.readLine()
|
||||
|
||||
if (input == "quit") {
|
||||
ws.close()
|
||||
break
|
||||
}
|
||||
|
||||
if (input.count > 0) {
|
||||
ws.send(Json.stringify({"text": input}))
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<h2>Part 3: Enhanced Features</h2>
|
||||
|
||||
<h3>Step 5: Private Messages</h3>
|
||||
|
||||
<p>Add support for private messages with the <code>/msg</code> command:</p>
|
||||
|
||||
<pre><code>import "websocket" for WebSocketServer, WebSocketMessage
|
||||
import "json" for Json
|
||||
import "regex" for Regex
|
||||
|
||||
System.print("=== Enhanced Chat Server ===")
|
||||
System.print("Listening on ws://0.0.0.0:8080")
|
||||
|
||||
var server = WebSocketServer.new("0.0.0.0", 8080)
|
||||
var clients = {}
|
||||
|
||||
var broadcast = Fn.new { |type, data|
|
||||
var message = Json.stringify({"type": type}.merge(data))
|
||||
for (id in clients.keys) {
|
||||
var fiber = Fiber.new { clients[id].send(message) }
|
||||
fiber.try()
|
||||
}
|
||||
}
|
||||
|
||||
var sendTo = Fn.new { |clientId, type, data|
|
||||
if (clients.containsKey(clientId)) {
|
||||
var message = Json.stringify({"type": type}.merge(data))
|
||||
clients[clientId].send(message)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var handleCommand = Fn.new { |client, clientId, text|
|
||||
var msgMatch = Regex.new("^/msg (\\w+) (.+)$").match(text)
|
||||
if (msgMatch) {
|
||||
var target = msgMatch.group(1)
|
||||
var message = msgMatch.group(2)
|
||||
|
||||
if (sendTo.call(target, "private", {"from": clientId, "text": message})) {
|
||||
sendTo.call(clientId, "private", {"from": "You -> %(target)", "text": message})
|
||||
} else {
|
||||
sendTo.call(clientId, "error", {"message": "User %(target) not found"})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (text == "/users") {
|
||||
var userList = clients.keys.toList.join(", ")
|
||||
sendTo.call(clientId, "info", {"message": "Online users: %(userList)"})
|
||||
return true
|
||||
}
|
||||
|
||||
if (text == "/help") {
|
||||
sendTo.call(clientId, "info", {
|
||||
"message": "Commands: /msg <user> <message>, /users, /help"
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var handleClient = Fn.new { |client, clientId|
|
||||
client.send(Json.stringify({
|
||||
"type": "welcome",
|
||||
"clientId": clientId
|
||||
}))
|
||||
|
||||
broadcast.call("join", {"user": clientId})
|
||||
|
||||
while (true) {
|
||||
var message = client.receive()
|
||||
|
||||
if (message == null) {
|
||||
clients.remove(clientId)
|
||||
broadcast.call("leave", {"user": clientId})
|
||||
System.print("%(clientId) disconnected")
|
||||
break
|
||||
}
|
||||
|
||||
if (message.opcode == WebSocketMessage.TEXT) {
|
||||
var data = Json.parse(message.payload)
|
||||
var text = data["text"]
|
||||
|
||||
if (!handleCommand.call(client, clientId, text)) {
|
||||
broadcast.call("message", {"from": clientId, "text": text})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var clientCounter = 0
|
||||
|
||||
while (true) {
|
||||
var client = server.accept()
|
||||
clientCounter = clientCounter + 1
|
||||
var clientId = "User%(clientCounter)"
|
||||
|
||||
System.print("%(clientId) connected")
|
||||
clients[clientId] = client
|
||||
|
||||
Fiber.new { handleClient.call(client, clientId) }.call()
|
||||
}</code></pre>
|
||||
|
||||
<h3>Step 6: Enhanced Client</h3>
|
||||
|
||||
<p>Update the client to handle new message types:</p>
|
||||
|
||||
<pre><code>import "websocket" for WebSocket, WebSocketMessage
|
||||
import "json" for Json
|
||||
import "io" for Stdin
|
||||
|
||||
System.print("Connecting to chat server...")
|
||||
|
||||
var ws = WebSocket.connect("ws://localhost:8080")
|
||||
var myId = ""
|
||||
|
||||
var receiveMessages = Fn.new {
|
||||
while (true) {
|
||||
var message = ws.receive()
|
||||
|
||||
if (message == null) {
|
||||
System.print("\nDisconnected from server")
|
||||
break
|
||||
}
|
||||
|
||||
if (message.opcode == WebSocketMessage.TEXT) {
|
||||
var data = Json.parse(message.payload)
|
||||
|
||||
if (data["type"] == "welcome") {
|
||||
myId = data["clientId"]
|
||||
System.print("Connected as %(myId)")
|
||||
System.print("Type /help for commands\n")
|
||||
} else if (data["type"] == "message") {
|
||||
System.print("[%(data["from"])] %(data["text"])")
|
||||
} else if (data["type"] == "private") {
|
||||
System.print("[PM %(data["from"])] %(data["text"])")
|
||||
} else if (data["type"] == "join") {
|
||||
System.print("* %(data["user"]) joined the chat")
|
||||
} else if (data["type"] == "leave") {
|
||||
System.print("* %(data["user"]) left the chat")
|
||||
} else if (data["type"] == "info") {
|
||||
System.print("INFO: %(data["message"])")
|
||||
} else if (data["type"] == "error") {
|
||||
System.print("ERROR: %(data["message"])")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Fiber.new { receiveMessages.call() }.call()
|
||||
|
||||
while (true) {
|
||||
var input = Stdin.readLine()
|
||||
|
||||
if (input == null || input == "/quit") {
|
||||
ws.close()
|
||||
break
|
||||
}
|
||||
|
||||
if (input.count > 0) {
|
||||
ws.send(Json.stringify({"text": input}))
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<h2>Part 4: Running the Application</h2>
|
||||
|
||||
<h3>Starting the Server</h3>
|
||||
|
||||
<pre><code>$ wren_cli chat_server.wren
|
||||
=== Enhanced Chat Server ===
|
||||
Listening on ws://0.0.0.0:8080</code></pre>
|
||||
|
||||
<h3>Connecting Clients</h3>
|
||||
|
||||
<p>Open multiple terminals and run:</p>
|
||||
|
||||
<pre><code>$ wren_cli chat_client.wren
|
||||
Connecting to chat server...
|
||||
Connected as User1
|
||||
Type /help for commands
|
||||
|
||||
> Hello everyone!
|
||||
[User1] Hello everyone!
|
||||
* User2 joined the chat
|
||||
[User2] Hi there!
|
||||
> /msg User2 This is a private message
|
||||
[PM You -> User2] This is a private message</code></pre>
|
||||
|
||||
<h2>Complete Server Code</h2>
|
||||
|
||||
<pre><code>import "websocket" for WebSocketServer, WebSocketMessage
|
||||
import "json" for Json
|
||||
import "regex" for Regex
|
||||
import "datetime" for DateTime
|
||||
|
||||
class ChatServer {
|
||||
construct new(host, port) {
|
||||
_server = WebSocketServer.new(host, port)
|
||||
_clients = {}
|
||||
_messageHistory = []
|
||||
_maxHistory = 100
|
||||
}
|
||||
|
||||
broadcast(type, data) {
|
||||
var message = Json.stringify({"type": type, "timestamp": DateTime.now().toString}.merge(data))
|
||||
for (id in _clients.keys) {
|
||||
var fiber = Fiber.new { _clients[id].send(message) }
|
||||
fiber.try()
|
||||
}
|
||||
}
|
||||
|
||||
sendTo(clientId, type, data) {
|
||||
if (_clients.containsKey(clientId)) {
|
||||
var message = Json.stringify({"type": type}.merge(data))
|
||||
_clients[clientId].send(message)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
handleCommand(client, clientId, text) {
|
||||
if (text.startsWith("/msg ")) {
|
||||
var parts = text[5..-1].split(" ")
|
||||
if (parts.count >= 2) {
|
||||
var target = parts[0]
|
||||
var message = parts[1..-1].join(" ")
|
||||
|
||||
if (sendTo(target, "private", {"from": clientId, "text": message})) {
|
||||
sendTo(clientId, "private", {"from": "You -> %(target)", "text": message})
|
||||
} else {
|
||||
sendTo(clientId, "error", {"message": "User not found"})
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (text == "/users") {
|
||||
sendTo(clientId, "info", {"message": "Online: %(_clients.keys.toList.join(", "))"})
|
||||
return true
|
||||
}
|
||||
|
||||
if (text == "/help") {
|
||||
sendTo(clientId, "info", {"message": "/msg <user> <text>, /users, /help, /quit"})
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
handleClient(client, clientId) {
|
||||
_clients[clientId] = client
|
||||
|
||||
sendTo(clientId, "welcome", {"clientId": clientId, "users": _clients.keys.toList})
|
||||
broadcast("join", {"user": clientId})
|
||||
|
||||
while (true) {
|
||||
var message = client.receive()
|
||||
|
||||
if (message == null) {
|
||||
_clients.remove(clientId)
|
||||
broadcast("leave", {"user": clientId})
|
||||
System.print("[%(DateTime.now())] %(clientId) disconnected")
|
||||
break
|
||||
}
|
||||
|
||||
if (message.opcode == WebSocketMessage.TEXT) {
|
||||
var data = Json.parse(message.payload)
|
||||
var text = data["text"]
|
||||
|
||||
if (!handleCommand(client, clientId, text)) {
|
||||
System.print("[%(DateTime.now())] %(clientId): %(text)")
|
||||
broadcast("message", {"from": clientId, "text": text})
|
||||
|
||||
_messageHistory.add({"from": clientId, "text": text, "time": DateTime.now().toString})
|
||||
if (_messageHistory.count > _maxHistory) {
|
||||
_messageHistory.removeAt(0)
|
||||
}
|
||||
}
|
||||
} else if (message.opcode == WebSocketMessage.PING) {
|
||||
client.pong(message.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run() {
|
||||
System.print("Chat server running on port 8080")
|
||||
var counter = 0
|
||||
|
||||
while (true) {
|
||||
var client = _server.accept()
|
||||
counter = counter + 1
|
||||
var clientId = "User%(counter)"
|
||||
|
||||
System.print("[%(DateTime.now())] %(clientId) connected")
|
||||
|
||||
Fiber.new { handleClient(client, clientId) }.call()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var server = ChatServer.new("0.0.0.0", 8080)
|
||||
server.run()</code></pre>
|
||||
|
||||
<div class="admonition note">
|
||||
<div class="admonition-title">Note</div>
|
||||
<p>WebSocket connections in Wren-CLI use fibers for concurrent handling. Each client runs in its own fiber, allowing the server to handle multiple connections simultaneously.</p>
|
||||
</div>
|
||||
|
||||
<h2>Next Steps</h2>
|
||||
|
||||
<ul>
|
||||
<li>Add user authentication</li>
|
||||
<li>Store message history in <a href="database-app.html">SQLite</a></li>
|
||||
<li>Create chat rooms</li>
|
||||
<li>See the <a href="../api/websocket.html">WebSocket API reference</a></li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user