• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dangernoodle-io / breadboard / 26137398248

20 May 2026 02:23AM UTC coverage: 99.793% (-0.2%) from 100.0%
26137398248

Pull #279

github

web-flow
Merge 33c103c79 into 460db1b47
Pull Request #279: fix(bb_openapi): stream the OpenAPI doc instead of materializing a 26+ KB tree

1020 of 1020 branches covered (100.0%)

Branch coverage included in aggregate %.

72 of 78 new or added lines in 1 file covered. (92.31%)

1869 of 1875 relevant lines covered (99.68%)

961.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.4
/components/bb_openapi/src/bb_openapi_emit.c
1
#include "bb_openapi.h"
2
#include "bb_log.h"
3

4
#include <string.h>
5
#include <ctype.h>
6
#include <stdio.h>
7

8
static const char *TAG = "bb_openapi";
9

10
#define UNIQUE_PATH_CAP 64
11

12
// ---------------------------------------------------------------------------
13
// Method name helpers
14
// ---------------------------------------------------------------------------
15

16
static const char *method_str(bb_http_method_t m)
126✔
17
{
18
    switch (m) {
126✔
19
        case BB_HTTP_GET:     return "get";
92✔
20
        case BB_HTTP_POST:    return "post";
28✔
21
        case BB_HTTP_PATCH:   return "patch";
1✔
22
        case BB_HTTP_PUT:     return "put";
1✔
23
        case BB_HTTP_DELETE:  return "delete";
2✔
24
        case BB_HTTP_OPTIONS: return "options";
1✔
25
        default:              return "get";
1✔
26
    }
27
}
28

29
// ---------------------------------------------------------------------------
30
// operationId derivation
31
// ---------------------------------------------------------------------------
32
// Rule: <method><PathCamelCase>
33
//   - strip leading '/'
34
//   - each path segment's first char is uppercased
35
//   - '-' and '_' are dropped and next char is uppercased
36
//   e.g. GET /api/stats -> "getApiStats"
37
//        POST /api/pool-config -> "postApiPoolConfig"
38

39
// Caller guarantees: non-NULL path (registry walkers skip NULL-path routes
40
// in collect_paths_walker / emit_operations_walker) and a non-empty buffer
41
// (only call site uses a fixed 128-byte stack array).
42
static void derive_operation_id(bb_http_method_t method, const char *path,
32✔
43
                                char *out, size_t out_size)
44
{
45
    const char *m = method_str(method);
32✔
46
    size_t pos = 0;
32✔
47

48
    // copy method prefix (already lowercase). method_str returns at most
49
    // 7 chars ("options"); out_size is 128 — no bounds check needed here.
50
    for (const char *p = m; *p; p++) {
130✔
51
        out[pos++] = *p;
98✔
52
    }
53

54
    bool next_upper = true;  // first path char after '/' gets uppercased
32✔
55
    bool skip_slash = true;  // skip the leading '/'
32✔
56

57
    for (const char *p = path; *p && pos < out_size - 1; p++) {
560✔
58
        char c = *p;
528✔
59
        if (c == '/') {
528✔
60
            if (skip_slash) {
95✔
61
                skip_slash = false;
32✔
62
            } else {
63
                next_upper = true;
63✔
64
            }
65
            continue;
95✔
66
        }
67
        if (c == '-' || c == '_') {
433✔
68
            next_upper = true;
18✔
69
            continue;
18✔
70
        }
71
        if (next_upper) {
415✔
72
            out[pos++] = (char)toupper((unsigned char)c);
112✔
73
            next_upper = false;
112✔
74
        } else {
75
            out[pos++] = c;
303✔
76
        }
77
    }
78

79
    out[pos] = '\0';
32✔
80
}
32✔
81

82
// ---------------------------------------------------------------------------
83
// Path uniqueness tracking (stack array, no malloc)
84
// ---------------------------------------------------------------------------
85

86
typedef struct {
87
    const char *paths[UNIQUE_PATH_CAP];
88
    size_t      count;
89
} path_set_t;
90

91
static bool path_set_contains(const path_set_t *ps, const char *path)
97✔
92
{
93
    for (size_t i = 0; i < ps->count; i++) {
148✔
94
        if (strcmp(ps->paths[i], path) == 0) return true;
53✔
95
    }
96
    return false;
95✔
97
}
98

99
static void path_set_add(path_set_t *ps, const char *path)
97✔
100
{
101
    // The bb_http registry caps at BB_ROUTE_REGISTRY_CAP (64) == UNIQUE_PATH_CAP,
102
    // so the path_set can hold every distinct path the registry can store.
103
    if (!path_set_contains(ps, path)) {
97✔
104
        ps->paths[ps->count++] = path;
95✔
105
    }
106
}
97✔
107

