10. API化:JSONレスポンスをつくってみよう

前章では、日記データをHTMLとして表示しました。この章では、同じデータをJSONとして返すAPIを作ります。

APIは、ブラウザで読むためのHTMLではなく、プログラムが読み取るためのデータを返します。C言語CGIでも、Content-Type と出力形式を変えれば、小さなJSON APIを実装できます。


この章で学ぶこと


APIとして返すデータ

第9章の diary テーブルを使い、公開投稿をJSONで返すAPIを考えます。

SELECT id, title, body, created_at
FROM diary
WHERE is_public = 1 AND id > ?
ORDER BY id ASC

レスポンスの例です。

{
  "items": [
    {
      "id": 101,
      "title": "はじめての日記",
      "body": "C言語でCGIを書いた",
      "created_at": "2026-06-01 10:00:00"
    }
  ]
}

トップレベルを配列だけにしても構いませんが、ここでは将来 countnext_after_id などを追加しやすいように、オブジェクトの中に items 配列を入れます。


JSONレスポンスのヘッダ

JSONを返すCGIでは、本文を出す前に次のヘッダを出力します。

printf("Content-Type: application/json; charset=UTF-8\r\n\r\n");

HTTPステータスを指定したい場合は、Content-Type より前に Status を出力します。

printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json; charset=UTF-8\r\n\r\n");

最小のJSON CGI

固定のJSONを返すだけなら、次のように書けます。

#include <stdio.h>

int main(void) {
    printf("Content-Type: application/json; charset=UTF-8\r\n\r\n");
    printf("{\"message\":\"Hello, JSON\"}\n");
    return 0;
}

確認します。

curl -i http://localhost/cgi-bin/hello_json.cgi

整形して見たい場合は jq を使います。

curl -s http://localhost/cgi-bin/hello_json.cgi | jq

JSON文字列をエスケープする

JSON文字列では、"\、改行、制御文字などを適切にエスケープする必要があります。DBから取り出した文字列やユーザー入力を、そのまま printf() で埋め込んではいけません。

この章では、出力先に直接エスケープしながら書き込む関数を使います。

#include <stdio.h>

void json_escape_print(FILE *out, const char *s) {
    for (; *s != '\0'; s++) {
        unsigned char c = (unsigned char)*s;

        switch (c) {
            case '"':
                fputs("\\\"", out);
                break;
            case '\\':
                fputs("\\\\", out);
                break;
            case '\b':
                fputs("\\b", out);
                break;
            case '\f':
                fputs("\\f", out);
                break;
            case '\n':
                fputs("\\n", out);
                break;
            case '\r':
                fputs("\\r", out);
                break;
            case '\t':
                fputs("\\t", out);
                break;
            default:
                if (c < 0x20) {
                    fprintf(out, "\\u%04x", c);
                } else {
                    fputc(c, out);
                }
                break;
        }
    }
}

使うときは、文字列の前後のダブルクォートを呼び出し側で出します。

printf("\"title\":\"");
json_escape_print(stdout, (const char *)title);
printf("\"");

after_id を読む

APIでは、すべての投稿を毎回返すのではなく、指定したIDより新しい投稿だけを返すと扱いやすくなります。

/cgi-bin/diary_api.cgi?after_id=100

after_id は整数として扱います。数字以外が含まれている場合はエラーにします。

#include <ctype.h>
#include <stdlib.h>
#include <string.h>

int parse_after_id(int *out) {
    const char *query = getenv("QUERY_STRING");
    const char *key = "after_id=";
    size_t key_len = strlen(key);

    *out = 0;

    if (query == NULL || query[0] == '\0') {
        return 1;
    }

    const char *p = query;
    while (*p != '\0') {
        if (strncmp(p, key, key_len) == 0) {
            p += key_len;
            break;
        }

        p = strchr(p, '&');
        if (p == NULL) {
            return 1;
        }
        p++;
    }

    if (*p == '\0') {
        return 0;
    }

    int value = 0;
    while (*p != '\0' && *p != '&') {
        if (!isdigit((unsigned char)*p)) {
            return 0;
        }
        value = value * 10 + (*p - '0');
        p++;
    }

    *out = value;
    return 1;
}

この例では、after_id がない場合は 0 とします。after_id=abc のような値は不正として扱います。


エラーをJSONで返す

APIでは、エラー時にもHTMLではなくJSONを返します。

void json_error_response(const char *status, const char *message) {
    printf("Status: %s\r\n", status);
    printf("Content-Type: application/json; charset=UTF-8\r\n\r\n");
    printf("{\"error\":\"");
    json_escape_print(stdout, message);
    printf("\"}\n");
}

使用例です。

int after_id;

if (!parse_after_id(&after_id)) {
    json_error_response("400 Bad Request", "after_id is invalid");
    return 0;
}

DB接続やSQL実行に失敗した場合も、内部のSQL文や接続情報を返さず、クライアントが判定できる範囲のメッセージにします。


