Skip to content

File SpaceTrackClient.cpp

File List > astrea > snapshot > snapshot > http-queries > spacetrack > SpaceTrackClient.cpp

Go to the documentation of this file

/*
 * The GNU Lesser General Public License (LGPL)
 *
 * Copyright (c) 2025 Jay Iuliano
 *
 * This file is part of Astrea.
 * Astrea is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License
 * as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
 * Astrea is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
 * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should
 * have received a copy of the GNU General Public License along with Astrea. If not, see <https://www.gnu.org/licenses/>.
 */

#include <snapshot/http-queries/spacetrack/SpaceTrackClient.hpp>

#include <chrono>
#include <iostream>
#include <set>
#include <stdexcept>

#include <date/date.h> // NOTE: This is standard in std::chrono as of GNU 13.2
#include <nlohmann/json.hpp>

#include <utilities/string_util.hpp>

namespace astrea {
namespace snapshot {

void SpaceTrackClient::login(const std::string& username, const std::string& password)
{
    if (username.empty()) { throw std::runtime_error("Error: No username provided."); }
    if (password.empty()) { throw std::runtime_error("Error: No password provided."); }

    cpr::Payload loginParams    = { { "identity", username }, { "password", password } };
    cpr::Response loginResponse = cpr::Post(_loginUrl, loginParams);
    _loginCookies               = loginResponse.cookies;
}

nlohmann::json SpaceTrackClient::query(
    const std::string& username,
    const std::string& password,
    const Controller& controller,
    const Action& action,
    const SpaceTrackClient::RequestClass& requestClass,
    const std::vector<std::pair<std::string, std::string>> predicates
)
{
    cpr::Url queryUrl = build_query_url(controller, action, requestClass, predicates);
    return query_impl(username, password, queryUrl);
}

nlohmann::json SpaceTrackClient::retrieve_all(const std::string& username, const std::string& password)
{
    cpr::Url queryUrl = "https://www.space-track.org/"
                        "basicspacedata/query/class/gp/object_type/payload/decay_date/null-val/epoch/%3Enow-30/orderby/"
                        "norad_cat_id/format/json";
    return query_impl(username, password, queryUrl);
}

std::string SpaceTrackClient::controller_to_string(const Controller& controller) const
{
    switch (controller) {
        case (Controller::BASIC_SPACE_DATA): {
            return "basicspacedata";
        }
        case (Controller::PUBLIC_FILES): {
            return "publicfiles";
        }
    }
    throw std::runtime_error("Unregonized contoller requested.");
}

std::string SpaceTrackClient::action_to_string(const Action& action) const
{
    switch (action) {
        case (Action::QUERY): {
            return "query";
        }
        case (Action::MODEL_DEF): {
            return "modeldef";
        }
    }
    throw std::runtime_error("Unregonized action requested.");
}

std::string SpaceTrackClient::class_to_string(const RequestClass& requestClass) const
{
    return std::visit([&](const auto& x) -> std::string { return class_to_string(x); }, requestClass);
}

std::string SpaceTrackClient::class_to_string(const SpaceDataClass& requestClass) const
{
    switch (requestClass) {
        case (SpaceDataClass::ANNOUNCEMENT): {
            return "announcement";
        }
        case (SpaceDataClass::BOX_SCORE): {
            return "boxscore";
        }
        case (SpaceDataClass::CDM_PUBLIC): {
            return "cdm_public";
        }
        case (SpaceDataClass::DECAY): {
            return "decay";
        }
        case (SpaceDataClass::GP): {
            return "gp";
        }
        case (SpaceDataClass::GP_HISTORY): {
            return "gp_history";
        }
        case (SpaceDataClass::LAUNCH_SITE): {
            return "launch_site";
        }
        case (SpaceDataClass::SATCAT): {
            return "satcat";
        }
        case (SpaceDataClass::SATCAT_CHANGE): {
            return "satcat_change";
        }
        case (SpaceDataClass::SATCAT_DEBUT): {
            return "satcat_debut";
        }
        case (SpaceDataClass::TIP): {
            return "tip";
        }
    }
    throw std::runtime_error("Unexpected request class for Space Data controller.");
}

std::string SpaceTrackClient::class_to_string(const PublicFilesClass& requestClass) const
{
    switch (requestClass) {
        case (PublicFilesClass::DIRS): {
            return "dirs";
        }
        case (PublicFilesClass::DOWNLOAD): {
            return "download";
        }
        case (PublicFilesClass::FILES): {
            return "files";
        }
        case (PublicFilesClass::LOAD_PUBLIC_DATA): {
            return "loadpublicdata";
        }
    }
    throw std::runtime_error("Unexpected request class for Public Files controller.");
}

cpr::Url SpaceTrackClient::build_query_url(
    const Controller& controller,
    const Action& action,
    const SpaceTrackClient::RequestClass& requestClass,
    const std::vector<std::pair<std::string, std::string>> predicates
) const
{
    cpr::Url url = _base + "/" + controller_to_string(controller) + "/" + action_to_string(action) + "/class/" +
                   class_to_string(requestClass);
    for (const auto& [predicate, value] : predicates) {
        url += "/" + predicate + "/" + value;
    }
    return url;
}

void SpaceTrackClient::check_query_history(const std::string& username) const
{
    // Query limits - https://www.space-track.org/documentation#api
    static const std::size_t MAX_QUERIES_PER_MINUTE = 30;
    static const std::size_t MAX_QUERIES_PER_HOUR   = 300;
    static const std::filesystem::path QUERY_HISTORY_FILE =
        "./astrea/snapshot/snapshot/database/spacetrack.query-history.json";
    static const std::string TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S";

    // Ingest query history
    nlohmann::json queryHistory;
    if (std::filesystem::exists(QUERY_HISTORY_FILE)) {
        std::ifstream queryHistroyStream(QUERY_HISTORY_FILE);
        queryHistory = nlohmann::json::parse(queryHistroyStream);
    }

    // Now
    using Time     = std::chrono::sys_time<std::chrono::milliseconds>;
    const Time now = std::chrono::time_point_cast<std::chrono::milliseconds>(std::chrono::system_clock::now());

    // Check query frequency
    if (queryHistory.contains(username)) {
        const Time oneMinuteAgo = now - std::chrono::minutes(1);
        const Time oneHourAgo   = now - std::chrono::hours(1);

        std::size_t idx         = 0;
        std::size_t nLastHour   = 0;
        std::size_t nLastMinute = 0;
        std::set<std::size_t> oldQueries;
        for (const auto& timestamp : queryHistory[username]) {
            // Stream date string into time point
            std::istringstream timestampStream{ timestamp.template get<std::string>() };
            Time queryTime;
            timestampStream >> date::parse(TIMESTAMP_FORMAT, queryTime);

            if (queryTime < oneHourAgo) { oldQueries.insert(idx); }
            else {
                ++nLastHour;
                if (queryTime >= oneMinuteAgo) { ++nLastMinute; }
            }
            ++idx;
        }

        // Clean old queries
        for (auto it = oldQueries.rbegin(); it != oldQueries.rend(); ++it) {
            queryHistory[username].erase(*it);
        }

        // Throw
        if (nLastHour >= MAX_QUERIES_PER_HOUR) {
            throw std::runtime_error("Error: Maximum number of hourly queries reached (300). Exiting so SpaceTrack "
                                     "doesn't ban you.");
        }
        if (nLastMinute >= MAX_QUERIES_PER_MINUTE) {
            throw std::runtime_error("Error: Maximum number of queries per minute reached (30). Exiting so SpaceTrack "
                                     "doesn't ban you.");
        }
    }

    // If it didn't throw, log the query time
    std::ostringstream outStream;
    outStream << now;
    queryHistory[username].push_back(outStream.str());

    // Save
    std::ofstream outFileStream(QUERY_HISTORY_FILE);
    outFileStream << std::setw(4) << queryHistory << std::endl;
}

bool SpaceTrackClient::valid_cookies() const
{
    if (_loginCookies.empty()) { return false; }
    for (const auto& cookie : _loginCookies) {
        auto now = std::chrono::system_clock::now();
        if (now >= cookie.GetExpires()) { return false; }
    }
    return true;
}

nlohmann::json SpaceTrackClient::query_impl(const std::string& username, const std::string& password, cpr::Url queryUrl)
{
    // Login
    if (!valid_cookies()) { login(username, password); }

    // Make sure we're not violating the user-agreement
    check_query_history(username);

    // Query
    cpr::Response r = cpr::Get(queryUrl, _loginCookies);

    // Extract response into json
    nlohmann::json response = nlohmann::json::parse(r.text);

    if (response.size() == 0) {
        std::ostringstream errorStream;
        errorStream << "Query failed. No data matching search was found.\n\n";
        errorStream << "Query Data: \n";
        errorStream << "    Status Code: " << std::to_string(r.status_code) << "\n";
        errorStream << "    Text: " << r.text << "\n";
        errorStream << "    Url: " << r.url << "\n";
        errorStream << "    Error: " << r.error.message << "\n";
        errorStream << "    Status Line: " << r.status_line << "\n";
        errorStream << "    Reason: " << r.reason << "\n";
        errorStream << "    Raw Header: \n\n" << r.raw_header;

        std::cout << errorStream.str();
    }

    return response;
}

} // namespace snapshot
} // namespace astrea