108
// ---------------------------------------------------------------------------
109
// Walker context for two-pass emission
110
// ---------------------------------------------------------------------------
111

112
typedef struct {
113
    path_set_t          *path_set;
114
    bb_json_t            paths_obj;
115
    const char          *current_path;
116
    bb_json_t            path_item_obj;
117
} emit_ctx_t;
118

119
// Pass 1: collect unique paths.
120
// bb_http_register_described_route rejects NULL routes; descriptors stored in
121
// the registry are guaranteed non-NULL with non-NULL paths.
122
static void collect_paths_walker(const bb_route_t *route, void *ctx)
97✔
123
{
124
    path_set_t *ps = (path_set_t *)ctx;
97✔
125
    path_set_add(ps, route->path);
97✔
126
}
97✔
127

128
// ---------------------------------------------------------------------------
129
// Build a single operation object for a route
130
// ---------------------------------------------------------------------------
131

132
static bb_json_t build_operation(const bb_route_t *route)
95✔
133
{
134
    bb_json_t op = bb_json_obj_new();
95✔
135
    if (!op) return NULL;
95✔
136

137
    // operationId
138
    if (route->operation_id) {
94✔
139
        bb_json_obj_set_string(op, "operationId", route->operation_id);
62✔
140
    } else {
141
        char op_id[128];
142
        derive_operation_id(route->method, route->path, op_id, sizeof(op_id));
32✔
143
        bb_json_obj_set_string(op, "operationId", op_id);
32✔
144
    }
145

146
    // summary
147
    if (route->summary) {
94✔
148
        bb_json_obj_set_string(op, "summary", route->summary);
70✔
149
    }
150

151
    // tags (single-element array)
152
    if (route->tag) {
94✔
153
        bb_json_t tags = bb_json_arr_new();
55✔
154
        if (tags) {
55✔
155
            bb_json_arr_append_string(tags, route->tag);
54✔
156
            bb_json_obj_set_arr(op, "tags", tags);
54✔
157
        }
158
    }
159

160
    // parameters array (query / path / header)
161
    if (route->parameters && route->parameters_count > 0) {
94✔
162
        bb_json_t params = bb_json_arr_new();
9✔
163
        if (params) {
9✔
164
            for (size_t i = 0; i < route->parameters_count; i++) {
17✔
165
                const bb_route_param_t *p = &route->parameters[i];
9✔
166
                bb_json_t param_obj = bb_json_obj_new();
9✔
167
                if (!param_obj) continue;
9✔
168
                bb_json_obj_set_string(param_obj, "name", p->name ? p->name : "");
8✔
169
                bb_json_obj_set_string(param_obj, "in",   p->in   ? p->in   : "query");
8✔
170
                if (p->description) {
8✔
171
                    bb_json_obj_set_string(param_obj, "description", p->description);
3✔
172
                }
173
                bb_json_obj_set_bool(param_obj, "required", p->required);
8✔
174
                if (p->schema_type) {
8✔
175
                    bb_json_t schema = bb_json_obj_new();
5✔
176
                    if (schema) {
5✔
177
                        bb_json_obj_set_string(schema, "type", p->schema_type);
4✔
178
                        bb_json_obj_set_obj(param_obj, "schema", schema);
4✔
179
                    }
180
                }
181
                bb_json_arr_append_obj(params, param_obj);
8✔
182
            }
183
            bb_json_obj_set_arr(op, "parameters", params);
8✔
184
        }
185
    }
186

187
    // requestBody — gated on request_schema; request_content_type without schema is ignored
188
    if (route->request_schema) {
94✔
189
        bb_json_t req_body = bb_json_obj_new();
22✔
190
        bb_json_t content  = bb_json_obj_new();
22✔
191
        bb_json_t media    = bb_json_obj_new();
22✔
192

193
        if (req_body && content && media) {
41✔
194
            bb_json_obj_set_raw(media, "schema", route->request_schema);
19✔
195
            const char *ct = route->request_content_type
38✔
196
                             ? route->request_content_type
197
                             : "application/json";
19✔
198
            bb_json_obj_set_obj(content, ct, media);
19✔
199
            bb_json_obj_set_obj(req_body, "content", content);
19✔
200
            bb_json_obj_set_bool(req_body, "required", true);
19✔
201
            bb_json_obj_set_obj(op, "requestBody", req_body);
19✔
202
        } else {
203
            bb_json_free(req_body);
3✔
204
            bb_json_free(content);
3✔
205
            bb_json_free(media);
3✔
206
        }
207
    }
208

209
    // responses
210
    bb_json_t responses = bb_json_obj_new();
94✔
211
    if (responses && route->responses) {
94✔
212
        for (const bb_route_response_t *r = route->responses; r->status != 0; r++) {
185✔
213
            char status_key[8];
214
            snprintf(status_key, sizeof(status_key), "%d", r->status);
93✔
215

216
            bb_json_t resp_obj = bb_json_obj_new();
93✔
217
            if (!resp_obj) continue;
93✔
218

219
            // OpenAPI requires response.description; emit empty string when absent.
220
            bb_json_obj_set_string(resp_obj, "description",
92✔
221
                                   r->description ? r->description : "");
92✔
222

223
            if (r->schema) {
92✔
224
                bb_json_t content = bb_json_obj_new();
40✔
225
                bb_json_t media   = bb_json_obj_new();
40✔
226
                if (content && media) {
78✔
227
                    bb_json_obj_set_raw(media, "schema", r->schema);
38✔
228
                    const char *ct = r->content_type ? r->content_type : "application/json";
38✔
229
                    bb_json_obj_set_obj(content, ct, media);
38✔
230
                    bb_json_obj_set_obj(resp_obj, "content", content);
38✔
231
                } else {
232
                    bb_json_free(content);
2✔
233
                    bb_json_free(media);
2✔
234
                }
235
            }
236

237
            bb_json_obj_set_obj(responses, status_key, resp_obj);
92✔
238
        }
239
    }
240

241
    if (responses) {
94✔
242
        bb_json_obj_set_obj(op, "responses", responses);
93✔
243
    }
244

245
    return op;
94✔
246
}
247

