diff --git a/servlets/historical-flight-api/.gitignore b/servlets/historical-flight-api/.gitignore new file mode 100644 index 0000000..7773828 --- /dev/null +++ b/servlets/historical-flight-api/.gitignore @@ -0,0 +1 @@ +dist/ \ No newline at end of file diff --git a/servlets/historical-flight-api/README.md b/servlets/historical-flight-api/README.md new file mode 100644 index 0000000..7c88502 --- /dev/null +++ b/servlets/historical-flight-api/README.md @@ -0,0 +1,58 @@ +# Historical Flight API Servlet + +Queries flight APIs for info about past flights, +using the following services: + +- https://opensky-network.org +- https://adsbdb.com +- https://airport-data.com + +Note: + +> In OpenSky, Flights are updated by a batch process at night, +> i.e., only flights from the previous day or earlier are available using this endpoint. + +OpenSky requires a username, password pair, you can easily get +with [a free account](https://opensky-network.org/login?view=registration). + +## Configuration + +- The servlet expects the config keys `username` `password` to be provided. +- It requires network access to the following domains: + - opensky-network.org + - api.adsbdb.com + - airport-data.com + +## Usage + +We expose the [OpenSky `arrival` and `departure` endpoints](https://openskynetwork.github.io/opensky-api/rest.html) +following their structure, except we also require a `requestType` field: + +```json +{ + "requestType": "departure", + "airport": "LIMC", + "begin": "1701428400", + "end": "1701435600" +} +``` + +Where `airport` is the ICAO code for the airport, and `begin`, `end` are UNIX timestamps +at UTC. + +The return value is the contents of the return value for such endpoints. + +We also expose the `aircraft` endpoint from [adsbdb.com](https://www.adsbdb.com). +This requires the `icao24` identifier of the aircraft, and its `callsign`, +which are always returned as part of the `arrival` and `departure` responses. The request looks like: + +```json +{ + "icao24": "440170", + "callsign": "EJU73BJ" +} +``` + +The result contains the returned value from the [adsbdb.com](https://www.adsbdb.com) +endpoint; we also automatically fetch and return the aircraft picture returned +in the [adsbdb.com](https://www.adsbdb.com) response in the `url_photo` field. diff --git a/servlets/historical-flight-api/go.mod b/servlets/historical-flight-api/go.mod new file mode 100755 index 0000000..f5cd493 --- /dev/null +++ b/servlets/historical-flight-api/go.mod @@ -0,0 +1,5 @@ +module historical-flight-api + +go 1.22.1 + +require github.com/extism/go-pdk v1.0.5 diff --git a/servlets/historical-flight-api/go.sum b/servlets/historical-flight-api/go.sum new file mode 100644 index 0000000..e3bfb8b --- /dev/null +++ b/servlets/historical-flight-api/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.0.5 h1:5d5yYkWBweBP84Z+H3DP5DsD0fwvf2anWXyypCXpSW8= +github.com/extism/go-pdk v1.0.5/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/servlets/historical-flight-api/main.go b/servlets/historical-flight-api/main.go new file mode 100755 index 0000000..29f539f --- /dev/null +++ b/servlets/historical-flight-api/main.go @@ -0,0 +1,197 @@ +// Note: run `go doc -all` in this package to see all of the types and functions available. +// ./pdk.gen.go contains the domain types from the host where your plugin will run. +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + + "github.com/extism/go-pdk" +) + +const ( + apiOpenSkyBaseUrl = "https://opensky-network.org/api" + apiOpenSkyArrival = "/flights/arrival" + apiOpenSkyDeparture = "/flights/departure" + + apiAdsbdbBaseUrl = "https://api.adsbdb.com/v0" + apiAdsbdbCallsign = "/callsign" + apiAdsbdbAircraft = "/aircraft" +) + +var ( + basicAuthToken string +) + +// Called when the tool is invoked. +// It takes CallToolRequest as input (The incoming tool request from the LLM) +// And returns CallToolResult (The servlet's response to the given tool call) +func Call(input CallToolRequest) (res CallToolResult, err error) { + if err = loadBasicAuthToken(); err != nil { + return CallToolResult{ + IsError: some(true), + Content: []Content{{ + Type: ContentTypeText, + Text: some(err.Error()), + }}, + }, nil + } + + args := input.Params.Arguments.(map[string]interface{}) + + requestType, _ := args["requestType"].(string) + + var result string + switch requestType { + case "arrival", "departure": + var airport = args["airport"].(string) + var begin = args["begin"].(string) + var end = args["end"].(string) + + // send the request, get response back (can check status on response via res.Status()) + result, err = flightInfo(requestType, airport, begin, end) + if err != nil { + return + } + return CallToolResult{ + Content: []Content{{ + Type: ContentTypeText, + Text: some(result), + }}, + }, nil + default: + var icao24 = args["icao24"].(string) + var callsign = args["callsign"].(string) + bytes := aircraft(icao24, callsign) + imgData := fetchImage(bytes) + + return CallToolResult{ + Content: []Content{{ + Type: ContentTypeImage, + MimeType: some("image/jpeg"), + Data: some(base64.StdEncoding.EncodeToString(imgData)), + }, { + Type: ContentTypeText, + Text: some(string(bytes)), + }}}, nil + } +} + +func loadBasicAuthToken() error { + if basicAuthToken != "" { + return nil + } + + user, uok := pdk.GetConfig("username") + pass, pok := pdk.GetConfig("password") + if !uok || !pok { + return errors.New("username or password not set") + } + basicAuthToken = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))) + + auth := user + ":" + pass + basicAuthToken = "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) + return nil +} + +func flightInfo(requestType string, airport string, begin string, end string) (string, error) { + query := &url.Values{} + query.Add("airport", airport) + query.Add("begin", begin) + query.Add("end", end) + + var path string + switch requestType { + case "arrival": + path = apiOpenSkyArrival + case "departure": + path = apiOpenSkyDeparture + default: + return "", errors.New("Invalid type") + } + + req := pdk.NewHTTPRequest(pdk.MethodGet, apiOpenSkyBaseUrl+path+"?"+query.Encode()) + req.SetHeader("Authorization", basicAuthToken) + + res := req.Send() + return string(res.Body()), nil +} + +func aircraft(modeS, callsign string) []byte { + req := pdk.NewHTTPRequest(pdk.MethodGet, apiAdsbdbBaseUrl+apiAdsbdbAircraft+"/"+modeS+"?callsign="+callsign) + res := req.Send() + return res.Body() +} + +func fetchImage(aircraftResponse []byte) []byte { + jsonData := map[string]interface{}{} + json.Unmarshal(aircraftResponse, &jsonData) + if response, ok := jsonData["response"].(map[string]interface{}); ok { + if aircraft, ok := response["aircraft"].(map[string]interface{}); ok { + if urlPhoto, ok := aircraft["url_photo"].(string); ok { + data := pdk.NewHTTPRequest(pdk.MethodGet, urlPhoto).Send() + return data.Body() + } + } + } + return nil +} + +func Describe() (ListToolsResult, error) { + return ListToolsResult{Tools: []ToolDescription{{ + Name: "historical-flight-api", + Description: "Get the flight arrivals and departures for a given airport by ICAO identifier within a given time range; or get the details and picture of a flight by callsign and ICAO24 hex code.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "requestType": map[string]interface{}{ + "type": "string", + "description": "The type of the request, 'departure', 'arrival' or 'aircraft'", + }, + }, + "required": []string{"requestType"}, + "oneOf": []interface{}{ + map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "airport": map[string]interface{}{ + "type": "string", + "description": "The ICAO identifier of the airport", + }, + "begin": map[string]interface{}{ + "type": "string", + "description": "The start of the time range as a UNIX timestamp in UTC", + }, + "end": map[string]interface{}{ + "type": "string", + "description": "The end of the time range as a UNIX timestamp in UTC", + }, + }, + "required": []string{"airport", "begin", "end"}, + }, + map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "callsign": map[string]interface{}{ + "type": "string", + "description": "The callsign of the flight", + }, + "icao24": map[string]interface{}{ + "type": "string", + "description": "The aircraft as ICAO24 hex code", + }, + }, + "required": []string{"callsign", "icao24"}, + }, + }, + }, + }}}, nil +} + +// box the value to return a nil-able reference +func some[T any](t T) *T { + return &t +} diff --git a/servlets/historical-flight-api/pdk.gen.go b/servlets/historical-flight-api/pdk.gen.go new file mode 100755 index 0000000..712f74e --- /dev/null +++ b/servlets/historical-flight-api/pdk.gen.go @@ -0,0 +1,221 @@ +// THIS FILE WAS GENERATED BY `xtp-go-bindgen`. DO NOT EDIT. +package main + +import ( + "errors" + + pdk "github.com/extism/go-pdk" +) + +//export call +func _Call() int32 { + var err error + _ = err + pdk.Log(pdk.LogDebug, "Call: getting JSON input") + var input CallToolRequest + err = pdk.InputJSON(&input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "Call: calling implementation function") + output, err := Call(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "Call: setting JSON output") + err = pdk.OutputJSON(output) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "Call: returning") + return 0 +} + +//export describe +func _Describe() int32 { + var err error + _ = err + output, err := Describe() + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "Describe: setting JSON output") + err = pdk.OutputJSON(output) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "Describe: returning") + return 0 +} + +// +type BlobResourceContents struct { + // A base64-encoded string representing the binary data of the item. + Blob string `json:"blob"` + // The MIME type of this resource, if known. + MimeType *string `json:"mimeType,omitempty"` + // The URI of this resource. + Uri string `json:"uri"` +} + +// Used by the client to invoke a tool provided by the server. +type CallToolRequest struct { + Method *string `json:"method,omitempty"` + Params Params `json:"params"` +} + +// The server's response to a tool call. +// +// Any errors that originate from the tool SHOULD be reported inside the result +// object, with `isError` set to true, _not_ as an MCP protocol-level error +// response. Otherwise, the LLM would not be able to see that an error occurred +// and self-correct. +// +// However, any errors in _finding_ the tool, an error indicating that the +// server does not support tool calls, or any other exceptional conditions, +// should be reported as an MCP error response. +type CallToolResult struct { + Content []Content `json:"content"` + // Whether the tool call ended in an error. + // + // If not set, this is assumed to be false (the call was successful). + IsError *bool `json:"isError,omitempty"` +} + +// A content response. +// For text content set type to ContentType.Text and set the `text` property +// For image content set type to ContentType.Image and set the `data` and `mimeType` properties +type Content struct { + Annotations *TextAnnotation `json:"annotations,omitempty"` + // The base64-encoded image data. + Data *string `json:"data,omitempty"` + // The MIME type of the image. Different providers may support different image types. + MimeType *string `json:"mimeType,omitempty"` + // The text content of the message. + Text *string `json:"text,omitempty"` + Type ContentType `json:"type"` +} + +// +type ContentType string + +const ( + ContentTypeText ContentType = "text" + ContentTypeImage ContentType = "image" + ContentTypeResource ContentType = "resource" +) + +func (v ContentType) String() string { + switch v { + case ContentTypeText: + return `text` + case ContentTypeImage: + return `image` + case ContentTypeResource: + return `resource` + default: + return "" + } +} + +func stringToContentType(s string) (ContentType, error) { + switch s { + case `text`: + return ContentTypeText, nil + case `image`: + return ContentTypeImage, nil + case `resource`: + return ContentTypeResource, nil + default: + return ContentType(""), errors.New("unable to convert string to ContentType") + } +} + +// Provides one or more descriptions of the tools available in this servlet. +type ListToolsResult struct { + // The list of ToolDescription objects provided by this servlet. + Tools []ToolDescription `json:"tools"` +} + +// +type Params struct { + Arguments interface{} `json:"arguments,omitempty"` + Name string `json:"name"` +} + +// The sender or recipient of messages and data in a conversation. +type Role string + +const ( + RoleAssistant Role = "assistant" + RoleUser Role = "user" +) + +func (v Role) String() string { + switch v { + case RoleAssistant: + return `assistant` + case RoleUser: + return `user` + default: + return "" + } +} + +func stringToRole(s string) (Role, error) { + switch s { + case `assistant`: + return RoleAssistant, nil + case `user`: + return RoleUser, nil + default: + return Role(""), errors.New("unable to convert string to Role") + } +} + +// A text annotation +type TextAnnotation struct { + // Describes who the intended customer of this object or data is. + // + // It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + Audience []Role `json:"audience,omitempty"` + // Describes how important this data is for operating the server. + // + // A value of 1 means "most important," and indicates that the data is + // effectively required, while 0 means "least important," and indicates that + // the data is entirely optional. + Priority float32 `json:"priority,omitempty"` +} + +// +type TextResourceContents struct { + // The MIME type of this resource, if known. + MimeType *string `json:"mimeType,omitempty"` + // The text of the item. This must only be set if the item can actually be represented as text (not binary data). + Text string `json:"text"` + // The URI of this resource. + Uri string `json:"uri"` +} + +// Describes the capabilities and expected paramters of the tool function +type ToolDescription struct { + // A description of the tool + Description string `json:"description"` + // The JSON schema describing the argument input + InputSchema interface{} `json:"inputSchema"` + // The name of the tool. It should match the plugin / binding name. + Name string `json:"name"` +} + +// Note: leave this in place, as the Go compiler will find the `export` function as the entrypoint. +func main() {} diff --git a/servlets/historical-flight-api/prepare.sh b/servlets/historical-flight-api/prepare.sh new file mode 100644 index 0000000..ec08baf --- /dev/null +++ b/servlets/historical-flight-api/prepare.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +# Function to check if a command exists +command_exists () { + command -v "$1" >/dev/null 2>&1 +} + +missing_deps=0 + +# Check for Go +if ! (command_exists go); then + missing_deps=1 + echo "❌ Go (supported version between 1.18 - 1.22) is not installed." + echo "" + echo "To install Go, visit the official download page:" + echo "👉 https://go.dev/dl/" + echo "" + echo "Or install it using a package manager:" + echo "" + echo "🔹 macOS (Homebrew):" + echo " brew install go" + echo "" + echo "🔹 Ubuntu/Debian:" + echo " sudo apt-get -y install golang-go" + echo "" + echo "🔹 Arch Linux:" + echo " sudo pacman -S go" + echo "" +fi + +# Check for the right version of Go, needed by TinyGo (supports go 1.18 - 1.22) +if (command_exists go); then + compat=0 + for v in `seq 18 22`; do + if (go version | grep -q "go1.$v"); then + compat=1 + fi + done + + if [ $compat -eq 0 ]; then + echo "❌ Supported Go version is not installed. Must be Go 1.18 - 1.22." + echo "" + fi +fi + + +ARCH=$(arch) + +# Check for TinyGo +if ! (command_exists tinygo); then + missing_deps=1 + echo "❌ TinyGo is not installed." + echo "" + echo "To install TinyGo, visit the official download page:" + echo "👉 https://tinygo.org/getting-started/install/" + echo "" + echo "Or install it using a package manager:" + echo "" + echo "🔹 macOS (Homebrew):" + echo " brew tap tinygo-org/tools" + echo " brew install tinygo" + echo "" + echo "🔹 Ubuntu/Debian:" + echo " wget https://github.com/tinygo-org/tinygo/releases/download/v0.31.2/tinygo_0.31.2_$ARCH.deb" + echo " sudo dpkg -i tinygo_0.31.2_$ARCH.deb" + echo "" + echo "🔹 Arch Linux:" + echo " pacman -S extra/tinygo" + echo "" +fi + +go install golang.org/x/tools/cmd/goimports@latest diff --git a/servlets/historical-flight-api/xtp.toml b/servlets/historical-flight-api/xtp.toml new file mode 100755 index 0000000..852a9f4 --- /dev/null +++ b/servlets/historical-flight-api/xtp.toml @@ -0,0 +1,17 @@ +app_id = "app_01je4dgpcyfvgrz8f1ys3pbxas" + +# This is where 'xtp plugin push' expects to find the wasm file after the build script has run. +bin = "dist/plugin.wasm" +extension_point_id = "ext_01je4jj1tteaktf0zd0anm8854" +name = "historical-flight-api" + +[scripts] + + # xtp plugin build runs this script to generate the wasm file + build = "mkdir -p dist && tinygo build -target wasi -o dist/plugin.wasm ." + + # xtp plugin init runs this script to format the plugin code + format = "go fmt && go mod tidy && goimports -w main.go" + + # xtp plugin init runs this script before running the format script + prepare = "sh prepare.sh && go get ./..."