diff --git a/.gitignore b/.gitignore index 575a1af..be13c22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .vscode/ -target/ \ No newline at end of file +target/ +*.pyc +*.pyo diff --git a/samples/.gitignore b/samples/.gitignore index 917660a..5145f80 100644 --- a/samples/.gitignore +++ b/samples/.gitignore @@ -1 +1,10 @@ -*.wasm \ No newline at end of file +# Ignore matching files +poetry.lock +*.wasm +# Ignore any matching folders +__pycache__/ +.venv/ +.env/ +# ignore generated bindings +plugins/ +schemas/ \ No newline at end of file diff --git a/samples/python/Makefile b/samples/python/Makefile new file mode 100644 index 0000000..907ab4e --- /dev/null +++ b/samples/python/Makefile @@ -0,0 +1,18 @@ +PLUGIN_FILE=my-plugin.wasm +PLUGIN_FOLDER=myplugin + +schemas: + poetry run python3 myplugin/helpers/schemas.py + +bindings: + poetry run componentize-py --wit-path wit --world logcraft:lgc/plugins@0.1.0 bindings ${PLUGIN_FOLDER} + +build: + make clear + make schemas + poetry run componentize-py --wit-path wit --world logcraft:lgc/plugins@0.1.0 componentize -p ${PLUGIN_FOLDER} main -o ${PLUGIN_FILE} + +clear: + rm -f ${PLUGIN_FILE} + rm -rf ${PLUGIN_FOLDER}/plugins + rm -rf ${PLUGIN_FOLDER}/schemas \ No newline at end of file diff --git a/samples/python/README.md b/samples/python/README.md index b29ab6d..0b15c67 100644 --- a/samples/python/README.md +++ b/samples/python/README.md @@ -1,6 +1,6 @@ # Python Plugin Sample -This is an example plugin using python. +This is an example plugin using Python. --- **Documentation**: https://docs.logcraft.io @@ -8,7 +8,7 @@ This is an example plugin using python. ## Poetry -This example uses python poetry to manage dependencies but feel free to use your prefered package manager. +This example uses Python poetry to manage dependencies but feel free to use your preferred package manager. ## Build @@ -17,29 +17,33 @@ Building a plugin is a 2 steps process: 1. Build the bindings for your IDE. This is optional but advised for development. ```bash -poetry run componentize-py --wit-path wit --world plugins bindings myplugin - +poetry run componentize-py --wit-path wit --world logcraft:lgc/plugins@0.1.0 bindings myplugin ``` -2. Build the plugin. This step automatically build the bindings regardless if you did it in the previous step or not. The resulting wasm file is the LogCraft CLI plugin. +2. Build the plugin. This step automatically builds the bindings regardless if you did it in the previous step or not. The resulting wasm file is the LogCraft CLI plugin. ```bash -poetry run componentize-py --wit-path wit --world plugin componentize -p myplugin main -o my-plugin.wasm +poetry run componentize-py --wit-path wit --world logcraft:lgc/plugins@0.1.0 componentize -p myplugin main -o my-plugin.wasm ``` +Or, with the provided Makefile: -## Important +```bash +% make bindings +% make build +% make clear +``` -As of june 2024, `componentize-py` uses cpython runtime without `zlib`. This is an issue that has consequences: we cannot use python `requests` library and probably others. +## Important +As of June 2024, `componentize-py` uses cpython runtime without `zlib`. This is an issue that has consequences: we cannot use python `requests` library and probably others. This is a known and identified problem that will be fixed in the future by the `componentize-py` team. -To workaround this issue, we created a http library based on `sink`, this is available in this sample python application (`myplugin.client`). - +To workaround this issue, we created an HTTP library based on `sink`, which is available in this sample Python application (`myplugin.client`). ## WIT -The wit files provides from: +The wit files are provided from: -1. `wit/world.wit` and `wit/plugin.wit` are LogCraft specific configuration files. These files define input/outputs of plugins. +1. `wit/world.wit` and `wit/plugin.`wit` are LogCraft-specific configuration files. These files define the inputs/outputs of plugins. 2. `wit/deps/` come from [wasi-http](https://github.com/WebAssembly/wasi-http/tree/main/wit/deps) \ No newline at end of file diff --git a/samples/python/myplugin/client/__init__.py b/samples/python/myplugin/client/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/samples/python/myplugin/helpers/__init__.py b/samples/python/myplugin/helpers/__init__.py new file mode 100644 index 0000000..254f221 --- /dev/null +++ b/samples/python/myplugin/helpers/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2023 LogCraft, SAS. +# SPDX-License-Identifier: MPL-2.0 \ No newline at end of file diff --git a/samples/python/myplugin/helpers/client/__init__.py b/samples/python/myplugin/helpers/client/__init__.py new file mode 100644 index 0000000..254f221 --- /dev/null +++ b/samples/python/myplugin/helpers/client/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2023 LogCraft, SAS. +# SPDX-License-Identifier: MPL-2.0 \ No newline at end of file diff --git a/samples/python/myplugin/client/pool_loop.py b/samples/python/myplugin/helpers/client/pool_loop.py similarity index 99% rename from samples/python/myplugin/client/pool_loop.py rename to samples/python/myplugin/helpers/client/pool_loop.py index 4f075f3..b9e07ab 100644 --- a/samples/python/myplugin/client/pool_loop.py +++ b/samples/python/myplugin/helpers/client/pool_loop.py @@ -10,13 +10,13 @@ import asyncio import socket import subprocess +from typing import Optional, cast from plugins.types import Ok, Err -from plugins.imports import types, streams, poll, outgoing_handler +from plugins.imports import types, streams, poll from plugins.imports.types import IncomingBody, OutgoingBody, OutgoingRequest, IncomingResponse from plugins.imports.streams import StreamErrorClosed, InputStream from plugins.imports.poll import Pollable -from typing import Optional, cast # Maximum number of bytes to read at a time READ_SIZE: int = 16 * 1024 diff --git a/samples/python/myplugin/client/req.py b/samples/python/myplugin/helpers/client/req.py similarity index 99% rename from samples/python/myplugin/client/req.py rename to samples/python/myplugin/helpers/client/req.py index e40901a..1d7de79 100644 --- a/samples/python/myplugin/client/req.py +++ b/samples/python/myplugin/helpers/client/req.py @@ -4,6 +4,7 @@ import traceback import poll_loop from poll_loop import PollLoop, Sink, Stream + from plugins.types import Ok, Err from plugins.imports.types import ( IncomingResponse, Method, Method_Get, Method_Head, Method_Post, Method_Put, Method_Delete, Method_Connect, Method_Options, @@ -11,6 +12,7 @@ Fields, Scheme, Scheme_Http, Scheme_Https, Scheme_Other, OutgoingRequest, OutgoingBody ) from plugins.imports.streams import StreamError_Closed + from dataclasses import dataclass from collections.abc import MutableMapping from typing import Optional diff --git a/samples/python/myplugin/helpers/schemas.py b/samples/python/myplugin/helpers/schemas.py new file mode 100644 index 0000000..3898760 --- /dev/null +++ b/samples/python/myplugin/helpers/schemas.py @@ -0,0 +1,32 @@ +# Copyright (c) 2023 LogCraft, SAS. +# SPDX-License-Identifier: MPL-2.0 +import os + +def generate(): + """ + Convert .k files from the 'package' directory to .py files + """ + root = os.path.join(os.path.dirname(__file__), '../') + root = os.path.abspath(root) + + # schemas directory + schemas_dir = os.path.join(root, 'schemas') + if not os.path.exists(schemas_dir): + os.makedirs(schemas_dir) + + # convert .k files to .py files + for filename in ["settings.k", "rule.k"]: + filepath = os.path.join(root, 'package', filename) + filepath = os.path.abspath(filepath) + + with open(filepath, 'r') as f: + content = f.read() + + variable = filename.replace('.k', '') + f_out = os.path.join(schemas_dir, filename.replace('.k', '.py')) + with open(f_out, 'w') as f: + f.write("%s = '''%s'''" % (variable, content)) + + +if __name__ == "__main__": + generate() diff --git a/samples/python/myplugin/main.py b/samples/python/myplugin/main.py index 0dbd9bc..e442815 100644 --- a/samples/python/myplugin/main.py +++ b/samples/python/myplugin/main.py @@ -1,68 +1,187 @@ # Copyright (c) 2023 LogCraft, SAS. # SPDX-License-Identifier: MPL-2.0 - +import json from typing import Optional # bindings generated by `componentize-py` -from plugins import Plugins -from plugins.exports.plugin import Metadata from plugins.types import Err, Ok, Some, Result +from plugins.exports import Plugin +from plugins.exports.plugin import Metadata +# bindings generated by `helpers.schemas.generate()` +from schemas.settings import settings +from schemas.rule import rule +# NOTE: # As of June 2024, the `requests` library is not supported due to missing dependencies -# in the cpython runtime used by componentize-py (ssl support, zlib). +# in the CPython runtime used by componentize-py (ssl support, zlib). # # A fix is planned for the future, so in the mean time, we use our own http library # derivated from sink # https://github.com/bytecodealliance/componentize-py/issues/96 +from helpers.client.req import Request, send + +class Plugin(Plugin): + """ + The following functions are called by `lgc` and they receive these parameters, or a + combinaison of them: + + `name`: + a string representing the name of the detection, this is what we have in the detection + file. + + `config`: + a JSON string representing the service configuration. This is an dict representing the + service configuration. The available keys are defined in the `package/settings.k` file. + + For example, in this sample code we are using a copy of the settings.k from the Splunk + plugin, so we should expect parameters such as 'endpoint', 'authorization', etc. -from client.req import Request, Response, send + `params`: + similar to `config`, the `params` parameter represents the detection rule (i.e. the yaml + file). The available keys are defined in the `package/rule.k` file. + NOTE: + This code sample is minimalistic and does not implement the actual logic to create, read, + update or delete a detection. It only shows how to parse the JSON strings received from + the CLI and how to return the results. In a real plugin, consider implementing classes to + represent the settings.k and rule.k files. + """ -class Plugin(Plugins): # func() -> metadata; def load(self) -> Metadata: """ - The `load()` function is called when the plugin is installed using `lgc plugins install`. + The `load()` function is called when the plugin is installed using + `lgc plugins install /path/to/my-plugin.wasm`. - It should return a `Metadata` object containing the plugin's name, version, author, and description. - Make sure the name respect kebab-case (lowercase and separated by dashes). The provided information - will be displayed in the lgc.yaml file. + It should return a `Metadata` object containing the plugin's name, version, author, and + description. Make sure the name respect kebab-case (lowercase and separated by dashes). + + This information will be registered/displayed in the lgc.yaml file. """ return Metadata("my-plugin", "0.1.0", "LogCraft", "This is a famous plugin") # func() -> string; def settings(self) -> str: - return "OK" + """ + The `settings()` function is called by several `lgc` capabilities such as + `lgc validate` or `lgc services configure my-plugin`. + """ + return settings # func() -> string; def schema(self) -> str: - return "OK" + """ + The `schema()` function is called by `lgc plugins schema my-plugin`. + """ + return rule # func(config: string, name: string, params: string) -> result, string>; def create(self, config: str, name: str, params: str) -> Result[Optional[str], str]: - return Ok(Some("create()")) + # decode the json string received from the CLI + try: + config = json.loads(config) # service configuration (settings.k) + params = json.loads(params) # detection rule (rule.k) + except Exception as e: + raise Err(str(e)) + + # Depending on the settings.k, we need to assemble an url that will be used to + # create the detection. Here we are using the `endpoint` key from the settings.k + # to create the url. + url = f"{config['endpoint']}/some/service/remote/path/{name}" + resp = send(Request("POST", url, {}, None)) + + if resp.status == 201: + # Rule content not needed + return "" + raise Err(str(resp.status)) # func(config: string, name: string, params: string) -> result, string>; def read(self, config: str, name: str, params: str) -> Optional[str]: - return Ok(Some("read()")) + # decode the json string received from the CLI + try: + config = json.loads(config) # service configuration (settings.k) + params = json.loads(params) # detection rule (rule.k) + except Exception as e: + raise Err(str(e)) + + # Depending on the settings.k, we need to assemble an url that will be used to + # retrieve a detection. Here we are using the `endpoint` key from the settings.k + # to create the url. + url = f"{config['endpoint']}/some/service/remote/path/{name}" + resp = send(Request("GET", url, {}, None)) + + if resp.status == 200: + # return a json/dict object as a string (representing the rule.k) + # ex: return json.dumps({"rule": "my-rule"}) + return str(resp.body) + # If 404, detection does not exist and will be created + elif resp.status == 404: + return None + # For any other HTTP code return an error + else: + raise Err(f"Error: HTTP/{resp.status}") # func(config: string, name: string, params: string) -> result, string>; def update(self, config: str, name: str, params: str) -> Optional[str]: - return Ok(Some("update()")) + # decode the json string received from the CLI + try: + config = json.loads(config) # service configuration (settings.k) + params = json.loads(params) # detection rule (rule.k) + except Exception as e: + raise Err(str(e)) + + # Depending on the settings.k, we need to assemble an url that will be used to + # retrieve a detection. Here we are using the `endpoint` key from the settings.k + # to create the url. + url = f"{config['endpoint']}/some/service/remote/path/{name}" + resp = send(Request("PUT", url, {}, None)) + + if resp.status == 200: + # Rule content not needed + return "" + raise Err(str(resp.status)) # func(config: string, name: string, params: string) -> result, string>; def delete(self, config: str, name: str, params: str) -> Optional[str]: - return Ok(Some("delete()")) + # decode the json string received from the CLI + try: + config = json.loads(config) # service configuration (settings.k) + params = json.loads(params) # detection rule (rule.k) + except Exception as e: + raise Err(str(e)) + + # Depending on the settings.k, we need to assemble an url that will be used to + # retrieve a detection. Here we are using the `endpoint` key from the settings.k + # to create the url. + url = f"{config['endpoint']}/some/service/remote/path/{name}" + resp = send(Request("DELETE", url, {}, None)) + + if resp.status == 201: + # Rule content not needed + return "" + elif resp.status == 404: + # Detection doesn't exists + return None + raise Err(str(resp.status)) # ping: func(config: string) -> result; - def ping(self, config: str) -> int: + def ping(self, config: str) -> int: + """ + `lgc services ping` will call this function to check if the service is up and running. + + This is a sample implementation of the `ping` function that sends a GET request + to `https://google.fr` and returns the status code, or an error if the request + fails. + """ try: - resp = send(Request("GET", "https://google.fr", {}, None)) + # Service configuration + config = json.loads(config) + # Make GET request with service provided endpoint + resp = send(Request("GET", config["endpoint"], {}, None)) except Exception as e: raise Err(str(e)) - if resp.status_code >= 400: - raise Err(str(resp.status_code)) - - return Ok(resp.status_code) + if resp.status >= 400: + raise Err(str(resp.status)) + return resp.status diff --git a/samples/python/myplugin/package/rule.k b/samples/python/myplugin/package/rule.k new file mode 100644 index 0000000..2e5cf81 --- /dev/null +++ b/samples/python/myplugin/package/rule.k @@ -0,0 +1,75 @@ +# Copyright (c) 2023 LogCraft, SAS. +# SPDX-License-Identifier: MPL-2.0 + +schema Rule: + """Splunk Detection Rule + + Attributes + ---------- + app: str, required + Application name + """ + app: str + savedsearch: SavedSearch + +schema SavedSearch: + """Splunk Saved Search + cron_schedule: str, optional + Valid values: cron string. + disabled: bool, optional + Indicates if the saved search is enabled. Defaults to false. + dispatch_buckets: int, optional + The maximum number of timeline buckets. Defaults to 0. + display_view: str, optional + Defines the default UI view name (not label) in which to load the results. + is_scheduled: bool, optional + Whether this search is to be run on a schedule. + is_visible: bool, optional + Specifies whether this saved search should be listed in the visible saved search list. Defaults to true. + max_concurrent: int, optional + The maximum number of concurrent instances of this search the scheduler is allowed to run. Defaults to 1. + realtime_schedule: bool, optional + Controls the way the scheduler computes the next execution time of a scheduled search. + request_ui_dispatch_app: str, optional + Specifies a field used by Splunk Web to denote the app this search should be dispatched in. + request_ui_dispatch_view: str, optional + Specifies a field used by Splunk Web to denote the view this search should be displayed in. + restart_on_searchpeer_add: bool, optional + Specifies whether to restart a real-time search managed by the scheduler when a search peer becomes available for this saved search. Defaults to true. + run_on_startup: bool, optional + Specifies whether to restart a real-time search managed by the scheduler when a search peer becomes available for this saved search. Defaults to true. + schedule_window: str, optional + Time window (in minutes) during which the search has lower priority. Defaults to 0. + schedule_priority: str, optional + Raises the scheduling priority of the named search. + search: str, optional + Required when creating a new search. + vsid: str, optional + Defines the viewstate id associated with the UI view listed in 'displayview'. + workload_pool: str, optional + Specifies the new workload pool where the existing running search will be placed. + action: any, optional + Enable or disable an alert action. + dispatch: any, optional + Wildcard argument that accepts any dispatch related argument. + """ + cron_schedule?: str + description?: str + disabled?: bool + dispatch_buckets?: int + display_view?: str + is_scheduled?: bool + is_visible?: bool + max_concurrent?: int + realtime_schedule?: bool + request_ui_dispatch_app?: str + request_ui_dispatch_view?: str + restart_on_searchpeer_add?: bool + run_on_startup?: bool + schedule_window?: str + schedule_priority?: str + search?: str + vsid?: str + workload_pool?: str + action?: any + dispatch?: any \ No newline at end of file diff --git a/samples/python/myplugin/package/settings.k b/samples/python/myplugin/package/settings.k new file mode 100644 index 0000000..6c66d83 --- /dev/null +++ b/samples/python/myplugin/package/settings.k @@ -0,0 +1,35 @@ +# Copyright (c) 2023 LogCraft, SAS. +# SPDX-License-Identifier: MPL-2.0 + +import regex + +schema Configuration: + """Splunk Configuration + + Attributes + ---------- + endpoint: str, required + Splunk URL + authorization_scheme: str, required + Authorization scheme + authorization: str, required + Authorization + timeout: int, optional + Timeout in seconds + """ + # Mandatory Parameters + + # Splunk URL + endpoint: str = 'https://splunk_endpoint:8089' + # Authorization scheme + authorization_scheme: AuthorizationScheme = "Bearer" + # Authorization + @info(sensitive="true") + authorization: str = "base64_encoded_token" + # Timeout + timeout?: int = 60 + + check: + regex.match(endpoint, "^https?://"), "Incorrect endpoint, must start with http:// or https://." + +type AuthorizationScheme = "Bearer" | "Basic" \ No newline at end of file diff --git a/samples/python/poetry.lock b/samples/python/poetry.lock deleted file mode 100644 index 4efe417..0000000 --- a/samples/python/poetry.lock +++ /dev/null @@ -1,21 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. - -[[package]] -name = "componentize-py" -version = "0.13.5" -description = "Tool to package Python applications as WebAssembly components" -optional = false -python-versions = "*" -files = [ - {file = "componentize_py-0.13.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1914553c3093a8b16d056f81a7932db0cb874842cb032a2be68f880f9621cec9"}, - {file = "componentize_py-0.13.5-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:0cdf5bfb17899e56cb6d27f84495ad3ba10fc2011debcf50c9f0cb4210f2d970"}, - {file = "componentize_py-0.13.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7fa9c80a390085db37e03adf90a8b9e04f37e9b083b2a131fe97da057ccdbed"}, - {file = "componentize_py-0.13.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b3cf7390453ffa8b45d8266ec11b7afa4f16503a6a43014fa5fa2e2d8dd3b76e"}, - {file = "componentize_py-0.13.5-cp37-abi3-win_amd64.whl", hash = "sha256:dbc8ac466d1a1074709d3344c03861507dacabab964b8ff13da59d0d363f166f"}, - {file = "componentize_py-0.13.5.tar.gz", hash = "sha256:1a41d92f3a8ff0f4b20894b9d0b0c0015fe3c77bedba1bf7bafe1257d177688a"}, -] - -[metadata] -lock-version = "2.0" -python-versions = "^3.12" -content-hash = "dd30b9ee63591d0a34952ddd2d2c35037ae1c37a011900fbdfe7a5246ec108cb" diff --git a/samples/python/pyproject.toml b/samples/python/pyproject.toml index 66c76c3..fbfd736 100644 --- a/samples/python/pyproject.toml +++ b/samples/python/pyproject.toml @@ -1,3 +1,6 @@ +# Copyright (c) 2023 LogCraft, SAS. +# SPDX-License-Identifier: MPL-2.0 + [tool.poetry] name = "myplugin" version = "0.1.0" @@ -8,6 +11,11 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.12" componentize-py = "^0.13.5" +asyncio = "^3.4.3" +mypy = "^1.10.1" + +[tool.poetry.group.test.dependencies] +ruff = "^0.5.0" [build-system] requires = ["poetry-core"] diff --git a/samples/python/wit/deps/http/handler.wit b/samples/python/wit/deps/http/handler.wit new file mode 100644 index 0000000..a34a064 --- /dev/null +++ b/samples/python/wit/deps/http/handler.wit @@ -0,0 +1,43 @@ +/// This interface defines a handler of incoming HTTP Requests. It should +/// be exported by components which can respond to HTTP Requests. +interface incoming-handler { + use types.{incoming-request, response-outparam}; + + /// This function is invoked with an incoming HTTP Request, and a resource + /// `response-outparam` which provides the capability to reply with an HTTP + /// Response. The response is sent by calling the `response-outparam.set` + /// method, which allows execution to continue after the response has been + /// sent. This enables both streaming to the response body, and performing other + /// work. + /// + /// The implementor of this function must write a response to the + /// `response-outparam` before returning, or else the caller will respond + /// with an error on its behalf. + handle: func( + request: incoming-request, + response-out: response-outparam + ); +} + +/// This interface defines a handler of outgoing HTTP Requests. It should be +/// imported by components which wish to make HTTP Requests. +interface outgoing-handler { + use types.{ + outgoing-request, request-options, future-incoming-response, error-code + }; + + /// This function is invoked with an outgoing HTTP Request, and it returns + /// a resource `future-incoming-response` which represents an HTTP Response + /// which may arrive in the future. + /// + /// The `options` argument accepts optional parameters for the HTTP + /// protocol's transport layer. + /// + /// This function may return an error if the `outgoing-request` is invalid + /// or not allowed to be made. Otherwise, protocol errors are reported + /// through the `future-incoming-response`. + handle: func( + request: outgoing-request, + options: option + ) -> result; +} diff --git a/samples/python/wit/deps/http/proxy.wit b/samples/python/wit/deps/http/proxy.wit new file mode 100644 index 0000000..687c24d --- /dev/null +++ b/samples/python/wit/deps/http/proxy.wit @@ -0,0 +1,32 @@ +package wasi:http@0.2.0; + +/// The `wasi:http/proxy` world captures a widely-implementable intersection of +/// hosts that includes HTTP forward and reverse proxies. Components targeting +/// this world may concurrently stream in and out any number of incoming and +/// outgoing HTTP requests. +world proxy { + /// HTTP proxies have access to time and randomness. + include wasi:clocks/imports@0.2.0; + import wasi:random/random@0.2.0; + + /// Proxies have standard output and error streams which are expected to + /// terminate in a developer-facing console provided by the host. + import wasi:cli/stdout@0.2.0; + import wasi:cli/stderr@0.2.0; + + /// TODO: this is a temporary workaround until component tooling is able to + /// gracefully handle the absence of stdin. Hosts must return an eof stream + /// for this import, which is what wasi-libc + tooling will do automatically + /// when this import is properly removed. + import wasi:cli/stdin@0.2.0; + + /// This is the default handler to use when user code simply wants to make an + /// HTTP request (e.g., via `fetch()`). + import outgoing-handler; + + /// The host delivers incoming HTTP requests to a component by calling the + /// `handle` function of this exported interface. A host may arbitrarily reuse + /// or not reuse component instance when delivering incoming HTTP requests and + /// thus a component must be able to handle 0..N calls to `handle`. + export incoming-handler; +} diff --git a/samples/python/wit/deps/http/types.wit b/samples/python/wit/deps/http/types.wit new file mode 100644 index 0000000..755ac6a --- /dev/null +++ b/samples/python/wit/deps/http/types.wit @@ -0,0 +1,570 @@ +/// This interface defines all of the types and methods for implementing +/// HTTP Requests and Responses, both incoming and outgoing, as well as +/// their headers, trailers, and bodies. +interface types { + use wasi:clocks/monotonic-clock@0.2.0.{duration}; + use wasi:io/streams@0.2.0.{input-stream, output-stream}; + use wasi:io/error@0.2.0.{error as io-error}; + use wasi:io/poll@0.2.0.{pollable}; + + /// This type corresponds to HTTP standard Methods. + variant method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + other(string) + } + + /// This type corresponds to HTTP standard Related Schemes. + variant scheme { + HTTP, + HTTPS, + other(string) + } + + /// These cases are inspired by the IANA HTTP Proxy Error Types: + /// https://www.iana.org/assignments/http-proxy-status/http-proxy-status.xhtml#table-http-proxy-error-types + variant error-code { + DNS-timeout, + DNS-error(DNS-error-payload), + destination-not-found, + destination-unavailable, + destination-IP-prohibited, + destination-IP-unroutable, + connection-refused, + connection-terminated, + connection-timeout, + connection-read-timeout, + connection-write-timeout, + connection-limit-reached, + TLS-protocol-error, + TLS-certificate-error, + TLS-alert-received(TLS-alert-received-payload), + HTTP-request-denied, + HTTP-request-length-required, + HTTP-request-body-size(option), + HTTP-request-method-invalid, + HTTP-request-URI-invalid, + HTTP-request-URI-too-long, + HTTP-request-header-section-size(option), + HTTP-request-header-size(option), + HTTP-request-trailer-section-size(option), + HTTP-request-trailer-size(field-size-payload), + HTTP-response-incomplete, + HTTP-response-header-section-size(option), + HTTP-response-header-size(field-size-payload), + HTTP-response-body-size(option), + HTTP-response-trailer-section-size(option), + HTTP-response-trailer-size(field-size-payload), + HTTP-response-transfer-coding(option), + HTTP-response-content-coding(option), + HTTP-response-timeout, + HTTP-upgrade-failed, + HTTP-protocol-error, + loop-detected, + configuration-error, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. It also includes an optional string for an + /// unstructured description of the error. Users should not depend on the + /// string for diagnosing errors, as it's not required to be consistent + /// between implementations. + internal-error(option) + } + + /// Defines the case payload type for `DNS-error` above: + record DNS-error-payload { + rcode: option, + info-code: option + } + + /// Defines the case payload type for `TLS-alert-received` above: + record TLS-alert-received-payload { + alert-id: option, + alert-message: option + } + + /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + record field-size-payload { + field-name: option, + field-size: option + } + + /// Attempts to extract a http-related `error` from the wasi:io `error` + /// provided. + /// + /// Stream operations which return + /// `wasi:io/stream/stream-error::last-operation-failed` have a payload of + /// type `wasi:io/error/error` with more information about the operation + /// that failed. This payload can be passed through to this function to see + /// if there's http-related information about the error to return. + /// + /// Note that this function is fallible because not all io-errors are + /// http-related errors. + http-error-code: func(err: borrow) -> option; + + /// This type enumerates the different kinds of errors that may occur when + /// setting or appending to a `fields` resource. + variant header-error { + /// This error indicates that a `field-key` or `field-value` was + /// syntactically invalid when used with an operation that sets headers in a + /// `fields`. + invalid-syntax, + + /// This error indicates that a forbidden `field-key` was used when trying + /// to set a header in a `fields`. + forbidden, + + /// This error indicates that the operation on the `fields` was not + /// permitted because the fields are immutable. + immutable, + } + + /// Field keys are always strings. + type field-key = string; + + /// Field values should always be ASCII strings. However, in + /// reality, HTTP implementations often have to interpret malformed values, + /// so they are provided as a list of bytes. + type field-value = list; + + /// This following block defines the `fields` resource which corresponds to + /// HTTP standard Fields. Fields are a common representation used for both + /// Headers and Trailers. + /// + /// A `fields` may be mutable or immutable. A `fields` created using the + /// constructor, `from-list`, or `clone` will be mutable, but a `fields` + /// resource given by other means (including, but not limited to, + /// `incoming-request.headers`, `outgoing-request.headers`) might be be + /// immutable. In an immutable fields, the `set`, `append`, and `delete` + /// operations will fail with `header-error.immutable`. + resource fields { + + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + constructor(); + + /// Construct an HTTP Fields. + /// + /// The resulting `fields` is mutable. + /// + /// The list represents each key-value pair in the Fields. Keys + /// which have multiple values are represented by multiple entries in this + /// list with the same key. + /// + /// The tuple is a pair of the field key, represented as a string, and + /// Value, represented as a list of bytes. In a valid Fields, all keys + /// and values are valid UTF-8 strings. However, values are not always + /// well-formed, so they are represented as a raw list of bytes. + /// + /// An error result will be returned if any header or value was + /// syntactically invalid, or if a header was forbidden. + from-list: static func( + entries: list> + ) -> result; + + /// Get all of the values corresponding to a key. If the key is not present + /// in this `fields`, an empty list is returned. However, if the key is + /// present but empty, this is represented by a list with one or more + /// empty field-values present. + get: func(name: field-key) -> list; + + /// Returns `true` when the key is present in this `fields`. If the key is + /// syntactically invalid, `false` is returned. + has: func(name: field-key) -> bool; + + /// Set all of the values for a key. Clears any existing values for that + /// key, if they have been set. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + set: func(name: field-key, value: list) -> result<_, header-error>; + + /// Delete all values for a key. Does nothing if no values for the key + /// exist. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + delete: func(name: field-key) -> result<_, header-error>; + + /// Append a value for a key. Does not change or delete any existing + /// values for that key. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + append: func(name: field-key, value: field-value) -> result<_, header-error>; + + /// Retrieve the full set of keys and values in the Fields. Like the + /// constructor, the list represents each key-value pair. + /// + /// The outer list represents each key-value pair in the Fields. Keys + /// which have multiple values are represented by multiple entries in this + /// list with the same key. + entries: func() -> list>; + + /// Make a deep copy of the Fields. Equivelant in behavior to calling the + /// `fields` constructor on the return value of `entries`. The resulting + /// `fields` is mutable. + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + type headers = fields; + + /// Trailers is an alias for Fields. + type trailers = fields; + + /// Represents an incoming HTTP Request. + resource incoming-request { + + /// Returns the method of the incoming request. + method: func() -> method; + + /// Returns the path with query parameters from the request, as a string. + path-with-query: func() -> option; + + /// Returns the protocol scheme from the request. + scheme: func() -> option; + + /// Returns the authority from the request, if it was present. + authority: func() -> option; + + /// Get the `headers` associated with the request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// The `headers` returned are a child resource: it must be dropped before + /// the parent `incoming-request` is dropped. Dropping this + /// `incoming-request` before all children are dropped will trap. + headers: func() -> headers; + + /// Gives the `incoming-body` associated with this request. Will only + /// return success at most once, and subsequent calls will return error. + consume: func() -> result; + } + + /// Represents an outgoing HTTP Request. + resource outgoing-request { + + /// Construct a new `outgoing-request` with a default `method` of `GET`, and + /// `none` values for `path-with-query`, `scheme`, and `authority`. + /// + /// * `headers` is the HTTP Headers for the Request. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, an `outgoing-request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `outgoing-handler.handle` implementation + /// to reject invalid constructions of `outgoing-request`. + constructor( + headers: headers + ); + + /// Returns the resource corresponding to the outgoing Body for this + /// Request. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-request` can be retrieved at most once. Subsequent + /// calls will return error. + body: func() -> result; + + /// Get the Method for the Request. + method: func() -> method; + /// Set the Method for the Request. Fails if the string present in a + /// `method.other` argument is not a syntactically valid method. + set-method: func(method: method) -> result; + + /// Get the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. + path-with-query: func() -> option; + /// Set the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. Fails is the + /// string given is not a syntactically valid path and query uri component. + set-path-with-query: func(path-with-query: option) -> result; + + /// Get the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. + scheme: func() -> option; + /// Set the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. Fails if the + /// string given is not a syntactically valid uri scheme. + set-scheme: func(scheme: option) -> result; + + /// Get the HTTP Authority for the Request. A value of `none` may be used + /// with Related Schemes which do not require an Authority. The HTTP and + /// HTTPS schemes always require an authority. + authority: func() -> option; + /// Set the HTTP Authority for the Request. A value of `none` may be used + /// with Related Schemes which do not require an Authority. The HTTP and + /// HTTPS schemes always require an authority. Fails if the string given is + /// not a syntactically valid uri authority. + set-authority: func(authority: option) -> result; + + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transfered to + /// another component by e.g. `outgoing-handler.handle`. + headers: func() -> headers; + } + + /// Parameters for making an HTTP Request. Each of these parameters is + /// currently an optional timeout applicable to the transport layer of the + /// HTTP protocol. + /// + /// These timeouts are separate from any the user may use to bound a + /// blocking call to `wasi:io/poll.poll`. + resource request-options { + /// Construct a default `request-options` value. + constructor(); + + /// The timeout for the initial connect to the HTTP Server. + connect-timeout: func() -> option; + + /// Set the timeout for the initial connect to the HTTP Server. An error + /// return value indicates that this timeout is not supported. + set-connect-timeout: func(duration: option) -> result; + + /// The timeout for receiving the first byte of the Response body. + first-byte-timeout: func() -> option; + + /// Set the timeout for receiving the first byte of the Response body. An + /// error return value indicates that this timeout is not supported. + set-first-byte-timeout: func(duration: option) -> result; + + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + between-bytes-timeout: func() -> option; + + /// Set the timeout for receiving subsequent chunks of bytes in the Response + /// body stream. An error return value indicates that this timeout is not + /// supported. + set-between-bytes-timeout: func(duration: option) -> result; + } + + /// Represents the ability to send an HTTP Response. + /// + /// This resource is used by the `wasi:http/incoming-handler` interface to + /// allow a Response to be sent corresponding to the Request provided as the + /// other argument to `incoming-handler.handle`. + resource response-outparam { + + /// Set the value of the `response-outparam` to either send a response, + /// or indicate an error. + /// + /// This method consumes the `response-outparam` to ensure that it is + /// called at most once. If it is never called, the implementation + /// will respond with an error. + /// + /// The user may provide an `error` to `response` to allow the + /// implementation determine how to respond with an HTTP error response. + set: static func( + param: response-outparam, + response: result, + ); + } + + /// This type corresponds to the HTTP standard Status Code. + type status-code = u16; + + /// Represents an incoming HTTP Response. + resource incoming-response { + + /// Returns the status code from the incoming response. + status: func() -> status-code; + + /// Returns the headers from the incoming response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `incoming-response` is dropped. + headers: func() -> headers; + + /// Returns the incoming body. May be called at most once. Returns error + /// if called additional times. + consume: func() -> result; + } + + /// Represents an incoming HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, indicating that the full contents of the + /// body have been received. This resource represents the contents as + /// an `input-stream` and the delivery of trailers as a `future-trailers`, + /// and ensures that the user of this interface may only be consuming either + /// the body contents or waiting on trailers at any given time. + resource incoming-body { + + /// Returns the contents of the body, as a stream of bytes. + /// + /// Returns success on first call: the stream representing the contents + /// can be retrieved at most once. Subsequent calls will return error. + /// + /// The returned `input-stream` resource is a child: it must be dropped + /// before the parent `incoming-body` is dropped, or consumed by + /// `incoming-body.finish`. + /// + /// This invariant ensures that the implementation can determine whether + /// the user is consuming the contents of the body, waiting on the + /// `future-trailers` to be ready, or neither. This allows for network + /// backpressure is to be applied when the user is consuming the body, + /// and for that backpressure to not inhibit delivery of the trailers if + /// the user does not read the entire body. + %stream: func() -> result; + + /// Takes ownership of `incoming-body`, and returns a `future-trailers`. + /// This function will trap if the `input-stream` child is still alive. + finish: static func(this: incoming-body) -> future-trailers; + } + + /// Represents a future which may eventaully return trailers, or an error. + /// + /// In the case that the incoming HTTP Request or Response did not have any + /// trailers, this future will resolve to the empty set of trailers once the + /// complete Request or Response body has been received. + resource future-trailers { + + /// Returns a pollable which becomes ready when either the trailers have + /// been received, or an error has occured. When this pollable is ready, + /// the `get` method will return `some`. + subscribe: func() -> pollable; + + /// Returns the contents of the trailers, or an error which occured, + /// once the future is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the trailers or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the HTTP Request or Response + /// body, as well as any trailers, were received successfully, or that an + /// error occured receiving them. The optional `trailers` indicates whether + /// or not trailers were present in the body. + /// + /// When some `trailers` are returned by this method, the `trailers` + /// resource is immutable, and a child. Use of the `set`, `append`, or + /// `delete` methods will return an error, and the resource must be + /// dropped before the parent `future-trailers` is dropped. + get: func() -> option, error-code>>>; + } + + /// Represents an outgoing HTTP Response. + resource outgoing-response { + + /// Construct an `outgoing-response`, with a default `status-code` of `200`. + /// If a different `status-code` is needed, it must be set via the + /// `set-status-code` method. + /// + /// * `headers` is the HTTP Headers for the Response. + constructor(headers: headers); + + /// Get the HTTP Status Code for the Response. + status-code: func() -> status-code; + + /// Set the HTTP Status Code for the Response. Fails if the status-code + /// given is not a valid http status code. + set-status-code: func(status-code: status-code) -> result; + + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transfered to + /// another component by e.g. `outgoing-handler.handle`. + headers: func() -> headers; + + /// Returns the resource corresponding to the outgoing Body for this Response. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-response` can be retrieved at most once. Subsequent + /// calls will return error. + body: func() -> result; + } + + /// Represents an outgoing HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, inducating the full contents of the body + /// have been sent. This resource represents the contents as an + /// `output-stream` child resource, and the completion of the body (with + /// optional trailers) with a static function that consumes the + /// `outgoing-body` resource, and ensures that the user of this interface + /// may not write to the body contents after the body has been finished. + /// + /// If the user code drops this resource, as opposed to calling the static + /// method `finish`, the implementation should treat the body as incomplete, + /// and that an error has occured. The implementation should propogate this + /// error to the HTTP protocol by whatever means it has available, + /// including: corrupting the body on the wire, aborting the associated + /// Request, or sending a late status code for the Response. + resource outgoing-body { + + /// Returns a stream for writing the body contents. + /// + /// The returned `output-stream` is a child resource: it must be dropped + /// before the parent `outgoing-body` resource is dropped (or finished), + /// otherwise the `outgoing-body` drop or `finish` will trap. + /// + /// Returns success on the first call: the `output-stream` resource for + /// this `outgoing-body` may be retrieved at most once. Subsequent calls + /// will return error. + write: func() -> result; + + /// Finalize an outgoing body, optionally providing trailers. This must be + /// called to signal that the response is complete. If the `outgoing-body` + /// is dropped without calling `outgoing-body.finalize`, the implementation + /// should treat the body as corrupted. + /// + /// Fails if the body's `outgoing-request` or `outgoing-response` was + /// constructed with a Content-Length header, and the contents written + /// to the body (via `write`) does not match the value given in the + /// Content-Length. + finish: static func( + this: outgoing-body, + trailers: option + ) -> result<_, error-code>; + } + + /// Represents a future which may eventaully return an incoming HTTP + /// Response, or an error. + /// + /// This resource is returned by the `wasi:http/outgoing-handler` interface to + /// provide the HTTP Response corresponding to the sent Request. + resource future-incoming-response { + /// Returns a pollable which becomes ready when either the Response has + /// been received, or an error has occured. When this pollable is ready, + /// the `get` method will return `some`. + subscribe: func() -> pollable; + + /// Returns the incoming HTTP Response, or an error, once one is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the response or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the incoming HTTP Response + /// status and headers have recieved successfully, or that an error + /// occured. Errors may also occur while consuming the response body, + /// but those will be reported by the `incoming-body` and its + /// `output-stream` child. + get: func() -> option>>; + + } +}