248
// Pass 2 context: emit operations for a specific path
249
typedef struct {
250
    const char *path;
251
    bb_json_t   path_item;
252
} pass2_ctx_t;
253

254
static void emit_operations_walker(const bb_route_t *route, void *ctx)
168✔
255
{
256
    pass2_ctx_t *p2 = (pass2_ctx_t *)ctx;
168✔
257
    if (strcmp(route->path, p2->path) != 0) return;
168✔
258

259
    bb_json_t op = build_operation(route);
84✔
260
    if (!op) return;
84✔
261

262
    bb_json_obj_set_obj(p2->path_item, method_str(route->method), op);
83✔
263
}
264

265
// ---------------------------------------------------------------------------
266
// Public emitter
267
// ---------------------------------------------------------------------------
268

269
bb_json_t bb_openapi_emit(const bb_openapi_meta_t *meta)
60✔
270
{
271
    if (!meta) {
60✔
272
        bb_log_e(TAG, "bb_openapi_emit: meta is NULL");
1✔
273
        return NULL;
1✔
274
    }
275

276
    bb_json_t root = bb_json_obj_new();
59✔
277
    if (!root) return NULL;
59✔
278

279
    // openapi version
280
    bb_json_obj_set_string(root, "openapi", "3.1.0");
58✔
281

282
    // info object
283
    bb_json_t info = bb_json_obj_new();
58✔
284
    if (!info) { bb_json_free(root); return NULL; }
58✔
285
    bb_json_obj_set_string(info, "title",   meta->title   ? meta->title   : "");
57✔
286
    bb_json_obj_set_string(info, "version", meta->version ? meta->version : "0.0.0");
57✔
287
    if (meta->description) {
57✔
288
        bb_json_obj_set_string(info, "description", meta->description);
1✔
289
    }
290
    bb_json_obj_set_obj(root, "info", info);
57✔
291

292
    // servers (optional)
293
    if (meta->server_url) {
57✔
294
        bb_json_t servers  = bb_json_arr_new();
3✔
295
        bb_json_t server_e = bb_json_obj_new();
3✔
296
        if (servers && server_e) {
3✔
297
            bb_json_obj_set_string(server_e, "url", meta->server_url);
1✔
298
            bb_json_arr_append_obj(servers, server_e);
1✔
299
            bb_json_obj_set_arr(root, "servers", servers);
1✔
300
        } else {
301
            if (servers)  bb_json_free(servers);
2✔
302
            if (server_e) bb_json_free(server_e);
2✔
303
        }
304
    }
305

306
    // paths — two-pass
307
    path_set_t ps;
308
    memset(&ps, 0, sizeof(ps));
57✔
309
    bb_http_route_registry_foreach(collect_paths_walker, &ps);
57✔
310

311
    bb_json_t paths_obj = bb_json_obj_new();
57✔
312
    if (!paths_obj) { bb_json_free(root); return NULL; }
57✔
313

314
    for (size_t i = 0; i < ps.count; i++) {
140✔
315
        bb_json_t path_item = bb_json_obj_new();
84✔
316
        if (!path_item) continue;
84✔
317

318
        pass2_ctx_t p2 = { .path = ps.paths[i], .path_item = path_item };
83✔
319
        bb_http_route_registry_foreach(emit_operations_walker, &p2);
83✔
320

321
        bb_json_obj_set_obj(paths_obj, ps.paths[i], path_item);
83✔
322
    }
323

324
    bb_json_obj_set_obj(root, "paths", paths_obj);
56✔
325

326
    return root;
56✔
327
}
328

