ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
graphql.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file graphql.cpp
6 * @brief GraphQL query subset - parser + executor (implementation).
7 *
8 * Recursive-descent parse into fixed node/arg pools, then a recursive emit that
9 * mirrors the selection set into a JSON `data` object, calling the resolver for
10 * leaf fields with the arguments collected along the path. No heap; all state is
11 * file-static (single-accessor, like the other services).
12 */
13
15#include "shared_primitives/strbuf.h" // pc_sb frame builder
16
17#if PC_ENABLE_GRAPHQL
18
19#include <stdio.h>
20#include <string.h>
21
22struct pc_gql_args
23{
24 const int *idx; // indices into s_gql.args that are in scope
25 int count;
26};
27
28namespace
29{
30struct Node
31{
32 char name[PC_GQL_NAME_MAX];
33 int first_arg;
34 int n_args;
35 int first_child; // -1 if leaf
36 int next_sib; // -1 if last
37};
38struct Arg
39{
40 char name[PC_GQL_NAME_MAX];
41 pc_gql_value val;
42};
43
44// All GraphQL parser + executor state, owned by one instance (internal linkage): the node /
45// arg / string pools and their cursors, the parse root + error, and the executor's arg-scope
46// stack, resolver, and dotted path. Grouped so it is one named owner, unreachable cross-TU;
47// single-accessor (never reentrant). The recursive parser/executor is a single-owner state
48// machine, so its helpers reach this owner directly.
49struct GqlCtx
50{
51 Node nodes[PC_GQL_MAX_NODES];
52 Arg args[PC_GQL_MAX_ARGS];
53 char strbuf[PC_GQL_STRBUF];
54 int nnodes;
55 int nargs;
56 int str_len;
57 int root;
58 pc_gql_result err;
59 // executor: scope stack of in-scope arg indices, resolver, and dotted path
60 int scope[PC_GQL_MAX_ARGS];
61 int scope_n;
62 pc_gql_resolver_fn resolver;
63 char path[PC_GQL_PATH_MAX];
64};
65GqlCtx s_gql;
66
67int new_node()
68{
69 if (s_gql.nnodes >= PC_GQL_MAX_NODES)
70 {
71 s_gql.err = pc_gql_result::PC_GQL_ERR_LIMIT;
72 return -1;
73 }
74 Node *n = &s_gql.nodes[s_gql.nnodes];
75 n->name[0] = '\0';
76 n->first_arg = -1;
77 n->n_args = 0;
78 n->first_child = -1;
79 n->next_sib = -1;
80 return s_gql.nnodes++;
81}
82
83// ---- lexer helpers --------------------------------------------------------
84struct Lex
85{
86 const char *p;
87 const char *e;
88};
89
90void skipws(Lex &L)
91{
92 while (L.p < L.e)
93 {
94 char c = *L.p;
95 if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',')
96 {
97 L.p++;
98 }
99 else if (c == '#')
100 {
101 while (L.p < L.e && *L.p != '\n')
102 {
103 L.p++;
104 }
105 }
106 else
107 {
108 break;
109 }
110 }
111}
112
113char peek(Lex &L)
114{
115 skipws(L);
116 return L.p < L.e ? *L.p : '\0';
117}
118
119// Record a generic parse error, preserving any more specific error already set.
120void gql_flag_parse_err()
121{
122 if (s_gql.err == pc_gql_result::PC_GQL_OK)
123 {
124 s_gql.err = pc_gql_result::PC_GQL_ERR_PARSE;
125 }
126}
127
128bool is_name_start(char c)
129{
130 return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_';
131}
132bool is_name(char c)
133{
134 return is_name_start(c) || (c >= '0' && c <= '9');
135}
136
137// @p cap is the size of @p out. It is bounded by the CALLER's buffer, not only by
138// PC_GQL_NAME_MAX: parse_value() passes a small scratch array that only ever has to hold
139// "true"/"false"/"null", and bounding solely by the global maximum let a longer bareword in an
140// argument position - e.g. `{ f(a: LONGENUMVALUE) }`, straight from untrusted query text - write
141// past the end of it. Names still cannot exceed PC_GQL_NAME_MAX; whichever limit is tighter wins.
142bool parse_name(Lex &L, char *out, size_t cap)
143{
144 skipws(L);
145 if (L.p >= L.e || !is_name_start(*L.p))
146 {
147 return false;
148 }
149 const size_t limit = cap < (size_t)PC_GQL_NAME_MAX ? cap : (size_t)PC_GQL_NAME_MAX;
150 size_t i = 0;
151 while (L.p < L.e && is_name(*L.p))
152 {
153 // `i + 1 >= limit`, not `i >= limit - 1`: equivalent for every limit >= 1, but it also
154 // stays correct if a caller ever passes cap == 0, where `limit - 1` would wrap to SIZE_MAX
155 // and defeat the bound entirely. No separate cap==0 guard needed (and none to leave dead).
156 if (i + 1 >= limit)
157 {
158 s_gql.err = pc_gql_result::PC_GQL_ERR_LIMIT;
159 return false;
160 }
161 out[i++] = *L.p++;
162 }
163 out[i] = '\0';
164 return true;
165}
166
167// Copy a decoded string into the strbuf pool; returns pointer or nullptr.
168const char *intern(const char *s, int len)
169{
170 if (s_gql.str_len + len + 1 > PC_GQL_STRBUF)
171 {
172 s_gql.err = pc_gql_result::PC_GQL_ERR_LIMIT;
173 return nullptr;
174 }
175 char *dst = s_gql.strbuf + s_gql.str_len;
176 memcpy(dst, s, len);
177 dst[len] = '\0';
178 s_gql.str_len += len + 1;
179 return dst;
180}
181
182bool parse_value(Lex &L, pc_gql_value *v)
183{
184 char c = peek(L);
185 if (c == '"')
186 {
187 L.p++; // opening quote
188 char tmp[PC_GQL_STRBUF];
189 int n = 0;
190 while (L.p < L.e && *L.p != '"')
191 {
192 char ch = *L.p++;
193 if (ch == '\\' && L.p < L.e)
194 {
195 char esc = *L.p++;
196 switch (esc)
197 {
198 case 'n':
199 ch = '\n';
200 break;
201 case 't':
202 ch = '\t';
203 break;
204 case 'r':
205 ch = '\r';
206 break;
207 case '"':
208 ch = '"';
209 break;
210 case '\\':
211 ch = '\\';
212 break;
213 case '/':
214 ch = '/';
215 break;
216 default:
217 ch = esc;
218 break;
219 }
220 }
221 if (n >= (int)sizeof(tmp) - 1)
222 {
223 s_gql.err = pc_gql_result::PC_GQL_ERR_LIMIT;
224 return false;
225 }
226 tmp[n++] = ch;
227 }
228 if (L.p >= L.e)
229 {
230 s_gql.err = pc_gql_result::PC_GQL_ERR_PARSE;
231 return false;
232 }
233 L.p++; // closing quote
234 const char *s = intern(tmp, n);
235 if (!s)
236 {
237 return false;
238 }
239 v->type = pc_gql_type::PC_GQL_STR;
240 v->s = s;
241 return true;
242 }
243 if (c == '-' || (c >= '0' && c <= '9'))
244 {
245 // Manual number parse (no stdlib): integer, optional fraction, optional
246 // exponent. Builds an int64 for plain integers and a double otherwise.
247 bool neg = false;
248 if (*L.p == '-')
249 {
250 neg = true;
251 L.p++;
252 }
253 bool any = false;
254 bool is_float = false;
255 unsigned long long ipart = 0; // accumulate unsigned: signed overflow on a huge literal is UB
256 double fval = 0.0;
257 while (L.p < L.e && *L.p >= '0' && *L.p <= '9')
258 {
259 ipart = ipart * 10ULL + (unsigned)(*L.p - '0');
260 L.p++;
261 any = true;
262 }
263 fval = (double)ipart;
264 if (L.p < L.e && *L.p == '.')
265 {
266 is_float = true;
267 L.p++;
268 double scale = 1.0;
269 while (L.p < L.e && *L.p >= '0' && *L.p <= '9')
270 {
271 scale *= 10.0;
272 fval += (double)(*L.p - '0') / scale;
273 L.p++;
274 any = true;
275 }
276 }
277 if (L.p < L.e && (*L.p == 'e' || *L.p == 'E'))
278 {
279 is_float = true;
280 L.p++;
281 bool eneg = false;
282 if (L.p < L.e && (*L.p == '+' || *L.p == '-'))
283 {
284 eneg = (*L.p++ == '-');
285 }
286 int ex = 0;
287 while (L.p < L.e && *L.p >= '0' && *L.p <= '9')
288 {
289 // clamp: 10^400 overflows the double to inf, and bounds the exponent below
290 ex = (ex < 400) ? ex * 10 + (*L.p - '0') : ex;
291 L.p++;
292 }
293 double m = 1.0;
294 for (int k = 0; k < ex; k++)
295 {
296 m *= 10.0;
297 }
298 fval = eneg ? fval / m : fval * m;
299 }
300 if (!any)
301 {
302 s_gql.err = pc_gql_result::PC_GQL_ERR_PARSE;
303 return false;
304 }
305 if (is_float)
306 {
307 v->type = pc_gql_type::PC_GQL_FLOAT;
308 v->f = neg ? -fval : fval;
309 }
310 else
311 {
312 v->type = pc_gql_type::PC_GQL_INT;
313 // Negate in signed space: ipart is unsigned (to dodge signed-overflow UB while
314 // accumulating), so -ipart would be a modular unsigned negation, not arithmetic negation.
315 v->i = neg ? -(long long)ipart : (long long)ipart;
316 }
317 return true;
318 }
319 // keyword: true / false / null
320 char kw[8];
321 if (parse_name(L, kw, sizeof(kw)))
322 {
323 if (strcmp(kw, "true") == 0)
324 {
325 v->type = pc_gql_type::PC_GQL_BOOL;
326 v->b = true;
327 return true;
328 }
329 if (strcmp(kw, "false") == 0)
330 {
331 v->type = pc_gql_type::PC_GQL_BOOL;
332 v->b = false;
333 return true;
334 }
335 if (strcmp(kw, "null") == 0)
336 {
337 v->type = pc_gql_type::PC_GQL_NULL;
338 return true;
339 }
340 }
341 s_gql.err = pc_gql_result::PC_GQL_ERR_PARSE;
342 return false;
343}
344
345int parse_selection(Lex &L, int depth);
346
347int parse_field(Lex &L, int depth)
348{
349 int idx = new_node();
350 if (idx < 0)
351 {
352 return -1;
353 }
354 if (!parse_name(L, s_gql.nodes[idx].name, sizeof(s_gql.nodes[idx].name)))
355 {
356 gql_flag_parse_err();
357 return -1;
358 }
359 // arguments
360 if (peek(L) == '(')
361 {
362 L.p++; // '('
363 int first = -1;
364 int count = 0;
365 while (peek(L) != ')')
366 {
367 if (s_gql.nargs >= PC_GQL_MAX_ARGS)
368 {
369 s_gql.err = pc_gql_result::PC_GQL_ERR_LIMIT;
370 return -1;
371 }
372 Arg *a = &s_gql.args[s_gql.nargs];
373 if (!parse_name(L, a->name, sizeof(a->name)))
374 {
375 gql_flag_parse_err();
376 return -1;
377 }
378 if (peek(L) != ':')
379 {
380 s_gql.err = pc_gql_result::PC_GQL_ERR_PARSE;
381 return -1;
382 }
383 L.p++; // ':'
384 if (!parse_value(L, &a->val))
385 {
386 return -1;
387 }
388 if (first < 0)
389 {
390 first = s_gql.nargs;
391 }
392 count++;
393 s_gql.nargs++;
394 }
395 L.p++; // ')'
396 s_gql.nodes[idx].first_arg = first;
397 s_gql.nodes[idx].n_args = count;
398 }
399 // sub-selection
400 if (peek(L) == '{')
401 {
402 s_gql.nodes[idx].first_child = parse_selection(L, depth + 1);
403 }
404 return s_gql.err != pc_gql_result::PC_GQL_OK ? -1 : idx;
405}
406
407int parse_selection(Lex &L, int depth)
408{
409 if (depth > PC_GQL_MAX_DEPTH)
410 {
411 s_gql.err = pc_gql_result::PC_GQL_ERR_LIMIT;
412 return -1;
413 }
414 if (peek(L) != '{')
415 {
416 s_gql.err = pc_gql_result::PC_GQL_ERR_PARSE;
417 return -1;
418 }
419 L.p++; // '{'
420 int first = -1;
421 int prev = -1;
422 while (peek(L) != '}')
423 {
424 if (L.p >= L.e)
425 {
426 s_gql.err = pc_gql_result::PC_GQL_ERR_PARSE;
427 return -1;
428 }
429 int f = parse_field(L, depth);
430 if (f < 0)
431 {
432 return -1;
433 }
434 if (first < 0)
435 {
436 first = f;
437 }
438 else
439 {
440 s_gql.nodes[prev].next_sib = f;
441 }
442 prev = f;
443 }
444 L.p++; // '}'
445 return first;
446}
447
448bool parse_document(Lex &L)
449{
450 char c = peek(L);
451 if (c != '{')
452 {
453 char kw[PC_GQL_NAME_MAX];
454 if (!parse_name(L, kw, sizeof(kw)) || strcmp(kw, "query") != 0)
455 {
456 s_gql.err = pc_gql_result::PC_GQL_ERR_PARSE; // only anonymous or `query` operations
457 return false;
458 }
459 if (peek(L) != '{') // optional operation name
460 {
461 char opname[PC_GQL_NAME_MAX];
462 if (!parse_name(L, opname, sizeof(opname)))
463 {
464 gql_flag_parse_err();
465 return false;
466 }
467 }
468 }
469 s_gql.root = parse_selection(L, 1);
470 if (s_gql.err != pc_gql_result::PC_GQL_OK)
471 {
472 return false;
473 }
474 if (peek(L) != '\0') // trailing junk after the operation
475 {
476 s_gql.err = pc_gql_result::PC_GQL_ERR_PARSE;
477 return false;
478 }
479 return true;
480}
481
482// ---- writer + executor ----------------------------------------------------
483struct Writer
484{
485 char *o;
486 size_t cap;
487 size_t n;
488 bool ovf;
489};
490void w_raw(Writer &w, const char *s, size_t len)
491{
492 if (w.ovf)
493 {
494 return;
495 }
496 if (w.n + len > w.cap)
497 {
498 w.ovf = true;
499 return;
500 }
501 memcpy(w.o + w.n, s, len);
502 w.n += len;
503}
504void w_str(Writer &w, const char *s)
505{
506 w_raw(w, s, strnlen(s, w.cap + 1));
507}
508void w_json_str(Writer &w, const char *s)
509{
510 w_raw(w, "\"", 1);
511 for (const char *p = s; *p; p++)
512 {
513 unsigned char ch = (unsigned char)*p;
514 if (ch == '"')
515 {
516 w_raw(w, "\\\"", 2);
517 }
518 else if (ch == '\\')
519 {
520 w_raw(w, "\\\\", 2);
521 }
522 else if (ch == '\n')
523 {
524 w_raw(w, "\\n", 2);
525 }
526 else if (ch == '\r')
527 {
528 w_raw(w, "\\r", 2);
529 }
530 else if (ch == '\t')
531 {
532 w_raw(w, "\\t", 2);
533 }
534 else if (ch < 0x20)
535 {
536 char u[7];
537 pc_sb sb_u = {u, sizeof(u), 0, true};
538 pc_sb_put(&sb_u, "\\u");
539 pc_sb_hex(&sb_u, (uint64_t)(ch), 4);
540 if (pc_sb_finish(&sb_u) == 0)
541 {
542 u[0] = '\0';
543 }
544 w_raw(w, u, 6);
545 }
546 else
547 {
548 w_raw(w, (const char *)&ch, 1);
549 }
550 }
551 w_raw(w, "\"", 1);
552}
553void w_scalar(Writer &w, const pc_gql_value *v)
554{
555 char b[40];
556 switch (v->type)
557 {
558 case pc_gql_type::PC_GQL_INT: {
559 pc_sb sb_b = {b, sizeof(b), 0, true};
560 pc_sb_i64(&sb_b, (int64_t)(v->i));
561 if (pc_sb_finish(&sb_b) == 0)
562 {
563 b[0] = '\0';
564 }
565 }
566 w_str(w, b);
567 break;
568 case pc_gql_type::PC_GQL_FLOAT: {
569 pc_sb sb_b2 = {b, sizeof(b), 0, true};
570 pc_sb_g(&sb_b2, (double)(v->f), 6);
571 if (pc_sb_finish(&sb_b2) == 0)
572 {
573 b[0] = '\0';
574 }
575 }
576 w_str(w, b);
577 break;
578 case pc_gql_type::PC_GQL_BOOL:
579 w_str(w, v->b ? "true" : "false");
580 break;
581 case pc_gql_type::PC_GQL_STR:
582 w_json_str(w, v->s ? v->s : "");
583 break;
584 default:
585 w_str(w, "null");
586 break;
587 }
588}
589
590void emit_field(Writer &w, int idx, int path_len)
591{
592 Node *node = &s_gql.nodes[idx];
593
594 // extend the dotted path: [parent].name
595 int plen = path_len;
596 if (plen > 0)
597 {
598 if (plen + 1 >= PC_GQL_PATH_MAX)
599 {
600 w.ovf = true;
601 return;
602 }
603 s_gql.path[plen++] = '.';
604 }
605 int nl = (int)strnlen(node->name, PC_GQL_PATH_MAX);
606 if (plen + nl >= PC_GQL_PATH_MAX)
607 {
608 w.ovf = true;
609 return;
610 }
611 memcpy(s_gql.path + plen, node->name, nl);
612 plen += nl;
613 s_gql.path[plen] = '\0';
614
615 // push this field's args into scope
616 int pushed = 0;
617 for (int a = 0; a < node->n_args; a++)
618 {
619 // scope_n cannot reach the cap: scope[] and args[] are both PC_GQL_MAX_ARGS long,
620 // parse_field refuses to record arg number PC_GQL_MAX_ARGS, and the nodes on one
621 // root-to-leaf path own disjoint slices of that pool - so the guard never bites.
622 if (s_gql.scope_n < PC_GQL_MAX_ARGS) // GCOVR_EXCL_LINE
623 {
624 s_gql.scope[s_gql.scope_n++] = node->first_arg + a;
625 pushed++;
626 }
627 }
628
629 w_json_str(w, node->name);
630 w_raw(w, ":", 1);
631
632 if (node->first_child >= 0)
633 {
634 w_raw(w, "{", 1);
635 bool first = true;
636 for (int c = node->first_child; c >= 0; c = s_gql.nodes[c].next_sib)
637 {
638 if (!first)
639 {
640 w_raw(w, ",", 1);
641 }
642 first = false;
643 emit_field(w, c, plen);
644 }
645 w_raw(w, "}", 1);
646 }
647 else
648 {
649 pc_gql_value v;
650 v.type = pc_gql_type::PC_GQL_NULL;
651 pc_gql_args view = {s_gql.scope, s_gql.scope_n};
652 if (s_gql.resolver && s_gql.resolver(s_gql.path, &view, &v))
653 {
654 w_scalar(w, &v);
655 }
656 else
657 {
658 w_str(w, "null");
659 }
660 }
661
662 s_gql.scope_n -= pushed; // pop
663 s_gql.path[path_len] = '\0';
664}
665} // namespace
666
667bool pc_gql_arg_int(const pc_gql_args *args, const char *name, long long *out)
668{
669 if (!args)
670 {
671 return false;
672 }
673 for (int k = 0; k < args->count; k++)
674 {
675 Arg *a = &s_gql.args[args->idx[k]];
676 if (strcmp(a->name, name) == 0 && a->val.type == pc_gql_type::PC_GQL_INT)
677 {
678 *out = a->val.i;
679 return true;
680 }
681 }
682 return false;
683}
684bool pc_gql_arg_str(const pc_gql_args *args, const char *name, const char **out)
685{
686 if (!args)
687 {
688 return false;
689 }
690 for (int k = 0; k < args->count; k++)
691 {
692 Arg *a = &s_gql.args[args->idx[k]];
693 if (strcmp(a->name, name) == 0 && a->val.type == pc_gql_type::PC_GQL_STR)
694 {
695 *out = a->val.s;
696 return true;
697 }
698 }
699 return false;
700}
701bool pc_gql_arg_bool(const pc_gql_args *args, const char *name, bool *out)
702{
703 if (!args)
704 {
705 return false;
706 }
707 for (int k = 0; k < args->count; k++)
708 {
709 Arg *a = &s_gql.args[args->idx[k]];
710 if (strcmp(a->name, name) == 0 && a->val.type == pc_gql_type::PC_GQL_BOOL)
711 {
712 *out = a->val.b;
713 return true;
714 }
715 }
716 return false;
717}
718
719pc_gql_result pc_graphql_execute(const char *query, size_t len, pc_gql_resolver_fn resolver, char *out, size_t cap)
720{
721 s_gql.nnodes = 0;
722 s_gql.nargs = 0;
723 s_gql.str_len = 0;
724 s_gql.scope_n = 0;
725 s_gql.root = -1;
726 s_gql.err = pc_gql_result::PC_GQL_OK;
727 s_gql.resolver = resolver;
728 s_gql.path[0] = '\0';
729
730 Lex L = {query, query + (query ? len : 0)};
731 if (!query || !out || cap == 0)
732 {
733 return pc_gql_result::PC_GQL_ERR_PARSE;
734 }
735
736 if (!parse_document(L))
737 {
738 const char *msg =
739 (s_gql.err == pc_gql_result::PC_GQL_ERR_LIMIT) ? "query exceeds a configured limit" : "syntax error";
740 Writer w = {out, cap, 0, false};
741 w_str(w, "{\"errors\":[{\"message\":");
742 w_json_str(w, msg);
743 w_str(w, "}]}");
744 if (!w.ovf && w.n < cap)
745 {
746 out[w.n] = '\0';
747 }
748 // every path that makes parse_document() return false has already set s_gql.err, so the
749 // PC_GQL_OK side of this test is unreachable
750 return s_gql.err != pc_gql_result::PC_GQL_OK ? s_gql.err : pc_gql_result::PC_GQL_ERR_PARSE; // GCOVR_EXCL_LINE
751 }
752
753 Writer w = {out, cap, 0, false};
754 w_str(w, "{\"data\":{");
755 bool first = true;
756 for (int c = s_gql.root; c >= 0; c = s_gql.nodes[c].next_sib)
757 {
758 if (!first)
759 {
760 w_raw(w, ",", 1);
761 }
762 first = false;
763 emit_field(w, c, 0);
764 }
765 w_str(w, "}}");
766 if (w.ovf || w.n >= cap)
767 {
768 return pc_gql_result::PC_GQL_ERR_OVERFLOW;
769 }
770 out[w.n] = '\0';
771 return pc_gql_result::PC_GQL_OK;
772}
773
774#endif // PC_ENABLE_GRAPHQL
Zero-heap GraphQL query subset - parser + executor (PC_ENABLE_GRAPHQL).
Bounded no-heap string builder that fails closed on overflow (one shared copy).
void pc_sb_g(pc_sb *b, double v, unsigned sig)
Append v with sig significant digits, choosing fixed or scientific form - the printf "%....
Definition strbuf.h:437
size_t pc_sb_finish(pc_sb *b)
NUL-terminate and return the built length, or 0 if the build overflowed.
Definition strbuf.h:648
void pc_sb_hex(pc_sb *b, uint64_t v, unsigned min_digits)
Append v as lowercase hex, zero-padded to at least min_digits (printf "%0Nx").
Definition strbuf.h:297
void pc_sb_i64(pc_sb *b, int64_t v)
Append v as signed decimal (64-bit), with a leading '-' when negative.
Definition strbuf.h:315
void pc_sb_put(pc_sb *b, const char *s)
Append NUL-terminated s; leaves the buffer untouched and clears ok if it would not fit.
Definition strbuf.h:60
Bump-append target; ok latches false once an append would overflow cap.
Definition strbuf.h:30