SQLはプレースホルダで実行する

after_id は整数として検証していますが、それでもSQL文字列に直接連結する必要はありません。ODBCのプレースホルダで渡します。

SQLCHAR sql[] =
    "SELECT id, title, body, created_at "
    "FROM diary "
    "WHERE is_public = 1 AND id > ? "
    "ORDER BY id ASC";

SQLINTEGER after_id_value = after_id;
SQLLEN after_id_ind = 0;

ret = SQLPrepare(stmt, sql, SQL_NTS);
if (!odbc_succeeded(ret)) {
    json_error_response("500 Internal Server Error", "failed to prepare query");
    goto cleanup;
}

ret = SQLBindParameter(
    stmt,
    1,
    SQL_PARAM_INPUT,
    SQL_C_SLONG,
    SQL_INTEGER,
    0,
    0,
    &after_id_value,
    0,
    &after_id_ind
);
if (!odbc_succeeded(ret)) {
    json_error_response("500 Internal Server Error", "failed to bind parameter");
    goto cleanup;
}

ret = SQLExecute(stmt);
if (!odbc_succeeded(ret)) {
    json_error_response("500 Internal Server Error", "failed to execute query");
    goto cleanup;
}

SELECT結果をJSON配列にする

SQLFetch() で1行ずつ取り出し、カンマの位置に注意しながらJSONを出力します。

SQLINTEGER id;
SQLCHAR title[256];
SQLCHAR body[4096];
SQLCHAR created_at[32];
SQLLEN id_ind;
SQLLEN title_ind;
SQLLEN body_ind;
SQLLEN created_ind;
int first = 1;

printf("Content-Type: application/json; charset=UTF-8\r\n\r\n");
printf("{\"items\":[");

while ((ret = SQLFetch(stmt)) != SQL_NO_DATA) {
    if (!odbc_succeeded(ret)) {
        printf("]}\n");
        goto cleanup;
    }

    SQLGetData(stmt, 1, SQL_C_SLONG, &id, 0, &id_ind);
    SQLGetData(stmt, 2, SQL_C_CHAR, title, sizeof(title), &title_ind);
    SQLGetData(stmt, 3, SQL_C_CHAR, body, sizeof(body), &body_ind);
    SQLGetData(stmt, 4, SQL_C_CHAR, created_at, sizeof(created_at), &created_ind);

    if (!first) {
        printf(",");
    }
    first = 0;

    printf("{\"id\":%d,", id);

    printf("\"title\":\"");
    json_escape_print(stdout, (const char *)title);
    printf("\",");

    printf("\"body\":\"");
    json_escape_print(stdout, (const char *)body);
    printf("\",");

    printf("\"created_at\":\"");
    json_escape_print(stdout, (const char *)created_at);
    printf("\"}");
}

printf("]}\n");

このコード片では見通しを優先し、SQLGetData() の戻り値やNULL値の扱いを省略しています。実際のコードでは、SQL_NULL_DATA を確認し、NULLなら null を出すか空文字にするかを決めます。

また、いったん Content-Type とJSON本文を出力し始めると、その後で 500 Internal Server Error に切り替えることはできません。厳密に実装する場合は、SQL実行が成功してからヘッダを出す、または一度メモリや一時ファイルに結果を組み立ててから出力します。


レスポンス形式を揃える

APIは、正常時と異常時の形式が揃っていると扱いやすくなります。

正常時の例です。

{
  "items": []
}

エラー時の例です。

{
  "error": "after_id is invalid"
}

HTTPステータスも合わせて使います。

状況 ステータス ボディ
正常 200 OK {"items":[...]}
クエリ不正 400 Bad Request {"error":"..."}
認証が必要 401 Unauthorized {"error":"..."}
権限なし 403 Forbidden {"error":"..."}
DBエラー 500 Internal Server Error {"error":"..."}

Apache設定との関係

CGIが返す Content-Type は、プログラム側で出力します。.cgi という拡張子でも、次のヘッダを出せばJSONとして扱われます。

printf("Content-Type: application/json; charset=UTF-8\r\n\r\n");

.api のような拡張子でCGIを動かしたい場合は、Apache側で AddHandler の設定が必要です。

AddHandler cgi-script .cgi .api

まずは通常の .cgi のままJSONを返す方が、CGIとJSONの関係を理解しやすいです。


動作確認

curl でヘッダも含めて確認します。

curl -i "http://localhost/cgi-bin/diary_api.cgi?after_id=100"

JSONの形を確認します。

curl -s "http://localhost/cgi-bin/diary_api.cgi?after_id=100" | jq

不正な値も試します。

curl -i "http://localhost/cgi-bin/diary_api.cgi?after_id=abc"

この場合は 400 Bad Request とJSONエラーが返るのが期待される挙動です。


小まとめ

次章のAppendixでは、開発環境、Apache設定、ODBC設定、デプロイ時の注意点をまとめます。