329
// ---------------------------------------------------------------------------
330
// Streaming emitter — sends the OpenAPI document chunk-by-chunk so peak memory
331
// is bounded to one operation's JSON tree at a time. Avoids the heap+stack
332
// pressure of materializing the full document on a board with many routes.
333
// ---------------------------------------------------------------------------
334

335
typedef struct {
336
    bb_http_request_t *req;
337
    const char        *path;
338
    bool               first_method;
339
    bb_err_t           err;
340
} stream_path_ctx_t;
341

342
static void stream_operations_walker(const bb_route_t *route, void *ctx)
29✔
343
{
344
    stream_path_ctx_t *sc = (stream_path_ctx_t *)ctx;
29✔
345
    if (sc->err != BB_OK) return;  // LCOV_EXCL_BR_LINE — short-circuit after prior chunk failure
29✔
346
    if (strcmp(route->path, sc->path) != 0) return;
29✔
347

348
    if (!sc->first_method) {
11✔
349
        sc->err = bb_http_resp_send_chunk(sc->req, ",", -1);
1✔
350
        if (sc->err != BB_OK) return;  // LCOV_EXCL_BR_LINE — send_chunk always BB_OK on host
1✔
351
    }
352
    sc->first_method = false;
11✔
353

354
    // Emit "method": — method names are HTTP verbs (lowercase a-z), JSON-safe.
355
    const char *m = method_str(route->method);
11✔
356
    sc->err = bb_http_resp_send_chunk(sc->req, "\"", -1);
11✔
357
    if (sc->err == BB_OK) sc->err = bb_http_resp_send_chunk(sc->req, m, -1);  // LCOV_EXCL_BR_LINE
11✔
358
    if (sc->err == BB_OK) sc->err = bb_http_resp_send_chunk(sc->req, "\":", -1);  // LCOV_EXCL_BR_LINE
11✔
359
    if (sc->err != BB_OK) return;  // LCOV_EXCL_BR_LINE
11✔
360

361
    bb_json_t op = build_operation(route);
11✔
362
    if (!op) {  // LCOV_EXCL_BR_LINE — alloc failure path (covered indirectly by tree-emit alloc-fail tests)
11✔
NEW
363
        sc->err = bb_http_resp_send_chunk(sc->req, "{}", -1);
×
NEW
364
        return;
×
365
    }
366

367
    char *op_str = bb_json_item_serialize(op);
11✔
368
    bb_json_free(op);
11✔
369
    if (op_str) {  // LCOV_EXCL_BR_LINE — bb_json_item_serialize never returns NULL on host
11✔
370
        sc->err = bb_http_resp_send_chunk(sc->req, op_str, -1);
11✔
371
        bb_json_free_str(op_str);
11✔
372
    } else {
NEW
373
        sc->err = bb_http_resp_send_chunk(sc->req, "{}", -1);
×
374
    }
375
}
376

377
// Stream a small bb_json subtree (info, servers) by building it, serializing
378
// it, sending the result, and freeing. Returns BB_OK or first error.
379
static bb_err_t stream_subtree(bb_http_request_t *req, bb_json_t subtree)
5✔
380
{
381
    if (!subtree) return BB_ERR_NO_SPACE;  // LCOV_EXCL_BR_LINE — caller passes live subtree
5✔
382
    char *s = bb_json_item_serialize(subtree);
5✔
383
    bb_json_free(subtree);
5✔
384
    if (!s) return BB_ERR_NO_SPACE;  // LCOV_EXCL_BR_LINE — serialize never fails on host
5✔
385
    bb_err_t err = bb_http_resp_send_chunk(req, s, -1);
5✔
386
    bb_json_free_str(s);
5✔
387
    return err;
5✔
388
}
389

390
bb_err_t bb_openapi_emit_stream(bb_http_request_t *req,
6✔
391
                                const bb_openapi_meta_t *meta)
