Skip to content

Commit 93f1a4d

Browse files
authored
servlet: unsplash (zig) (#59)
* servlet: unsplash Signed-off-by: Edoardo Vacchi <evacchi@users.noreply.github.com> * ci: add zig support Signed-off-by: Edoardo Vacchi <evacchi@users.noreply.github.com> * fix schema * update gitignore Signed-off-by: Edoardo Vacchi <evacchi@users.noreply.github.com> * externalize tool schemas Signed-off-by: Edoardo Vacchi <evacchi@users.noreply.github.com> --------- Signed-off-by: Edoardo Vacchi <evacchi@users.noreply.github.com>
1 parent a57e205 commit 93f1a4d

File tree

11 files changed

+650
-0
lines changed

11 files changed

+650
-0
lines changed

.github/workflows/cd.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ jobs:
4848
curl -LsSf https://astral.sh/uv/install.sh | sh
4949
curl -Ls https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash
5050
51+
- name: Install Zig
52+
uses: mlugg/setup-zig@v1
53+
with:
54+
version: 0.13.0
55+
5156
- name: Install xtp CLI
5257
run: |
5358
curl -L https://static.dylibso.com/cli/install.sh -s | bash

.github/workflows/ci.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ jobs:
4848
curl -LsSf https://astral.sh/uv/install.sh | sh
4949
curl -Ls https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash
5050
51+
- name: Install Zig
52+
uses: mlugg/setup-zig@v1
53+
with:
54+
version: 0.13.0
55+
5156
- name: Install xtp CLI
5257
run: |
5358
curl -L https://static.dylibso.com/cli/install.sh -s | bash

servlets/unsplash/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
zig-out/
2+
.zig-cache/**

servlets/unsplash/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Unsplash Servlet
2+
3+
[Unsplash](https://unsplash.com) is a collection of high-quality photos.
4+
This servlet allows you to search and retrieve pictures from the archive.
5+
Use in conjuction with the `fetch` servlet to fetch and display the URLs.
6+
7+
The servlet serves also as a tutorial for Zig support.
8+
9+
## Config
10+
11+
`API_KEY`: Your "Access Key" on https://unsplash.com/oauth/applications
12+
13+
## Hosts
14+
15+
- You should allow access to `api.unsplash.com`.
16+
17+
## Example:
18+
19+
20+
fetch a photo of Milan, Piazza Duomo
21+
22+
> I'll help you fetch a photo of Milan's Piazza del Duomo using the Unsplash API
23+
>
24+
>> (uses `unsplash`)
25+
>
26+
> Let me fetch this specific photo to show you:
27+
>
28+
>> (uses `fetch`)
29+
>
30+
> This is a stunning aerial view of Milan's Piazza del Duomo, taken from one of the upper levels of the Cathedral (Duomo di Milano). The image captures the intricate Gothic architecture of the Duomo on the left, with its elaborate spires and statues, while showing the expansive square below filled with people. On the right side, you can see the elegant Galleria Vittorio Emanuele II, one of the world's oldest shopping galleries, with its distinctive neoclassical architecture. The photo provides a great perspective on how this historic square serves as the heart of Milan, where Gothic architecture meets classical design and modern city life.
31+
32+
The retrieved image: https://unsplash.com/photos/people-on-street-near-concrete-buildings-BBigUtobEYo

servlets/unsplash/build.zig

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const std = @import("std");
2+
3+
// if you're not using WASI, change this to .freestanding
4+
const BUILD_TARGET_OS = .wasi;
5+
6+
pub fn build(b: *std.Build) !void {
7+
const optimize = b.standardOptimizeOption(.{});
8+
const target = b.standardTargetOptions(.{
9+
.default_target = .{ .abi = .musl, .os_tag = BUILD_TARGET_OS, .cpu_arch = .wasm32 },
10+
});
11+
const pdk_module = b.dependency("extism-pdk", .{ .target = target, .optimize = optimize }).module("extism-pdk");
12+
13+
var plugin = b.addExecutable(.{
14+
.name = "plugin",
15+
.root_source_file = b.path("src/pdk.zig"),
16+
.target = target,
17+
.optimize = optimize,
18+
});
19+
if (BUILD_TARGET_OS == .wasi) {
20+
plugin.wasi_exec_model = .reactor;
21+
}
22+
plugin.rdynamic = true;
23+
plugin.entry = .disabled; // or add an empty `pub fn main() void {}` to your code
24+
plugin.root_module.addImport("extism-pdk", pdk_module);
25+
b.installArtifact(plugin);
26+
}

servlets/unsplash/build.zig.zon

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
.{
2+
.name = "unsplash",
3+
// This is a [Semantic Version](https://semver.org/).
4+
// In a future version of Zig it will be used for package deduplication.
5+
.version = "0.0.0",
6+
7+
// This field is optional.
8+
// This is currently advisory only; Zig does not yet do anything
9+
// with this value.
10+
//.minimum_zig_version = "0.11.0",
11+
12+
// This field is optional.
13+
// Each dependency must either provide a `url` and `hash`, or a `path`.
14+
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
15+
// Once all dependencies are fetched, `zig build` no longer requires
16+
// internet connectivity.
17+
.dependencies = .{
18+
.@"extism-pdk" = .{
19+
.url = "https://github.com/extism/zig-pdk/archive/refs/tags/v1.3.0.tar.gz",
20+
.hash = "1220d21f918a4e96d7ccea385d7609d97c39430d027997f82121e3fbae273e4d4c06",
21+
},
22+
},
23+
.paths = .{
24+
// This makes *all* files, recursively, included in this package. It is generally
25+
// better to explicitly list the files and directories instead, to insure that
26+
// fetching from tarballs, file system paths, and version control all result
27+
// in the same contents hash.
28+
"",
29+
// For example...
30+
//"build.zig",
31+
//"build.zig.zon",
32+
//"src",
33+
//"LICENSE",
34+
//"README.md",
35+
},
36+
}

servlets/unsplash/src/main.zig

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
const schema = @import("schema.zig");
2+
const extism = @import("extism-pdk");
3+
const http = extism.http;
4+
const plugin = @import("pdk.zig")._plugin;
5+
const std = @import("std");
6+
const json = std.json;
7+
const eql = std.mem.eql;
8+
const allocPrint = std.fmt.allocPrint;
9+
const allocator = std.heap.wasm_allocator;
10+
11+
/// Called when the tool is invoked.
12+
/// If you support multiple tools, you must switch on the input.params.name to detect which tool is being called.
13+
/// It takes CallToolRequest as input (The incoming tool request from the LLM)
14+
/// And returns CallToolResult (The servlet's response to the given tool call)
15+
pub fn call(input: schema.CallToolRequest) !schema.CallToolResult {
16+
const name = input.params.name;
17+
if (eql(u8, name, "photos")) {
18+
return getPhotos(input.params.arguments);
19+
} else if (eql(u8, name, "photos_id")) {
20+
return getPhotosId(input.params.arguments);
21+
} else if (eql(u8, name, "search_photos")) {
22+
return searchPhotos(input.params.arguments);
23+
}
24+
return error.PluginFunctionNotImplemented;
25+
}
26+
27+
fn getPhotos(args: ?json.ArrayHashMap(std.json.Value)) !schema.CallToolResult {
28+
const apiKey = try plugin.getConfig("API_KEY") orelse return error.MissingConfig;
29+
const page = args.?.map.get("page") orelse json.Value{ .integer = 1 };
30+
const per_page = args.?.map.get("per_page") orelse json.Value{ .integer = 10 };
31+
const url = try allocPrint(
32+
allocator,
33+
"https://api.unsplash.com/photos?page={d}&per_page={d}",
34+
.{ page.integer, per_page.integer },
35+
);
36+
var req = http.HttpRequest.init("GET", url);
37+
defer req.deinit(allocator);
38+
try req.setHeader(
39+
allocator,
40+
"Authorization",
41+
try allocPrint(allocator, "Client-ID {s}", .{apiKey}),
42+
);
43+
const resp = try plugin.request(req, null);
44+
const body = try resp.body(allocator);
45+
if (resp.status != 200) {
46+
return callToolError(try allocPrint(
47+
allocator,
48+
"Error {d}: {s}",
49+
.{ resp.status, body },
50+
));
51+
}
52+
return schema.CallToolResult{
53+
.content = try allocator.dupe(schema.Content, &.{.{
54+
.type = schema.ContentType.text,
55+
.text = body,
56+
}}),
57+
};
58+
}
59+
60+
fn getPhotosId(arguments: ?json.ArrayHashMap(std.json.Value)) !schema.CallToolResult {
61+
const apiKey = try plugin.getConfig("API_KEY") orelse return error.MissingConfig;
62+
const args = arguments orelse return callToolError("missing arguments");
63+
const id = args.map.get("id") orelse return error.MissingArgument;
64+
const url = try allocPrint(
65+
allocator,
66+
"https://api.unsplash.com/photos/{s}",
67+
.{id.string},
68+
);
69+
var req = http.HttpRequest.init("GET", url);
70+
defer req.deinit(allocator);
71+
try req.setHeader(
72+
allocator,
73+
"Authorization",
74+
try allocPrint(allocator, "Client-ID {s}", .{apiKey}),
75+
);
76+
const resp = try plugin.request(req, null);
77+
const body = try resp.body(allocator);
78+
if (resp.status != 200) {
79+
return callToolError(try allocPrint(
80+
allocator,
81+
"Error {d}: {s}",
82+
.{ resp.status, body },
83+
));
84+
}
85+
return schema.CallToolResult{
86+
.content = try allocator.dupe(schema.Content, &.{.{
87+
.type = schema.ContentType.text,
88+
.text = body,
89+
}}),
90+
};
91+
}
92+
93+
// query Search terms.
94+
// page Page number to retrieve. (Optional; default: 1)
95+
// per_page Number of items per page. (Optional; default: 10)
96+
// order_by How to sort the photos. (Optional; default: relevant). Valid values are latest and relevant.
97+
// collections Collection ID(‘s) to narrow search. Optional. If multiple, comma-separated.
98+
// content_filter Limit results by content safety. (Optional; default: low). Valid values are low and high.
99+
// color Filter results by color. Optional. Valid values are: black_and_white, black, white, yellow, orange, red, purple, magenta, green, teal, and blue.
100+
// orientation Filter by photo orientation. Optional. (Valid values: landscape, portrait, squarish)
101+
102+
fn searchPhotos(arguments: ?json.ArrayHashMap(std.json.Value)) !schema.CallToolResult {
103+
const apiKey = try plugin.getConfig("API_KEY") orelse return error.MissingConfig;
104+
const args = arguments orelse return callToolError("missing arguments");
105+
const query = args.map.get("query") orelse return callToolError("missing query");
106+
const page = args.map.get("page") orelse json.Value{ .integer = 1 };
107+
const per_page = args.map.get("per_page") orelse json.Value{ .integer = 10 };
108+
const order_by = args.map.get("order_by") orelse json.Value{ .string = "relevant" };
109+
const content_filter = args.map.get("content_filter") orelse json.Value{ .string = "low" };
110+
var color: []u8 = "";
111+
if (args.map.get("color") != null) {
112+
const c = args.map.get("color").?.string;
113+
color = try allocPrint(allocator, "&color={s}", .{c});
114+
}
115+
var orientation: []u8 = "";
116+
if (args.map.get("orientation") != null) {
117+
const c = args.map.get("orientation").?.string;
118+
orientation = try allocPrint(allocator, "&orientation={s}", .{c});
119+
}
120+
const url = try allocPrint(
121+
allocator,
122+
"https://api.unsplash.com/search/photos?page={d}&per_page={d}&order_by={s}&content_filter={s}{s}{s}&query={s}",
123+
.{ page.integer, per_page.integer, order_by.string, content_filter.string, color, orientation, query.string },
124+
);
125+
var req = http.HttpRequest.init("GET", url);
126+
defer req.deinit(allocator);
127+
try req.setHeader(
128+
allocator,
129+
"Authorization",
130+
try allocPrint(allocator, "Client-ID {s}", .{apiKey}),
131+
);
132+
const resp = try plugin.request(req, null);
133+
const body = try resp.body(allocator);
134+
if (resp.status != 200) {
135+
return callToolError(try allocPrint(
136+
allocator,
137+
"Error {d}: {s}",
138+
.{ resp.status, body },
139+
));
140+
}
141+
return schema.CallToolResult{
142+
.content = try allocator.dupe(schema.Content, &.{.{
143+
.type = schema.ContentType.text,
144+
.text = body,
145+
}}),
146+
};
147+
}
148+
149+
fn callToolError(msg: []const u8) !schema.CallToolResult {
150+
return schema.CallToolResult{
151+
.isError = true,
152+
.content = try allocator.dupe(schema.Content, &.{.{
153+
.type = schema.ContentType.text,
154+
.text = msg,
155+
}}),
156+
};
157+
}
158+
159+
pub fn describe() !schema.ListToolsResult {
160+
const tools = @embedFile("tools.json");
161+
const r = try json.parseFromSlice(schema.ListToolsResult, allocator, tools, .{});
162+
return r.value;
163+
}

servlets/unsplash/src/pdk.zig

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// THIS FILE WAS GENERATED BY `xtp-zig-bindgen`. DO NOT EDIT.
2+
const std = @import("std");
3+
const extism = @import("extism-pdk");
4+
5+
const user = @import("main.zig");
6+
const schema = @import("schema.zig");
7+
8+
pub const _plugin = extism.Plugin.init(std.heap.wasm_allocator);
9+
10+
const ERR_PRINTING_MSG: []const u8 = "std.fmt.allocPrint failed when formatting plugin error";
11+
12+
export fn call() i32 {
13+
// Get the input data
14+
// in JSON
15+
const json_input = _plugin.getJsonOpt(schema.CallToolRequest, .{}) catch |err| {
16+
const msg = std.fmt.allocPrint(_plugin.allocator, "{}", .{err}) catch ERR_PRINTING_MSG;
17+
_plugin.setError(msg);
18+
return -1;
19+
};
20+
defer json_input.deinit();
21+
22+
var input = json_input.value();
23+
// decode all the inner buffer fields from base64 (may be no-op)
24+
input = (input.XXX__decodeBase64Fields() catch |err| {
25+
const msg = std.fmt.allocPrint(_plugin.allocator, "{}", .{err}) catch ERR_PRINTING_MSG;
26+
_plugin.setError(msg);
27+
return -1;
28+
}).*;
29+
30+
// Call the implementation function
31+
const output = user.call(input) catch |err| {
32+
const msg = std.fmt.allocPrint(_plugin.allocator, "{}", .{err}) catch ERR_PRINTING_MSG;
33+
_plugin.setError(msg);
34+
return -1;
35+
};
36+
37+
var json_output = output;
38+
// encode all the inner buffer fields to base64 (may be no-op)
39+
json_output = (json_output.XXX__encodeBase64Fields() catch |err| {
40+
const msg = std.fmt.allocPrint(_plugin.allocator, "{}", .{err}) catch ERR_PRINTING_MSG;
41+
_plugin.setError(msg);
42+
return -1;
43+
}).*;
44+
_plugin.outputJson(json_output, .{}) catch |err| {
45+
const msg = std.fmt.allocPrint(_plugin.allocator, "{}", .{err}) catch ERR_PRINTING_MSG;
46+
_plugin.setError(msg);
47+
return -1;
48+
};
49+
return 0;
50+
}
51+
52+
export fn describe() i32 {
53+
const output = user.describe() catch |err| {
54+
const msg = std.fmt.allocPrint(_plugin.allocator, "{}", .{err}) catch ERR_PRINTING_MSG;
55+
_plugin.setError(msg);
56+
return -1;
57+
};
58+
59+
var json_output = output;
60+
// encode all the inner buffer fields to base64 (may be no-op)
61+
json_output = (json_output.XXX__encodeBase64Fields() catch |err| {
62+
const msg = std.fmt.allocPrint(_plugin.allocator, "{}", .{err}) catch ERR_PRINTING_MSG;
63+
_plugin.setError(msg);
64+
return -1;
65+
}).*;
66+
_plugin.outputJson(json_output, .{}) catch |err| {
67+
const msg = std.fmt.allocPrint(_plugin.allocator, "{}", .{err}) catch ERR_PRINTING_MSG;
68+
_plugin.setError(msg);
69+
return -1;
70+
};
71+
return 0;
72+
}

0 commit comments

Comments
 (0)