diff --git a/CMakeLists.txt b/CMakeLists.txt index 0c62669..3836f85 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -147,6 +147,7 @@ add_library(wolfram src/admin_typed.c src/sync_typed.c src/server_typed.c + src/agent/lexicon_typed.c src/agent/threadgate_postgate.c src/agent/moderation_actions.c src/agent/lexcall.c @@ -983,6 +984,11 @@ if(WOLFRAM_BUILD_TESTS) target_compile_definitions(test_server_typed PRIVATE WF_TEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/test/fixtures") add_test(NAME server_typed COMMAND test_server_typed) + add_executable(test_lexicon_typed test/test_lexicon_typed.c) + target_link_libraries(test_lexicon_typed PRIVATE wolfram) + target_include_directories(test_lexicon_typed PRIVATE test ${cjson_SOURCE_DIR}) + add_test(NAME lexicon_typed COMMAND test_lexicon_typed) + add_executable(test_lexcall test/test_lexcall.c) target_link_libraries(test_lexcall PRIVATE wolfram) target_include_directories(test_lexcall PRIVATE test ${cjson_SOURCE_DIR}) diff --git a/include/wolfram/lexicon_typed.h b/include/wolfram/lexicon_typed.h new file mode 100644 index 0000000..3f84d97 --- /dev/null +++ b/include/wolfram/lexicon_typed.h @@ -0,0 +1,59 @@ +/* + * lexicon_typed.h — owned typed parser + agent convenience wrapper for the + * com.atproto.lexicon.resolveLexicon query. + * + * Conventions mirror the other *_typed modules: wf_status error codes, static + * strdup/set_string/reset helpers, ownership via cJSON_DetachItem*, an owned + * `schema` cJSON subtree for the open/unbounded lexicon schema object, and a + * matching `_free` for the owned struct (a freed/zeroed struct frees safely). + * Every owned string is heap-allocated. + * + * The agent wrapper (wf_agent_resolve_lexicon_typed) validates the required + * `nsid` argument, syncs auth onto the agent's primary XRPC client, issues the + * generated lex call, then parses the body. On success the output is owned by + * the caller (free with wf_lexicon_resolved_free); on error it is left reset. + */ + +#ifndef WOLFRAM_LEXICON_TYPED_H +#define WOLFRAM_LEXICON_TYPED_H + +#include "wolfram/agent.h" + +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* com.atproto.lexicon.resolveLexicon output: { cid, schema, uri }. The full + * lexicon schema object is kept as an owned detached cJSON subtree since its + * shape is large and open. */ +typedef struct wf_lexicon_resolved { + char *cid; /* owned; required resolved record CID */ + char *uri; /* owned; required AT-URI of the schema record */ + cJSON *schema; /* owned detached schema object; NULL absent */ +} wf_lexicon_resolved; + +/* Parse a resolveLexicon JSON body ("cid", "schema", "uri"). Returns + * WF_ERR_INVALID_ARG on NULL inputs, WF_ERR_PARSE on malformed JSON or a + * missing/invalid shape, WF_ERR_ALLOC on allocation failure, WF_OK on success. + * On any error `out` is left fully reset. */ +wf_status wf_lexicon_parse_resolve(const char *json, size_t json_len, + wf_lexicon_resolved *out); + +/* Free the owned contents of a parsed resolveLexicon result (safe on reset). */ +void wf_lexicon_resolved_free(wf_lexicon_resolved *r); + +/* com.atproto.lexicon.resolveLexicon. `nsid` is the lexicon NSID to resolve + * (e.g. "app.bsky.feed.post"). */ +wf_status wf_agent_resolve_lexicon_typed(wf_agent *agent, const char *nsid, + wf_lexicon_resolved *out); + +#ifdef __cplusplus +} +#endif + +#endif /* WOLFRAM_LEXICON_TYPED_H */ diff --git a/src/agent/lexicon_typed.c b/src/agent/lexicon_typed.c new file mode 100644 index 0000000..bbc4bd3 --- /dev/null +++ b/src/agent/lexicon_typed.c @@ -0,0 +1,136 @@ +/* + * lexicon_typed.c — owned typed parser + agent convenience wrapper for the + * com.atproto.lexicon.resolveLexicon query. See include/wolfram/lexicon_typed.h + * for the public API, the authoritative wire format, and ownership rules. + * + * Mirrors the other *_typed modules: static strdup/set_string/reset helpers, + * owned strings, a detached `schema` cJSON subtree for the open lexicon schema, + * and full cleanup on the first error. The agent wrapper calls the generated + * lex wrapper directly after syncing auth via wf_agent_sync_auth. + */ + +#include "wolfram/lexicon_typed.h" + +#include "agent/_internal.h" +#include "wolfram/atproto_lex.h" + +#include + +#include +#include + +/* ---- local string/reset helpers ---- */ + +static char *wf_lexicon_strdup(const char *s) { + if (!s) { + return NULL; + } + size_t len = strlen(s); + char *copy = (char *)malloc(len + 1); + if (copy) { + memcpy(copy, s, len + 1); + } + return copy; +} + +static wf_status wf_lexicon_set_string(char **dst, const char *src) { + char *copy = wf_lexicon_strdup(src); + if (src && !copy) { + return WF_ERR_ALLOC; + } + free(*dst); + *dst = copy; + return WF_OK; +} + +static void wf_lexicon_resolved_reset(wf_lexicon_resolved *r) { + if (!r) { + return; + } + free(r->cid); + free(r->uri); + if (r->schema) { + cJSON_Delete(r->schema); + } + memset(r, 0, sizeof(*r)); +} + +wf_status wf_lexicon_parse_resolve(const char *json, size_t json_len, + wf_lexicon_resolved *out) { + if (!json || !out) { + return WF_ERR_INVALID_ARG; + } + memset(out, 0, sizeof(*out)); + + cJSON *root = cJSON_ParseWithLength(json, json_len); + if (!root) { + return WF_ERR_PARSE; + } + if (!cJSON_IsObject(root)) { + cJSON_Delete(root); + return WF_ERR_PARSE; + } + + wf_status status = WF_OK; + cJSON *cid = cJSON_GetObjectItemCaseSensitive(root, "cid"); + cJSON *uri = cJSON_GetObjectItemCaseSensitive(root, "uri"); + cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema"); + + if (cJSON_IsString(cid) && cid->valuestring) { + status = wf_lexicon_set_string(&out->cid, cid->valuestring); + } + if (status == WF_OK && cJSON_IsString(uri) && uri->valuestring) { + status = wf_lexicon_set_string(&out->uri, uri->valuestring); + } + if (status == WF_OK && schema != NULL) { + out->schema = cJSON_DetachItemFromObject(root, "schema"); + } + + if (status == WF_OK) { + /* Keep any remaining top-level fields in `extra` would require a slot; + * the schema is the only open field we surface, so just free the root + * now that its known children have been detached. */ + cJSON_DetachItemFromObject(root, "cid"); + cJSON_DetachItemFromObject(root, "uri"); + cJSON_Delete(root); + } else { + wf_lexicon_resolved_reset(out); + cJSON_Delete(root); + } + return status; +} + +void wf_lexicon_resolved_free(wf_lexicon_resolved *r) { + if (!r) { + return; + } + wf_lexicon_resolved_reset(r); +} + +wf_status wf_agent_resolve_lexicon_typed(wf_agent *agent, const char *nsid, + wf_lexicon_resolved *out) { + if (!agent || !agent->client || !nsid || !nsid[0] || !out) { + return WF_ERR_INVALID_ARG; + } + + wf_lexicon_resolved result = {0}; + wf_lex_com_atproto_lexicon_resolve_lexicon_main_params params = {0}; + params.nsid = nsid; + + wf_agent_sync_auth(agent); + wf_response res = {0}; + wf_status status = wf_lex_com_atproto_lexicon_resolve_lexicon_main_call( + agent->client, ¶ms, &res); + if (status != WF_OK) { + wf_response_free(&res); + return status; + } + status = wf_lexicon_parse_resolve(res.body, res.body_len, &result); + wf_response_free(&res); + if (status == WF_OK) { + *out = result; + } else { + wf_lexicon_resolved_free(&result); + } + return status; +} diff --git a/test/test_lexicon_typed.c b/test/test_lexicon_typed.c new file mode 100644 index 0000000..8e77b70 --- /dev/null +++ b/test/test_lexicon_typed.c @@ -0,0 +1,71 @@ +/* + * test_lexicon_typed.c — offline unit tests for the com.atproto.lexicon + * resolveLexicon owned parser and wrapper argument validation. No network. + */ + +#include "wolfram/lexicon_typed.h" +#include "test.h" + +#include + +#include +#include + +/* resolveLexicon sample: a minimal lexicon schema record. */ +static const char *k_resolve_json = + "{\"cid\":\"bafyreighash\"," + "\"uri\":\"at://did:plc:abc/com.atproto.lexicon.lexicon/app.bsky.feed.post\"," + "\"schema\":{\"lexicon\":1,\"id\":\"app.bsky.feed.post\"," + "\"defs\":{\"main\":{\"type\":\"record\"}}}}"; + +int main(void) { + /* ---- parse round-trip ---- */ + { + wf_lexicon_resolved out = {0}; + wf_status s = wf_lexicon_parse_resolve(k_resolve_json, + strlen(k_resolve_json), &out); + WF_CHECK(s == WF_OK); + WF_CHECK(out.cid && strcmp(out.cid, "bafyreighash") == 0); + WF_CHECK(out.uri && + strcmp(out.uri, + "at://did:plc:abc/com.atproto.lexicon.lexicon/" + "app.bsky.feed.post") == 0); + WF_CHECK(out.schema != NULL); + if (out.schema) { + cJSON *id = + cJSON_GetObjectItemCaseSensitive(out.schema, "id"); + WF_CHECK(cJSON_IsString(id) && id->valuestring && + strcmp(id->valuestring, "app.bsky.feed.post") == 0); + } + wf_lexicon_resolved_free(&out); + wf_lexicon_resolved_free(&out); /* idempotent */ + } + + /* ---- invalid / NULL input validation ---- */ + { + wf_lexicon_resolved out = {0}; + WF_CHECK(wf_lexicon_parse_resolve(NULL, 0, &out) == + WF_ERR_INVALID_ARG); + WF_CHECK(wf_lexicon_parse_resolve(NULL, 0, NULL) == + WF_ERR_INVALID_ARG); + /* malformed JSON */ + WF_CHECK(wf_lexicon_parse_resolve("not json", 8, &out) == + WF_ERR_PARSE); + wf_lexicon_resolved_free(&out); + } + + /* ---- wrapper argument validation (NULL agent) ---- */ + { + wf_lexicon_resolved out = {0}; + WF_CHECK(wf_agent_resolve_lexicon_typed(NULL, "x", &out) == + WF_ERR_INVALID_ARG); + WF_CHECK(wf_agent_resolve_lexicon_typed(NULL, NULL, &out) == + WF_ERR_INVALID_ARG); + WF_CHECK(wf_agent_resolve_lexicon_typed(NULL, "x", NULL) == + WF_ERR_INVALID_ARG); + wf_lexicon_resolved_free(&out); + } + + WF_TEST_SUMMARY(); + return 0; /* unreachable */ +}