392
{
393
    if (!req || !meta) return BB_ERR_INVALID_ARG;
6✔
394

395
    bb_openapi_meta_t effective = *meta;
4✔
396
    if (!effective.title)   effective.title   = "breadboard device";
4✔
397
    if (!effective.version) effective.version = "0.0.0";
4✔
398

399
    bb_err_t err = bb_http_resp_send_chunk(req,
4✔
400
        "{\"openapi\":\"3.1.0\",\"info\":", -1);
401
    if (err != BB_OK) return err;  // LCOV_EXCL_BR_LINE — host send_chunk always BB_OK
4✔
402

403
    // info — small fixed-size subtree.
404
    bb_json_t info = bb_json_obj_new();
4✔
405
    if (!info) return BB_ERR_NO_SPACE;  // LCOV_EXCL_BR_LINE — alloc failure path
4✔
406
    bb_json_obj_set_string(info, "title",   effective.title);
4✔
407
    bb_json_obj_set_string(info, "version", effective.version);
4✔
408
    if (effective.description) {
4✔
409
        bb_json_obj_set_string(info, "description", effective.description);
1✔
410
    }
411
    err = stream_subtree(req, info);
4✔
412
    if (err != BB_OK) return err;  // LCOV_EXCL_BR_LINE
4✔
413

414
    // servers — optional, single-element array.
415
    if (effective.server_url) {
4✔
416
        err = bb_http_resp_send_chunk(req, ",\"servers\":", -1);
1✔
417
        if (err != BB_OK) return err;  // LCOV_EXCL_BR_LINE
1✔
418
        bb_json_t servers = bb_json_arr_new();
1✔
419
        bb_json_t server_e = bb_json_obj_new();
1✔
420
        if (!servers || !server_e) {  // LCOV_EXCL_BR_LINE — alloc failure path
1✔
NEW
421
            if (servers)  bb_json_free(servers);   // LCOV_EXCL_BR_LINE — alloc-failure rollback
×
NEW
422
            if (server_e) bb_json_free(server_e);  // LCOV_EXCL_BR_LINE — alloc-failure rollback
×
NEW
423
            return BB_ERR_NO_SPACE;
×
424
        }
425
        bb_json_obj_set_string(server_e, "url", effective.server_url);
1✔
426
        bb_json_arr_append_obj(servers, server_e);
1✔
427
        err = stream_subtree(req, servers);
1✔
428
        if (err != BB_OK) return err;  // LCOV_EXCL_BR_LINE
1✔
429
    }
430

431
    err = bb_http_resp_send_chunk(req, ",\"paths\":{", -1);
4✔
432
    if (err != BB_OK) return err;  // LCOV_EXCL_BR_LINE
4✔
433

434
    path_set_t ps;
435
    memset(&ps, 0, sizeof(ps));
4✔
436
    bb_http_route_registry_foreach(collect_paths_walker, &ps);
4✔
437

438
    for (size_t i = 0; i < ps.count; i++) {
14✔
439
        if (i > 0) {
10✔
440
            err = bb_http_resp_send_chunk(req, ",", -1);
6✔
441
            if (err != BB_OK) return err;  // LCOV_EXCL_BR_LINE
6✔
442
        }
443
        // Paths in the bb_http registry are static const char * literals
444
        // composed of /[a-zA-Z0-9_/-]+/ — JSON-safe without escaping.
445
        err = bb_http_resp_send_chunk(req, "\"", -1);
10✔
446
        if (err == BB_OK) err = bb_http_resp_send_chunk(req, ps.paths[i], -1);  // LCOV_EXCL_BR_LINE
10✔
447
        if (err == BB_OK) err = bb_http_resp_send_chunk(req, "\":{", -1);  // LCOV_EXCL_BR_LINE
10✔
448
        if (err != BB_OK) return err;  // LCOV_EXCL_BR_LINE
10✔
449

450
        stream_path_ctx_t sc = {
10✔
451
            .req = req, .path = ps.paths[i],
10✔
452
            .first_method = true, .err = BB_OK,
453
        };
454
        bb_http_route_registry_foreach(stream_operations_walker, &sc);
10✔
455
        if (sc.err != BB_OK) return sc.err;  // LCOV_EXCL_BR_LINE
10✔
456

457
        err = bb_http_resp_send_chunk(req, "}", -1);
10✔
458
        if (err != BB_OK) return err;  // LCOV_EXCL_BR_LINE
10✔
459
    }
460

461
    return bb_http_resp_send_chunk(req, "}}", -1);
4✔
462
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc