From 26f77b586e235c7c9ecbb8c7746c3e0a2b7cff50 Mon Sep 17 00:00:00 2001 From: Rafael Amador Date: Thu, 8 Oct 2026 20:49:46 +0000 Subject: [PATCH] Cover all 233 ESI operations and add opt-in client optimizations Endpoints - Implement the remaining operations of the ESI OpenAPI spec (233 of 233), including the Access List, Activities, Structures and Corporation Projects groups, and extend industry, killmails, market, wallet, cosmetics, faction warfare, freelance jobs, military campaigns, Paragon Hub and user interface. - Replace the near-duplicate page types with a generic `Page`, and merge `KillmailPosition`/`AssetLocationPosition` into `Position`, `CorporationStanding` into `Standing` and `CorporationBlueprint` into `Blueprint`. - Add `Language` and `tenant` request settings, and `EsiError::Gone` for the operations that declare `x-tombstone-ttl`. Client - Add pagination helpers (`query_with_pages`, `fetch_all_pages`, `fetch_all_cursor`) and `post_chunked` for bulk POST endpoints. - Add an opt-in response cache with ETag/Last-Modified revalidation, an LRU eviction order, and entry and byte limits. - Add an opt-in proactive rate-limit policy (`Off`, `Wait`, `Fail`). - Index the spec once for path and operation lookups, and add `ensure_spec_fresh`. - Request compressed responses. Tests and tooling - Add a conformance test against the OpenAPI fixture, cache and throttle tests against a local server, and live checks for the new endpoints. - Add a weekly spec drift workflow. - Bump the version to 0.2.0 and update the changelog and README. --- .env.example | 2 +- .github/workflows/spec-diff.yml | 82 +++ CHANGELOG.md | 54 ++ Cargo.toml | 14 +- README.md | 15 +- TODO.md | 408 ++++++++++++++ src/builders.rs | 133 +++++ src/cache.rs | 382 +++++++++++++ src/client.rs | 742 +++++++++++++++++++++++-- src/cursor_page.rs | 135 +++++ src/errors.rs | 11 + src/groups/access_list.rs | 128 +++++ src/groups/activities.rs | 134 +++++ src/groups/alliance.rs | 18 +- src/groups/assets.rs | 37 +- src/groups/calendar.rs | 189 +++++++ src/groups/character.rs | 423 +++++++++++++-- src/groups/clones.rs | 10 +- src/groups/contacts.rs | 167 ++++++ src/groups/contracts.rs | 346 +++++++++++- src/groups/corporation.rs | 803 +++++++++++++++++++++++++++- src/groups/corporation_projects.rs | 573 ++++++++++++++++++++ src/groups/cosmetics.rs | 299 +++++++++++ src/groups/dogma.rs | 157 +++++- src/groups/faction_warfare.rs | 97 +++- src/groups/fittings.rs | 155 ++++++ src/groups/fleets.rs | 352 ++++++++++++ src/groups/freelance_jobs.rs | 316 +++++++++++ src/groups/incursions.rs | 8 +- src/groups/industry.rs | 223 +++++++- src/groups/insurance.rs | 29 +- src/groups/killmails.rs | 69 +-- src/groups/location.rs | 14 +- src/groups/loyalty.rs | 67 ++- src/groups/mail.rs | 327 ++++++++++- src/groups/market.rs | 283 +++++++++- src/groups/meta.rs | 193 +++++++ src/groups/military_campaigns.rs | 171 ++++++ src/groups/mod.rs | 93 ++++ src/groups/paragon_hub.rs | 189 +++++++ src/groups/planetary_interaction.rs | 302 ++++++++++- src/groups/routes.rs | 84 ++- src/groups/search.rs | 24 +- src/groups/skills.rs | 71 ++- src/groups/sovereignty.rs | 162 +++++- src/groups/status.rs | 35 +- src/groups/structures.rs | 472 ++++++++++++++++ src/groups/universe.rs | 486 +++++++++++++++-- src/groups/user_interface.rs | 109 +++- src/groups/wallet.rs | 152 +++++- src/groups/wars.rs | 96 +++- src/legacy.rs | 18 + src/lib.rs | 3 + src/macros.rs | 414 +++++++++++++- src/prelude.rs | 3 +- src/rate_limiter.rs | 353 ++++++++++++ src/spec.rs | 462 +++++++++++++++- tests/authenticated.rs | 212 +++++++- tests/cache.rs | 607 +++++++++++++++++++++ tests/common/mod.rs | 4 +- tests/conformance.rs | 696 ++++++++++++++++++++++++ tests/fleet_write.rs | 113 ++++ 62 files changed, 12406 insertions(+), 320 deletions(-) create mode 100644 .github/workflows/spec-diff.yml create mode 100644 TODO.md create mode 100644 src/cache.rs create mode 100644 src/cursor_page.rs create mode 100644 src/groups/access_list.rs create mode 100644 src/groups/activities.rs create mode 100644 src/groups/corporation_projects.rs create mode 100644 src/groups/cosmetics.rs create mode 100644 src/groups/freelance_jobs.rs create mode 100644 src/groups/meta.rs create mode 100644 src/groups/military_campaigns.rs create mode 100644 src/groups/paragon_hub.rs create mode 100644 src/groups/structures.rs create mode 100644 src/rate_limiter.rs create mode 100644 tests/cache.rs create mode 100644 tests/conformance.rs create mode 100644 tests/fleet_write.rs diff --git a/.env.example b/.env.example index 35557cb..4d3d4b8 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ ESI_CLIENT_SECRET= # Must match the callback URL registered for the application. ESI_CALLBACK_URL=http://localhost:5000/esi/callback # Space-separated scopes requested at login (keep the quotes). -ESI_SCOPES="esi-assets.read_assets.v1 esi-characters.read_blueprints.v1 esi-characters.read_notifications.v1 esi-clones.read_clones.v1 esi-clones.read_implants.v1 esi-industry.read_character_jobs.v1 esi-killmails.read_killmails.v1 esi-location.read_location.v1 esi-location.read_online.v1 esi-location.read_ship_type.v1 esi-mail.read_mail.v1 esi-markets.read_character_orders.v1 esi-search.search_structures.v1 esi-skills.read_skills.v1 esi-universe.read_structures.v1 esi-wallet.read_character_wallet.v1" +ESI_SCOPES="esi-access.read_lists.v1 esi-activities.read_character.v1 esi-alliances.read_contacts.v1 esi-assets.read_assets.v1 esi-assets.read_corporation_assets.v1 esi-calendar.read_calendar_events.v1 esi-calendar.respond_calendar_events.v1 esi-characters.read_agents_research.v1 esi-characters.read_blueprints.v1 esi-characters.read_contacts.v1 esi-characters.read_corporation_roles.v1 esi-characters.read_fatigue.v1 esi-characters.read_freelance_jobs.v1 esi-characters.read_fw_stats.v1 esi-characters.read_loyalty.v1 esi-characters.read_medals.v1 esi-characters.read_notifications.v1 esi-characters.read_standings.v1 esi-characters.read_titles.v1 esi-characters.write_contacts.v1 esi-clones.read_clones.v1 esi-clones.read_implants.v1 esi-contracts.read_character_contracts.v1 esi-contracts.read_corporation_contracts.v1 esi-corporations.read_blueprints.v1 esi-corporations.read_contacts.v1 esi-corporations.read_container_logs.v1 esi-corporations.read_corporation_membership.v1 esi-corporations.read_divisions.v1 esi-corporations.read_facilities.v1 esi-corporations.read_freelance_jobs.v1 esi-corporations.read_fw_stats.v1 esi-corporations.read_medals.v1 esi-corporations.read_projects.v1 esi-corporations.read_standings.v1 esi-corporations.read_starbases.v1 esi-corporations.read_structures.v1 esi-corporations.read_titles.v1 esi-corporations.track_members.v1 esi-fittings.read_fittings.v1 esi-fittings.write_fittings.v1 esi-fleets.read_fleet.v1 esi-fleets.write_fleet.v1 esi-industry.read_character_jobs.v1 esi-industry.read_character_mining.v1 esi-industry.read_corporation_jobs.v1 esi-industry.read_corporation_mining.v1 esi-killmails.read_corporation_killmails.v1 esi-killmails.read_killmails.v1 esi-location.read_location.v1 esi-location.read_online.v1 esi-location.read_ship_type.v1 esi-mail.organize_mail.v1 esi-mail.read_mail.v1 esi-mail.send_mail.v1 esi-markets.read_character_orders.v1 esi-markets.read_corporation_orders.v1 esi-markets.structure_markets.v1 esi-planets.manage_planets.v1 esi-planets.read_customs_offices.v1 esi-search.search_structures.v1 esi-skills.read_skillqueue.v1 esi-skills.read_skills.v1 esi-structures.read_character.v1 esi-structures.read_corporation.v1 esi-ui.open_window.v1 esi-ui.write_waypoint.v1 esi-universe.read_structures.v1 esi-wallet.read_character_wallet.v1 esi-wallet.read_corporation_wallets.v1 esi.activity.char:read esi.cosmetic.char:read" # Refresh token for the test character; written by the auth_get_refresh_token # example and updated automatically when EVE SSO rotates it. ESI_REFRESH_TOKEN= diff --git a/.github/workflows/spec-diff.yml b/.github/workflows/spec-diff.yml new file mode 100644 index 0000000..512446b --- /dev/null +++ b/.github/workflows/spec-diff.yml @@ -0,0 +1,82 @@ +name: Spec drift + +# Compares the live ESI OpenAPI spec with the fixture in +# `resources/test/openapi.json` and opens (or updates) an issue when they +# differ, or when ESI offers a compatibility date newer than the one the crate +# uses. It runs weekly and can be run by hand. +# +# The issue only reports the difference; updating the fixture and the code is +# done in a pull request. +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + spec-diff: + name: spec-diff + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + GH_TOKEN: ${{ github.token }} + ESI: https://esi.evetech.net + steps: + - uses: actions/checkout@v4 + + - name: Read the compatibility date the crate uses + id: crate + run: | + date=$(grep -oP 'COMPATIBILITY_DATE_DEFAULT: &str = "\K[0-9-]+' src/client.rs) + test -n "$date" + echo "date=$date" >> "$GITHUB_OUTPUT" + + - name: Download the live spec and the available dates + run: | + curl -fsS --retry 3 -H "X-Compatibility-Date: ${{ steps.crate.outputs.date }}" \ + "$ESI/meta/openapi.json" -o live.json + curl -fsS --retry 3 "$ESI/meta/compatibility-dates" -o dates.json + + - name: Compare + id: diff + run: | + jq -S . live.json > live.sorted.json + jq -S . resources/test/openapi.json > fixture.sorted.json + jq -r '.paths | to_entries[] | .key as $p | .value | to_entries[] | select(.value|type=="object" and has("operationId")) | "\(.value.operationId) \(.key|ascii_upcase) \($p)"' live.json | sort > live.ops + jq -r '.paths | to_entries[] | .key as $p | .value | to_entries[] | select(.value|type=="object" and has("operationId")) | "\(.value.operationId) \(.key|ascii_upcase) \($p)"' resources/test/openapi.json | sort > fixture.ops + newest=$(jq -r '(.compatibility_dates // .) | max' dates.json) + { + echo "## Spec drift" + echo + echo "Compatibility date used by the crate: \`${{ steps.crate.outputs.date }}\`. Newest offered by ESI: \`$newest\`." + echo + echo "### Operations added or changed upstream" + echo '```' + comm -13 fixture.ops live.ops + echo '```' + echo "### Operations removed or changed locally" + echo '```' + comm -23 fixture.ops live.ops + echo '```' + } > report.md + changed=false + cmp -s live.sorted.json fixture.sorted.json || changed=true + newer=false + [ "$newest" \> "${{ steps.crate.outputs.date }}" ] && newer=true + echo "changed=$changed" >> "$GITHUB_OUTPUT" + echo "newer=$newer" >> "$GITHUB_OUTPUT" + cat report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Open or update the issue + if: steps.diff.outputs.changed == 'true' || steps.diff.outputs.newer == 'true' + run: | + title="ESI spec drift" + number=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$title\" in:title" --json number,title --jq "map(select(.title==\"$title\"))[0].number // empty") + if [ -n "$number" ]; then + gh issue comment "$number" --repo "$GITHUB_REPOSITORY" --body-file report.md + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body-file report.md + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 348a123..dc58077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,60 @@ All notable changes to this project are documented here. This project is a fork of [rfesi](https://github.com/Celeo/rfesi) 0.50.2; versioning restarts at 0.1.0. +## [Unreleased] + +## [0.2.0] - 2026-10-08 + +### Added + +- New endpoint groups: `Esi::group_meta()` (`GetMetaChangelog`, `GetMetaCompatibilityDates`, `GetMetaName`, `GetMetaStatus`). +- `StatusGroup::get_status`, `WarsGroup` (`list`, `get_war`, `get_war_killmails`), `SovereigntyGroup` (`get_campaigns`, `get_systems`) and `InsuranceGroup::get_prices`. +- `DogmaGroup` (attributes, effects, dynamic items), `RoutesGroup::calculate_route` (`RouteRequest`, `RoutePreference`), `LoyaltyGroup::get_store_offers`, `ContractsGroup` public contracts (listing, items, bids), `IndustryGroup::get_facilities` and `MarketGroup` (`get_groups`, `get_group`, `get_region_types`). +- New groups: `Esi::group_freelance_jobs()` (`list`, `get_job`) and `Esi::group_military_campaigns()` (`list`, `get_campaign`, `list_objectives`, `get_objective`), with a shared `Cursor` type for cursor pagination. +- Fields that the spec defines as enums are Rust enums with an `Unrecognized` fallback variant (`HttpMethod`, `ChangeType`, `RouteHealth`, `CampaignEventType`, `ContractType`, `JobState`, `JobCareer`, `CampaignState`). +- `UniverseGroup`: ancestries, bloodlines, races, factions, graphics, categories, group IDs, asteroid belts, moons, planets, stars, stargates, public structures, system jumps and kills, and name resolution (`get_names`), with `StructureFilter` and `NameCategory`. +- New groups: `Esi::group_cosmetics()` (`get_skinr`) and `Esi::group_paragon_hub()` (`get_skinr_listings`); `PlanetaryInteractionGroup::get_schematic`. +- Authenticated character endpoints: `SkillsGroup` (`get_attributes`, `get_skillqueue`), `CharacterGroup` (`get_agents_research`, `get_fatigue`, `get_medals`, `get_contact_notifications`, `get_roles`, `get_standings`, `get_titles`, `calculate_cspa_charge`), `WalletGroup::get_journal`, `LoyaltyGroup::get_character_points` and `FactionWarfareGroup::get_character_stats`. Their `character_id` is `i64`. +- `MailGroup` (mail list and details, send, update, delete, labels create/delete, mailing lists) and `ContactsGroup` (character, corporation and alliance contacts and labels, add/edit/delete character contacts), with `RecipientType`, `MailLabelColor` and `ContactType` enums. `MailLabel::label_id` is now `i64`. +- `api_get!`, `api_post!`, `api_put!` and `api_delete!` accept tagged query parameters (`Required`, `Optional`, `Many`, `OptionalMany`); list parameters are sent as repeated keys. +- `CalendarGroup` (events, event details, respond, attendees) and `FittingsGroup` (list, create, delete), with `EventResponse`, `EventOwnerType`, `EventReply` and `FittingFlag` enums. +- `FleetsGroup` (character fleet, settings, members, wings and squads: invite, kick, move, create, rename, delete), with a `FleetRole` enum. `api_post!` accepts `; NoBody` for POST requests without a body. +- Character and corporation contracts (list, bids, items) with `ContractStatus` and `ContractAvailability` enums; `PlanetaryInteractionGroup` colonies (`get_character_planets`, `get_character_planet`) and `get_corporation_customs_offices`, with `PlanetType` and `StandingLevel` enums. +- `CorporationGroup`: blueprints, container logs, divisions, facilities, icons, medals, issued medals, member limit, member titles, member tracking, roles, roles history, shareholders, standings, starbases, structures and titles, with enums for container actions, role types, starbase and structure states (all with an `Unrecognized` fallback). `CorporationRole`, `MedalStatus` and `StandingSource` now also implement `Serialize`. +- **Breaking:** every integer the spec defines as `int64` is now `i64` in the group types and method parameters (previously `i32`, `u32`, `u64`, `u16` or `u8` in many places). `page` stays `i32`. +- `Structures` group: character mercenary dens, corporation skyhooks and sovereignty hubs (listing and detail). +- `CorporationProjects` group: project listing, detail, contribution and contributors, with cursor pagination (`Cursor`). +- Industry mining (character, corporation extractions and observers) and corporation industry jobs; corporation killmails, orders (open and history), structure market orders and character order history; corporation wallets (balances, journal, transactions). New enums `OrderRange`, `ClosedOrderState`, `IndustryJobStatus` and `ObserverType`. +- New groups `AccessListGroup` and `ActivitiesGroup`; character SKINR licenses and components, corporation faction warfare stats, character and corporation freelance jobs, character military campaign objectives, Paragon Hub SKINR listings (character, alliance, corporation) and the remaining User Interface calls (autopilot waypoint, contract, information and new mail windows). Enums `AccessLevel`, `TacticalOperationState`, `ParticipationState` and `SkinrComponentType`. All 233 operations of the spec are now covered. +- `tests/conformance.rs` checks every spec operation is implemented and that the HTTP method, path placeholders, query keys and response struct fields match `resources/test/openapi.json`. +- Pagination helpers: `Esi::query_with_pages` (also returns the `X-Pages` header), `Esi::fetch_all_pages` (walks the `page` parameter) and `Esi::fetch_all_cursor` (walks `cursor.after`). `RequestType` is now `Clone` and `Copy`. +- Optional response cache (`EsiBuilder::enable_cache`, off by default): `GET` responses are revalidated with `ETag` / `Last-Modified`, a `304 Not Modified` reuses the stored body, and entries are served without a request while younger than the operation's `x-client-cache-ttl` (or the response `max-age`). `Spec::client_cache_ttl` and `Esi::cache_len` were added. +- `Spec` now reads scopes, rate limits, required roles, pagination and tombstone TTLs per operation, and the OAuth2 scopes of the spec (`Spec::operation`, `Spec::required_scopes`, `Spec::oauth_scopes`, `Spec::scope_string_for`). `Esi::required_scopes`, `Esi::missing_scopes`, `Esi::required_roles` and `Esi::declared_rate_limits` expose them. +- Weekly `spec-diff` workflow that reports drift between the live ESI spec and the test fixture. +- `EsiBuilder::language` (`Language`, the `Accept-Language` header) and `EsiBuilder::tenant` (the `X-Tenant` header). `If-None-Match` and `If-Modified-Since` are sent by the response cache. +- `EsiError::Gone`: returned for HTTP `404` / `410` on the five operations that declare `x-tombstone-ttl` (Paragon Hub SKINR listings), with the tombstone TTL. Which status ESI uses for a tombstone is not in the spec, so both are accepted. +- Responses are requested compressed (`gzip`, `brotli`, `deflate` features of `reqwest`). +- `Esi::fetch_all_cursor` asks for the largest page size the spec allows (`limit=100`) unless the query sets `limit`; `Esi::fetch_all_pages` requests the pages after the first concurrently (`EsiBuilder::page_concurrency`, 4 by default); the response cache holds at most 1024 responses (`EsiBuilder::cache_max_entries`). Only when it is full, it drops expired entries without an `ETag` / `Last-Modified` first, then expired entries that can be revalidated, then live ones, least recently used first within each group. +- `CorporationRole` enum (with an `Unrecognized` fallback). +- The live tests cover the new endpoints; the scopes they need were added to `ESI_SCOPES` in `.env.example`. +- `uuid` is now a dependency (UUID identifiers of freelance jobs and military campaigns). +- `api_put!` and `api_delete!` macros for building PUT and DELETE endpoint functions. +- `EsiBuilder::cache_max_bytes` limits the size of the response cache in bytes, next to `cache_max_entries`; both use the same eviction order, and a response larger than the limit is not cached. `Esi::cache_bytes` reports the size. +- `Esi::post_chunked` sends a `POST` with an array body in chunks (concurrently, in order). The list endpoints with a maximum body length split longer lists automatically: `CharacterGroup::get_affiliation`, `UniverseGroup::get_names` and the character and corporation asset names and locations (1000 items per request, checked against the spec by `tests/conformance.rs`). An empty list sends no request. +- `EsiBuilder::rate_limit_policy` (`RateLimitPolicy`: `Off`, `Wait { max_wait }`, `Fail`; `Off` by default) throttles requests before ESI answers `429`. The client tracks the tokens of each route group and access token from the `X-Ratelimit-*` headers and its own spending, honors `Retry-After`, and either sleeps until the request fits or returns `EsiError::RateLimited` without calling ESI. Only operations that declare an `x-rate-limit` group in the spec are throttled. +- `Esi::ensure_spec_fresh` downloads the spec only when it is missing, was requested with another compatibility date, or its `Cache-Control: max-age` has passed. `Esi::update_spec` still always asks, but sends `If-None-Match` / `If-Modified-Since` when the server gave the spec an `ETag` or `Last-Modified` (it does not today) and keeps the spec on a `304`. + +### Changed + +- Resolving a path to its `x-client-cache-ttl` / `x-tombstone-ttl` and an `operationId` to its metadata uses an index built once with the spec instead of scanning every path on each call (`Spec::get_operation_for_path` still scans and is meant for one-off use). +- `Esi::fetch_all_cursor` reads each page in one pass, straight into the record type, instead of building a JSON tree first. +- **Breaking:** the cursor page types (`ProjectsPage`, `ContributorsPage`, `FreelanceJobsPage`, `JobParticipantsPage`, `ObjectivesPage`, `CharacterObjectivesPage`, `SkinrListingsPage`, `CharacterSkinrListingsPage`) are replaced by one `Page` with `cursor` and `items` (the records, whatever ESI names the array). `KillmailPosition` and `AssetLocationPosition` are now `Position` (which is also `Copy` and `PartialEq`), `CorporationStanding` is `Standing` and `CorporationBlueprint` is `Blueprint`. +- **Breaking:** every `character_id` is now `i64` instead of `i32`: the path parameter of all character methods, the `character_id` fields of `CharacterAffiliation`, `CharacterLeaderboardItem` and the killmail structs, `IndustryJob::completed_character_id`, and the `character_ids` argument of `CharacterGroup::get_affiliation` (now `&[i64]`, was `&[u64]`). + +### Fixed + +- Successful responses with an empty body (such as `204 No Content`) no longer fail to parse when the return type is `()`. + ## [0.1.0] - 2026-09-22 ### Changed diff --git a/Cargo.toml b/Cargo.toml index df08a44..46a98fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "esi-openapi" -version = "0.1.0" +version = "0.2.0" authors = ["Rafael ", "Celeo "] edition = "2021" description = "Rust API for EVE Online's ESI, based on the OpenAPI specification (fork of rfesi)" @@ -16,22 +16,24 @@ categories = ["api-bindings"] base64 = "0.23.1" http = "1.3.1" jsonwebtoken = { version = "11.1.0", optional = true, features = ["rust_crypto"] } +futures-util = { version = "0.3", default-features = false, features = ["alloc"] } log = "0.4.27" rand = "0.10.3" -reqwest = { version = "0.13.5", default-features = false, features = ["json", "charset", "http2", "form", "query"] } +reqwest = { version = "0.13.5", default-features = false, features = ["json", "charset", "http2", "form", "query", "gzip", "brotli", "deflate"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" sha2 = "0.11.0" -thiserror = "2.0.20" -tokio = "1" +thiserror = "2.0.21" +tokio = "1.53.2" +uuid = { version = "1.27.0", features = ["serde"] } [dev-dependencies] dotenvy = "0.15.7" pretty_env_logger = "0.5.0" rusty-hook = "0.11.2" -tokio = { version = "1.45.0", features = ["full"] } +tokio = { version = "1.53.2", features = ["full"] } chrono = "0.4.41" -uuid = { version = "1.16.0", features = ["v4", "fast-rng"] } +uuid = { version = "1.27.0", features = ["v4", "fast-rng"] } [features] default = ["random_state", "validate_jwt", "rustls-tls"] diff --git a/README.md b/README.md index f01914f..c591689 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,20 @@ This crate has several features that are enabled by default. [Docs link](https://docs.rs/esi-openapi). -Not every ESI endpoint is mapped to a function yet. Missing endpoints can be called with `Esi::get_endpoint_for_op_id` and `Esi::query`, and PRs adding endpoints are welcome. +### Coverage + +All 233 operations of the ESI OpenAPI spec (compatibility date `2026-08-18`) have a function in an endpoint group, reached through `Esi::group_*()`. `tests/conformance.rs` checks this against `resources/test/openapi.json` and also that each function uses the HTTP method, path placeholders, query keys and response fields of the spec. Anything newer can be called with `Esi::get_endpoint_for_op_id` and `Esi::query`, and PRs are welcome. + +Beyond the endpoints: + +- `Esi::fetch_all_pages` and `Esi::fetch_all_cursor` walk paginated listings; `Esi::query_with_pages` also returns `X-Pages`. +- `EsiBuilder::enable_cache(true)` turns on a response cache that revalidates with `ETag` / `Last-Modified` and honors `x-client-cache-ttl`. `cache_max_entries` and `cache_max_bytes` bound its size. +- `Esi::post_chunked` and the bulk lookups (`get_affiliation`, `get_names`, asset names and locations) split lists longer than the spec's maximum into several requests. +- `EsiBuilder::rate_limit_policy(RateLimitPolicy::Wait { .. } | Fail)` throttles requests against the rate limits ESI reports instead of provoking `429`s (off by default). +- `Esi::ensure_spec_fresh` reloads the spec only when it is stale. +- `EsiBuilder::language` and `EsiBuilder::tenant` set `Accept-Language` and `X-Tenant`. +- `Esi::required_scopes`, `Esi::missing_scopes`, `Esi::required_roles` and `Esi::declared_rate_limits` read the scopes, roles and rate limits that the spec declares for each operation. +- `EsiError::Gone` reports resources ESI keeps as tombstones. ### Compatibility date diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..785f087 --- /dev/null +++ b/TODO.md @@ -0,0 +1,408 @@ +# TODO: parity with the published ESI API + +Baseline: published OpenAPI spec, compatibility date `2026-08-18` (the latest listed by `/meta/compatibility-dates`). `resources/test/openapi.json` is byte-identical to the live spec, so no fixture refresh is needed today. + +Coverage: **233 of 233 operations implemented (0 missing)**. All operation IDs used in `src/groups` exist in the spec. + +## Summary by group + +| Tag | Module | Done | Total | Status | +| --- | --- | --- | --- | --- | +| Access List | `access_list.rs` | 2 | 2 | done | +| Activities | `activities.rs` | 3 | 3 | done | +| Alliance | `alliance.rs` | 4 | 4 | done | +| Assets | `assets.rs` | 6 | 6 | done | +| Calendar | `calendar.rs` | 4 | 4 | done | +| Character | `character.rs` | 14 | 14 | done | +| Clones | `clones.rs` | 2 | 2 | done | +| Contacts | `contacts.rs` | 9 | 9 | done | +| Contracts | `contracts.rs` | 9 | 9 | done | +| Corporation | `corporation.rs` | 22 | 22 | done | +| Corporation Projects | `corporation_projects.rs` | 4 | 4 | done | +| Cosmetics | `cosmetics.rs` | 3 | 3 | done | +| Dogma | `dogma.rs` | 5 | 5 | done | +| Faction Warfare | `faction_warfare.rs` | 8 | 8 | done | +| Fittings | `fittings.rs` | 3 | 3 | done | +| Fleets | `fleets.rs` | 14 | 14 | done | +| Freelance Jobs | `freelance_jobs.rs` | 6 | 6 | done | +| Incursions | `incursions.rs` | 1 | 1 | done | +| Industry | `industry.rs` | 8 | 8 | done | +| Insurance | `insurance.rs` | 1 | 1 | done | +| Killmails | `killmails.rs` | 3 | 3 | done | +| Location | `location.rs` | 3 | 3 | done | +| Loyalty | `loyalty.rs` | 2 | 2 | done | +| Mail | `mail.rs` | 9 | 9 | done | +| Market | `market.rs` | 11 | 11 | done | +| Meta | `meta.rs` | 4 | 4 | done | +| Military Campaigns | `military_campaigns.rs` | 6 | 6 | done | +| Paragon Hub | `paragon_hub.rs` | 5 | 5 | done | +| Planetary Interaction | `planetary_interaction.rs` | 4 | 4 | done | +| Routes | `routes.rs` | 1 | 1 | done | +| Search | `search.rs` | 1 | 1 | done | +| Skills | `skills.rs` | 3 | 3 | done | +| Sovereignty | `sovereignty.rs` | 2 | 2 | done | +| Status | `status.rs` | 1 | 1 | done | +| Structures | `structures.rs` | 6 | 6 | done | +| Universe | `universe.rs` | 30 | 30 | done | +| User Interface | `user_interface.rs` | 5 | 5 | done | +| Wallet | `wallet.rs` | 6 | 6 | done | +| Wars | `wars.rs` | 3 | 3 | done | + +Modules in `src/groups` with no matching tag in the spec: `bookmarks`, `opportunities` (decide: remove, or keep as empty groups for API stability). + +## Conventions used for new code + +- Integers: any value the spec defines as `integer` with `format: int64` is `i64`; `page` (the only `int32` in the spec) is `i32`. Never `u32`/`u64`. +- Enums in the spec are Rust enums with an `Unrecognized` variant (`#[serde(other)]`) so a new value from ESI does not fail parsing. +- UUIDs are `uuid::Uuid` (the `uuid` crate is a regular dependency). +- Free-form values with a very large set of variants (e.g. a star's `spectral_class`, about 90 values) stay `String`; the allowed values are documented on the field. +- Date-times stay `String`, like the rest of the crate. +- Every `character_id` (path parameters and struct fields) is `i64`, as are `completed_character_id` and the `character_ids` body of `CharacterGroup::get_affiliation`. Other character-valued IDs with a different name (`sender_id`, `client_id`, `owner_id`...) were not reviewed. +- Large enums: corporation roles are a Rust enum; the wallet journal `ref_type` (162 values) stays `String`. +- Live tests in `tests/authenticated.rs` fail on a `403`, so a refresh token obtained before the new scopes were added to `ESI_SCOPES` has to be regenerated (`auth_get_refresh_token`). +- No free functions; helpers live in `impl` blocks. + +## Phase 0: infrastructure (do first, everything else depends on it) + +- [x] `api_put!` and `api_delete!` macros (added). Tagged query parameters (`Required`/`Optional`/`Many`/`OptionalMany`) are supported in all four macros. +- [x] Shared request parameters: `EsiBuilder::language` (`Accept-Language`) and `EsiBuilder::tenant` (`X-Tenant`) are sent on every request and are part of the cache key; `If-None-Match` / `If-Modified-Since` are sent by the response cache; `X-Compatibility-Date` was already sent. +- [x] Conditional requests: opt-in response cache (`EsiBuilder::enable_cache`, `src/cache.rs`) that stores `ETag`/`Last-Modified` per token+URL+query, sends `If-None-Match`/`If-Modified-Since`, reuses the body on `304 Not Modified` (transparent: no separate result type) and honors `x-client-cache-ttl` (falls back to `Cache-Control: max-age`). +- [x] Pagination helpers in `client.rs`: `Esi::query_with_pages` (returns `X-Pages`), `Esi::fetch_all_pages` (`page` parameter) and `Esi::fetch_all_cursor` (the 12 `x-pagination: cursor` operations). +- [x] `Spec` / `SpecPathMethod` now read `security` (scopes), `x-rate-limit`, `x-required-roles`, `x-pagination`, `x-tombstone-ttl` and `x-client-cache-ttl`. Helpers: `Esi::required_scopes`, `Esi::missing_scopes` (fail early), `Esi::required_roles`, `Esi::declared_rate_limits` (static budgets per group; live values stay in `rate_limit_status`). +- [x] `x-tombstone-ttl` operations (5): `EsiError::Gone { status, tombstone_ttl_secs }` for HTTP 404/410 on those operations. The spec does not say which status ESI uses, so both are accepted; confirm against the live API. +- [x] Scope list: `Spec::oauth_scopes` and `Spec::scope_string_for`; `tests/conformance.rs` checks `ESI_SCOPES` in `.env.example` against the spec and that it covers every scope an operation needs. +- [x] Conformance test (`tests/conformance.rs`): for every implemented operation, check method, path placeholders, query keys and the struct fields against the spec (`components/schemas`, 349 schemas). Earlier fixes (`Killmail`, `MailLabels`, `Clones`) show hand-written structs drift; this test would have caught them. Consider generating structs from the schemas instead of writing them by hand. +- [x] Coverage test (`tests/conformance.rs`): fail CI when an operation in the fixture has no implementation, using an explicit allow-list for operations deliberately skipped. +- [x] Scheduled CI job (`.github/workflows/spec-diff.yml`, weekly and manual) diffs the live spec against `resources/test/openapi.json` and opens or updates an "ESI spec drift" issue when they differ or a newer compatibility date appears. +- [x] Live tests in `tests/authenticated.rs` for the authenticated read endpoints that need no ID from an earlier call (character and corporation checks); detail endpoints and write calls are not covered. `tests/conformance.rs` and `tests/cache.rs` cover the rest without network. + +- [x] Empty successful responses (e.g. `204 No Content`) are read as JSON `null`, so `()` return types work for write endpoints. + +## Suggested order + +1. **Phase 0** (above). +2. **Public endpoints**, no auth needed: Status, Meta, Dogma, Wars, Sovereignty, Insurance, Routes, Freelance Jobs, Military Campaigns, remaining Universe, Market (groups, types), Contracts (public), Loyalty (offers), Industry (facilities), Paragon Hub and Cosmetics (public ones). +3. **Authenticated character endpoints**: Skills, Character, Mail, Contacts, Calendar, Fittings, Fleets, Contracts, Planetary Interaction, Wallet, Loyalty, Access List, Activities. +4. **Corporation / alliance endpoints** (many need `roles`): Corporation, Structures, Corporation Projects, Industry, Killmails, Market orders, Wallet. +5. Write endpoints (PUT/POST/DELETE) last in each group, since they need the Phase 0 macros and are the riskiest to test live. + +## Operations by group + +Legend: `[x]` implemented. Scope in parentheses; `roles` = `x-required-roles`; `paged` = `x-pagination`; `tombstone` = `x-tombstone-ttl`. Order within a group follows the path. + +### Access List (2/2) → `src/groups/access_list.rs` +- [x] `GET /characters/{character_id}/access-lists` — `GetCharactersAccessListsListing` (esi-access.read_lists.v1) +- [x] `GET /characters/{character_id}/access-lists/{access_list_id}` — `GetCharactersAccessListsDetail` (esi-access.read_lists.v1) + +### Activities (3/3) → `src/groups/activities.rs` +- [x] `GET /characters/{character_id}/mercenary-tactical-operations` — `GetCharactersMercenaryTacticalOperationsListing` (esi-activities.read_character.v1) +- [x] `GET /characters/{character_id}/mercenary-tactical-operations/{operation_id}` — `GetCharactersMercenaryTacticalOperationsDetail` (esi-activities.read_character.v1) +- [x] `GET /skyhooks/raidable` — `GetSkyhooksRaidable` + +### Alliance (4/4) → `src/groups/alliance.rs` +- [x] `GET /alliances` — `GetAlliances` +- [x] `GET /alliances/{alliance_id}` — `GetAlliancesAllianceId` +- [x] `GET /alliances/{alliance_id}/corporations` — `GetAlliancesAllianceIdCorporations` +- [x] `GET /alliances/{alliance_id}/icons` — `GetAlliancesAllianceIdIcons` + +### Assets (6/6) → `src/groups/assets.rs` +- [x] `GET /characters/{character_id}/assets` — `GetCharactersCharacterIdAssets` (esi-assets.read_assets.v1) +- [x] `POST /characters/{character_id}/assets/locations` — `PostCharactersCharacterIdAssetsLocations` (esi-assets.read_assets.v1) +- [x] `POST /characters/{character_id}/assets/names` — `PostCharactersCharacterIdAssetsNames` (esi-assets.read_assets.v1) +- [x] `GET /corporations/{corporation_id}/assets` — `GetCorporationsCorporationIdAssets` (esi-assets.read_corporation_assets.v1; roles: Director) +- [x] `POST /corporations/{corporation_id}/assets/locations` — `PostCorporationsCorporationIdAssetsLocations` (esi-assets.read_corporation_assets.v1; roles: Director) +- [x] `POST /corporations/{corporation_id}/assets/names` — `PostCorporationsCorporationIdAssetsNames` (esi-assets.read_corporation_assets.v1; roles: Director) + +### Calendar (4/4) → `src/groups/calendar.rs` +- [x] `GET /characters/{character_id}/calendar` — `GetCharactersCharacterIdCalendar` (esi-calendar.read_calendar_events.v1) +- [x] `GET /characters/{character_id}/calendar/{event_id}` — `GetCharactersCharacterIdCalendarEventId` (esi-calendar.read_calendar_events.v1) +- [x] `PUT /characters/{character_id}/calendar/{event_id}` — `PutCharactersCharacterIdCalendarEventId` (esi-calendar.respond_calendar_events.v1) +- [x] `GET /characters/{character_id}/calendar/{event_id}/attendees` — `GetCharactersCharacterIdCalendarEventIdAttendees` (esi-calendar.read_calendar_events.v1) + +### Character (14/14) → `src/groups/character.rs` +- [x] `POST /characters/affiliation` — `PostCharactersAffiliation` +- [x] `GET /characters/{character_id}` — `GetCharactersDetail` +- [x] `GET /characters/{character_id}/agents_research` — `GetCharactersCharacterIdAgentsResearch` (esi-characters.read_agents_research.v1) +- [x] `GET /characters/{character_id}/blueprints` — `GetCharactersCharacterIdBlueprints` (esi-characters.read_blueprints.v1) +- [x] `GET /characters/{character_id}/corporationhistory` — `GetCharactersCharacterIdCorporationhistory` +- [x] `POST /characters/{character_id}/cspa` — `PostCharactersCharacterIdCspa` (esi-characters.read_contacts.v1) +- [x] `GET /characters/{character_id}/fatigue` — `GetCharactersCharacterIdFatigue` (esi-characters.read_fatigue.v1) +- [x] `GET /characters/{character_id}/medals` — `GetCharactersCharacterIdMedals` (esi-characters.read_medals.v1) +- [x] `GET /characters/{character_id}/notifications` — `GetCharactersCharacterIdNotifications` (esi-characters.read_notifications.v1) +- [x] `GET /characters/{character_id}/notifications/contacts` — `GetCharactersCharacterIdNotificationsContacts` (esi-characters.read_notifications.v1) +- [x] `GET /characters/{character_id}/portrait` — `GetCharactersCharacterIdPortrait` +- [x] `GET /characters/{character_id}/roles` — `GetCharactersCharacterIdRoles` (esi-characters.read_corporation_roles.v1) +- [x] `GET /characters/{character_id}/standings` — `GetCharactersCharacterIdStandings` (esi-characters.read_standings.v1) +- [x] `GET /characters/{character_id}/titles` — `GetCharactersCharacterIdTitles` (esi-characters.read_titles.v1) + +### Clones (2/2) → `src/groups/clones.rs` +- [x] `GET /characters/{character_id}/clones` — `GetCharactersCharacterIdClones` (esi-clones.read_clones.v1) +- [x] `GET /characters/{character_id}/implants` — `GetCharactersCharacterIdImplants` (esi-clones.read_implants.v1) + +### Contacts (9/9) → `src/groups/contacts.rs` +- [x] `GET /alliances/{alliance_id}/contacts` — `GetAlliancesAllianceIdContacts` (esi-alliances.read_contacts.v1) +- [x] `GET /alliances/{alliance_id}/contacts/labels` — `GetAlliancesAllianceIdContactsLabels` (esi-alliances.read_contacts.v1) +- [x] `DELETE /characters/{character_id}/contacts` — `DeleteCharactersCharacterIdContacts` (esi-characters.write_contacts.v1) +- [x] `GET /characters/{character_id}/contacts` — `GetCharactersCharacterIdContacts` (esi-characters.read_contacts.v1) +- [x] `POST /characters/{character_id}/contacts` — `PostCharactersCharacterIdContacts` (esi-characters.write_contacts.v1) +- [x] `PUT /characters/{character_id}/contacts` — `PutCharactersCharacterIdContacts` (esi-characters.write_contacts.v1) +- [x] `GET /characters/{character_id}/contacts/labels` — `GetCharactersCharacterIdContactsLabels` (esi-characters.read_contacts.v1) +- [x] `GET /corporations/{corporation_id}/contacts` — `GetCorporationsCorporationIdContacts` (esi-corporations.read_contacts.v1) +- [x] `GET /corporations/{corporation_id}/contacts/labels` — `GetCorporationsCorporationIdContactsLabels` (esi-corporations.read_contacts.v1) + +### Contracts (9/9) → `src/groups/contracts.rs` +- [x] `GET /characters/{character_id}/contracts` — `GetCharactersCharacterIdContracts` (esi-contracts.read_character_contracts.v1) +- [x] `GET /characters/{character_id}/contracts/{contract_id}/bids` — `GetCharactersCharacterIdContractsContractIdBids` (esi-contracts.read_character_contracts.v1) +- [x] `GET /characters/{character_id}/contracts/{contract_id}/items` — `GetCharactersCharacterIdContractsContractIdItems` (esi-contracts.read_character_contracts.v1) +- [x] `GET /contracts/public/bids/{contract_id}` — `GetContractsPublicBidsContractId` +- [x] `GET /contracts/public/items/{contract_id}` — `GetContractsPublicItemsContractId` +- [x] `GET /contracts/public/{region_id}` — `GetContractsPublicRegionId` +- [x] `GET /corporations/{corporation_id}/contracts` — `GetCorporationsCorporationIdContracts` (esi-contracts.read_corporation_contracts.v1) +- [x] `GET /corporations/{corporation_id}/contracts/{contract_id}/bids` — `GetCorporationsCorporationIdContractsContractIdBids` (esi-contracts.read_corporation_contracts.v1) +- [x] `GET /corporations/{corporation_id}/contracts/{contract_id}/items` — `GetCorporationsCorporationIdContractsContractIdItems` (esi-contracts.read_corporation_contracts.v1) + +### Corporation (22/22) → `src/groups/corporation.rs` +- [x] `GET /corporations/npccorps` — `GetCorporationsNpccorps` +- [x] `GET /corporations/{corporation_id}` — `GetCorporationsCorporationId` +- [x] `GET /corporations/{corporation_id}/alliancehistory` — `GetCorporationsCorporationIdAlliancehistory` +- [x] `GET /corporations/{corporation_id}/blueprints` — `GetCorporationsCorporationIdBlueprints` (esi-corporations.read_blueprints.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/containers/logs` — `GetCorporationsCorporationIdContainersLogs` (esi-corporations.read_container_logs.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/divisions` — `GetCorporationsCorporationIdDivisions` (esi-corporations.read_divisions.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/facilities` — `GetCorporationsCorporationIdFacilities` (esi-corporations.read_facilities.v1; roles: Factory_Manager) +- [x] `GET /corporations/{corporation_id}/icons` — `GetCorporationsCorporationIdIcons` +- [x] `GET /corporations/{corporation_id}/medals` — `GetCorporationsCorporationIdMedals` (esi-corporations.read_medals.v1) +- [x] `GET /corporations/{corporation_id}/medals/issued` — `GetCorporationsCorporationIdMedalsIssued` (esi-corporations.read_medals.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/members` — `GetCorporationsCorporationIdMembers` (esi-corporations.read_corporation_membership.v1) +- [x] `GET /corporations/{corporation_id}/members/limit` — `GetCorporationsCorporationIdMembersLimit` (esi-corporations.track_members.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/members/titles` — `GetCorporationsCorporationIdMembersTitles` (esi-corporations.read_titles.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/membertracking` — `GetCorporationsCorporationIdMembertracking` (esi-corporations.track_members.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/roles` — `GetCorporationsCorporationIdRoles` (esi-corporations.read_corporation_membership.v1) +- [x] `GET /corporations/{corporation_id}/roles/history` — `GetCorporationsCorporationIdRolesHistory` (esi-corporations.read_corporation_membership.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/shareholders` — `GetCorporationsCorporationIdShareholders` (esi-wallet.read_corporation_wallets.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/standings` — `GetCorporationsCorporationIdStandings` (esi-corporations.read_standings.v1) +- [x] `GET /corporations/{corporation_id}/starbases` — `GetCorporationsCorporationIdStarbases` (esi-corporations.read_starbases.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/starbases/{starbase_id}` — `GetCorporationsCorporationIdStarbasesStarbaseId` (esi-corporations.read_starbases.v1; roles: Director) +- [x] `GET /corporations/{corporation_id}/structures` — `GetCorporationsCorporationIdStructures` (esi-corporations.read_structures.v1; roles: Station_Manager) +- [x] `GET /corporations/{corporation_id}/titles` — `GetCorporationsCorporationIdTitles` (esi-corporations.read_titles.v1; roles: Director) + +### Corporation Projects (4/4) → `src/groups/corporation_projects.rs` +- [x] `GET /corporations/{corporation_id}/projects` — `GetCorporationsProjectsListing` (esi-corporations.read_projects.v1; paged) +- [x] `GET /corporations/{corporation_id}/projects/{project_id}` — `GetCorporationsProjectsDetail` (esi-corporations.read_projects.v1) +- [x] `GET /corporations/{corporation_id}/projects/{project_id}/contribution/{character_id}` — `GetCorporationsProjectsContribution` (esi-corporations.read_projects.v1) +- [x] `GET /corporations/{corporation_id}/projects/{project_id}/contributors` — `GetCorporationsProjectsContributors` (esi-corporations.read_projects.v1; roles: Project_Manager; paged) + +### Cosmetics (3/3) → `src/groups/cosmetics.rs` +- [x] `GET /characters/{character_id}/cosmetics/skinr` — `GetCharactersCosmeticsSkinr` (esi.cosmetic.char:read) +- [x] `GET /characters/{character_id}/cosmetics/skinr/components` — `GetCharactersCosmeticsSkinrComponents` (esi.cosmetic.char:read) +- [x] `GET /cosmetics/skinr/{skinr_id}` — `GetCosmeticsSkinr` + +### Dogma (5/5) → `src/groups/dogma.rs` +- [x] `GET /dogma/attributes` — `GetDogmaAttributes` +- [x] `GET /dogma/attributes/{attribute_id}` — `GetDogmaAttributesAttributeId` +- [x] `GET /dogma/dynamic/items/{type_id}/{item_id}` — `GetDogmaDynamicItemsTypeIdItemId` +- [x] `GET /dogma/effects` — `GetDogmaEffects` +- [x] `GET /dogma/effects/{effect_id}` — `GetDogmaEffectsEffectId` + +### Faction Warfare (8/8) → `src/groups/faction_warfare.rs` +- [x] `GET /characters/{character_id}/fw/stats` — `GetCharactersCharacterIdFwStats` (esi-characters.read_fw_stats.v1) +- [x] `GET /corporations/{corporation_id}/fw/stats` — `GetCorporationsCorporationIdFwStats` (esi-corporations.read_fw_stats.v1) +- [x] `GET /fw/leaderboards` — `GetFwLeaderboards` +- [x] `GET /fw/leaderboards/characters` — `GetFwLeaderboardsCharacters` +- [x] `GET /fw/leaderboards/corporations` — `GetFwLeaderboardsCorporations` +- [x] `GET /fw/stats` — `GetFwStats` +- [x] `GET /fw/systems` — `GetFwSystems` +- [x] `GET /fw/wars` — `GetFwWars` + +### Fittings (3/3) → `src/groups/fittings.rs` +- [x] `GET /characters/{character_id}/fittings` — `GetCharactersCharacterIdFittings` (esi-fittings.read_fittings.v1) +- [x] `POST /characters/{character_id}/fittings` — `PostCharactersCharacterIdFittings` (esi-fittings.write_fittings.v1) +- [x] `DELETE /characters/{character_id}/fittings/{fitting_id}` — `DeleteCharactersCharacterIdFittingsFittingId` (esi-fittings.write_fittings.v1) + +### Fleets (14/14) → `src/groups/fleets.rs` +- [x] `GET /characters/{character_id}/fleet` — `GetCharactersCharacterIdFleet` (esi-fleets.read_fleet.v1) +- [x] `GET /fleets/{fleet_id}` — `GetFleetsFleetId` (esi-fleets.read_fleet.v1) +- [x] `PUT /fleets/{fleet_id}` — `PutFleetsFleetId` (esi-fleets.write_fleet.v1) +- [x] `GET /fleets/{fleet_id}/members` — `GetFleetsFleetIdMembers` (esi-fleets.read_fleet.v1) +- [x] `POST /fleets/{fleet_id}/members` — `PostFleetsFleetIdMembers` (esi-fleets.write_fleet.v1) +- [x] `DELETE /fleets/{fleet_id}/members/{member_id}` — `DeleteFleetsFleetIdMembersMemberId` (esi-fleets.write_fleet.v1) +- [x] `PUT /fleets/{fleet_id}/members/{member_id}` — `PutFleetsFleetIdMembersMemberId` (esi-fleets.write_fleet.v1) +- [x] `DELETE /fleets/{fleet_id}/squads/{squad_id}` — `DeleteFleetsFleetIdSquadsSquadId` (esi-fleets.write_fleet.v1) +- [x] `PUT /fleets/{fleet_id}/squads/{squad_id}` — `PutFleetsFleetIdSquadsSquadId` (esi-fleets.write_fleet.v1) +- [x] `GET /fleets/{fleet_id}/wings` — `GetFleetsFleetIdWings` (esi-fleets.read_fleet.v1) +- [x] `POST /fleets/{fleet_id}/wings` — `PostFleetsFleetIdWings` (esi-fleets.write_fleet.v1) +- [x] `DELETE /fleets/{fleet_id}/wings/{wing_id}` — `DeleteFleetsFleetIdWingsWingId` (esi-fleets.write_fleet.v1) +- [x] `PUT /fleets/{fleet_id}/wings/{wing_id}` — `PutFleetsFleetIdWingsWingId` (esi-fleets.write_fleet.v1) +- [x] `POST /fleets/{fleet_id}/wings/{wing_id}/squads` — `PostFleetsFleetIdWingsWingIdSquads` (esi-fleets.write_fleet.v1) + +### Freelance Jobs (6/6) → `src/groups/freelance_jobs.rs` +- [x] `GET /characters/{character_id}/freelance-jobs` — `GetCharactersFreelanceJobsListing` (esi-characters.read_freelance_jobs.v1) +- [x] `GET /characters/{character_id}/freelance-jobs/{job_id}/participation` — `GetCharactersFreelanceJobsParticipation` (esi-characters.read_freelance_jobs.v1) +- [x] `GET /corporations/{corporation_id}/freelance-jobs` — `GetCorporationsFreelanceJobsListing` (esi-corporations.read_freelance_jobs.v1; roles: Project_Manager; paged) +- [x] `GET /corporations/{corporation_id}/freelance-jobs/{job_id}/participants` — `GetCorporationsFreelanceJobsParticipants` (esi-corporations.read_freelance_jobs.v1; roles: Project_Manager; paged) +- [x] `GET /freelance-jobs` — `GetFreelanceJobsListing` (paged) +- [x] `GET /freelance-jobs/{job_id}` — `GetFreelanceJobsDetail` + +### Incursions (1/1) → `src/groups/incursions.rs` +- [x] `GET /incursions` — `GetIncursions` + +### Industry (8/8) → `src/groups/industry.rs` +- [x] `GET /characters/{character_id}/industry/jobs` — `GetCharactersCharacterIdIndustryJobs` (esi-industry.read_character_jobs.v1) +- [x] `GET /characters/{character_id}/mining` — `GetCharactersCharacterIdMining` (esi-industry.read_character_mining.v1) +- [x] `GET /corporation/{corporation_id}/mining/extractions` — `GetCorporationCorporationIdMiningExtractions` (esi-industry.read_corporation_mining.v1; roles: Station_Manager) +- [x] `GET /corporation/{corporation_id}/mining/observers` — `GetCorporationCorporationIdMiningObservers` (esi-industry.read_corporation_mining.v1; roles: Accountant) +- [x] `GET /corporation/{corporation_id}/mining/observers/{observer_id}` — `GetCorporationCorporationIdMiningObserversObserverId` (esi-industry.read_corporation_mining.v1; roles: Accountant) +- [x] `GET /corporations/{corporation_id}/industry/jobs` — `GetCorporationsCorporationIdIndustryJobs` (esi-industry.read_corporation_jobs.v1; roles: Factory_Manager) +- [x] `GET /industry/facilities` — `GetIndustryFacilities` +- [x] `GET /industry/systems` — `GetIndustrySystems` + +### Insurance (1/1) → `src/groups/insurance.rs` +- [x] `GET /insurance/prices` — `GetInsurancePrices` + +### Killmails (3/3) → `src/groups/killmails.rs` +- [x] `GET /characters/{character_id}/killmails/recent` — `GetCharactersCharacterIdKillmailsRecent` (esi-killmails.read_killmails.v1) +- [x] `GET /corporations/{corporation_id}/killmails/recent` — `GetCorporationsCorporationIdKillmailsRecent` (esi-killmails.read_corporation_killmails.v1; roles: Director) +- [x] `GET /killmails/{killmail_id}/{killmail_hash}` — `GetKillmailsKillmailIdKillmailHash` + +### Location (3/3) → `src/groups/location.rs` +- [x] `GET /characters/{character_id}/location` — `GetCharactersCharacterIdLocation` (esi-location.read_location.v1) +- [x] `GET /characters/{character_id}/online` — `GetCharactersCharacterIdOnline` (esi-location.read_online.v1) +- [x] `GET /characters/{character_id}/ship` — `GetCharactersCharacterIdShip` (esi-location.read_ship_type.v1) + +### Loyalty (2/2) → `src/groups/loyalty.rs` +- [x] `GET /characters/{character_id}/loyalty/points` — `GetCharactersCharacterIdLoyaltyPoints` (esi-characters.read_loyalty.v1) +- [x] `GET /loyalty/stores/{corporation_id}/offers` — `GetLoyaltyStoresCorporationIdOffers` + +### Mail (9/9) → `src/groups/mail.rs` +- [x] `GET /characters/{character_id}/mail` — `GetCharactersCharacterIdMail` (esi-mail.read_mail.v1) +- [x] `POST /characters/{character_id}/mail` — `PostCharactersCharacterIdMail` (esi-mail.send_mail.v1) +- [x] `GET /characters/{character_id}/mail/labels` — `GetCharactersCharacterIdMailLabels` (esi-mail.read_mail.v1) +- [x] `POST /characters/{character_id}/mail/labels` — `PostCharactersCharacterIdMailLabels` (esi-mail.organize_mail.v1) +- [x] `DELETE /characters/{character_id}/mail/labels/{label_id}` — `DeleteCharactersCharacterIdMailLabelsLabelId` (esi-mail.organize_mail.v1) +- [x] `GET /characters/{character_id}/mail/lists` — `GetCharactersCharacterIdMailLists` (esi-mail.read_mail.v1) +- [x] `DELETE /characters/{character_id}/mail/{mail_id}` — `DeleteCharactersCharacterIdMailMailId` (esi-mail.organize_mail.v1) +- [x] `GET /characters/{character_id}/mail/{mail_id}` — `GetCharactersCharacterIdMailMailId` (esi-mail.read_mail.v1) +- [x] `PUT /characters/{character_id}/mail/{mail_id}` — `PutCharactersCharacterIdMailMailId` (esi-mail.organize_mail.v1) + +### Market (11/11) → `src/groups/market.rs` +- [x] `GET /characters/{character_id}/orders` — `GetCharactersCharacterIdOrders` (esi-markets.read_character_orders.v1) +- [x] `GET /characters/{character_id}/orders/history` — `GetCharactersCharacterIdOrdersHistory` (esi-markets.read_character_orders.v1) +- [x] `GET /corporations/{corporation_id}/orders` — `GetCorporationsCorporationIdOrders` (esi-markets.read_corporation_orders.v1; roles: Accountant, Trader) +- [x] `GET /corporations/{corporation_id}/orders/history` — `GetCorporationsCorporationIdOrdersHistory` (esi-markets.read_corporation_orders.v1; roles: Accountant, Trader) +- [x] `GET /markets/groups` — `GetMarketsGroups` +- [x] `GET /markets/groups/{market_group_id}` — `GetMarketsGroupsMarketGroupId` +- [x] `GET /markets/prices` — `GetMarketsPrices` +- [x] `GET /markets/structures/{structure_id}` — `GetMarketsStructuresStructureId` (esi-markets.structure_markets.v1) +- [x] `GET /markets/{region_id}/history` — `GetMarketsRegionIdHistory` +- [x] `GET /markets/{region_id}/orders` — `GetMarketsRegionIdOrders` +- [x] `GET /markets/{region_id}/types` — `GetMarketsRegionIdTypes` + +### Meta (4/4) → `src/groups/meta.rs` +- [x] `GET /meta/changelog` — `GetMetaChangelog` +- [x] `GET /meta/compatibility-dates` — `GetMetaCompatibilityDates` +- [x] `GET /meta/name` — `GetMetaName` +- [x] `GET /meta/status` — `GetMetaStatus` + +### Military Campaigns (6/6) → `src/groups/military_campaigns.rs` +- [x] `GET /characters/{character_id}/military-campaigns/objectives` — `GetCharactersMilitaryCampaignsObjectivesListing` (esi.activity.char:read; paged) +- [x] `GET /characters/{character_id}/military-campaigns/objectives/{objective_id}` — `GetCharactersMilitaryCampaignsObjectivesParticipation` (esi.activity.char:read) +- [x] `GET /military-campaigns` — `GetMilitaryCampaignsListing` +- [x] `GET /military-campaigns/{campaign_id}` — `GetMilitaryCampaignsDetail` +- [x] `GET /military-campaigns/{campaign_id}/objectives` — `GetMilitaryCampaignsObjectivesListing` (paged) +- [x] `GET /military-campaigns/{campaign_id}/objectives/{objective_id}` — `GetMilitaryCampaignsObjectivesDetail` + +### Paragon Hub (5/5) → `src/groups/paragon_hub.rs` +- [x] `GET /characters/{character_id}/paragon-hub/skinr` — `GetCharactersParagonHubSkinr` (esi.cosmetic.char:read; paged; tombstone) +- [x] `GET /paragon-hub/skinr` — `GetParagonHubSkinr` (paged; tombstone) +- [x] `GET /paragon-hub/skinr/alliances/{alliance_id}` — `GetParagonHubSkinrAlliances` (esi.cosmetic.char:read; paged; tombstone) +- [x] `GET /paragon-hub/skinr/characters/{character_id}` — `GetParagonHubSkinrCharacters` (esi.cosmetic.char:read; paged; tombstone) +- [x] `GET /paragon-hub/skinr/corporations/{corporation_id}` — `GetParagonHubSkinrCorporations` (esi.cosmetic.char:read; paged; tombstone) + +### Planetary Interaction (4/4) → `src/groups/planetary_interaction.rs` +- [x] `GET /characters/{character_id}/planets` — `GetCharactersCharacterIdPlanets` (esi-planets.manage_planets.v1) +- [x] `GET /characters/{character_id}/planets/{planet_id}` — `GetCharactersCharacterIdPlanetsPlanetId` (esi-planets.manage_planets.v1) +- [x] `GET /corporations/{corporation_id}/customs_offices` — `GetCorporationsCorporationIdCustomsOffices` (esi-planets.read_customs_offices.v1; roles: Director) +- [x] `GET /universe/schematics/{schematic_id}` — `GetUniverseSchematicsSchematicId` + +### Routes (1/1) → `src/groups/routes.rs` +- [x] `POST /route/{origin_system_id}/{destination_system_id}` — `PostRoute` + +### Search (1/1) → `src/groups/search.rs` +- [x] `GET /characters/{character_id}/search` — `GetCharactersCharacterIdSearch` (esi-search.search_structures.v1) + +### Skills (3/3) → `src/groups/skills.rs` +- [x] `GET /characters/{character_id}/attributes` — `GetCharactersCharacterIdAttributes` (esi-skills.read_skills.v1) +- [x] `GET /characters/{character_id}/skillqueue` — `GetCharactersCharacterIdSkillqueue` (esi-skills.read_skillqueue.v1) +- [x] `GET /characters/{character_id}/skills` — `GetCharactersCharacterIdSkills` (esi-skills.read_skills.v1) + +### Sovereignty (2/2) → `src/groups/sovereignty.rs` +- [x] `GET /sovereignty/campaigns` — `GetSovereigntyCampaigns` +- [x] `GET /sovereignty/systems` — `GetSovereigntySystems` + +### Status (1/1) → `src/groups/status.rs` +- [x] `GET /status` — `GetStatus` + +### Structures (6/6) → `src/groups/structures.rs` +- [x] `GET /characters/{character_id}/structures/mercenary-dens` — `GetCharactersStructuresMercenaryDensListing` (esi-structures.read_character.v1) +- [x] `GET /characters/{character_id}/structures/mercenary-dens/{mercenary_den_id}` — `GetCharactersStructuresMercenaryDensDetail` (esi-structures.read_character.v1) +- [x] `GET /corporations/{corporation_id}/structures/skyhooks` — `GetCorporationsStructuresSkyhooksListing` (esi-structures.read_corporation.v1; roles: Station_Manager) +- [x] `GET /corporations/{corporation_id}/structures/skyhooks/{skyhook_id}` — `GetCorporationsStructuresSkyhooksDetail` (esi-structures.read_corporation.v1; roles: Station_Manager) +- [x] `GET /corporations/{corporation_id}/structures/sovereignty-hubs` — `GetCorporationsStructuresSovereigntyHubsListing` (esi-structures.read_corporation.v1) +- [x] `GET /corporations/{corporation_id}/structures/sovereignty-hubs/{sovereignty_hub_id}` — `GetCorporationsStructuresSovereigntyHubsDetail` (esi-structures.read_corporation.v1; roles: Station_Manager) + +### Universe (30/30) → `src/groups/universe.rs` +- [x] `GET /universe/ancestries` — `GetUniverseAncestries` +- [x] `GET /universe/asteroid_belts/{asteroid_belt_id}` — `GetUniverseAsteroidBeltsAsteroidBeltId` +- [x] `GET /universe/bloodlines` — `GetUniverseBloodlines` +- [x] `GET /universe/categories` — `GetUniverseCategories` +- [x] `GET /universe/categories/{category_id}` — `GetUniverseCategoriesCategoryId` +- [x] `GET /universe/constellations` — `GetUniverseConstellations` +- [x] `GET /universe/constellations/{constellation_id}` — `GetUniverseConstellationsConstellationId` +- [x] `GET /universe/factions` — `GetUniverseFactions` +- [x] `GET /universe/graphics` — `GetUniverseGraphics` +- [x] `GET /universe/graphics/{graphic_id}` — `GetUniverseGraphicsGraphicId` +- [x] `GET /universe/groups` — `GetUniverseGroups` +- [x] `GET /universe/groups/{group_id}` — `GetUniverseGroupsGroupId` +- [x] `POST /universe/ids` — `PostUniverseIds` +- [x] `GET /universe/moons/{moon_id}` — `GetUniverseMoonsMoonId` +- [x] `POST /universe/names` — `PostUniverseNames` +- [x] `GET /universe/planets/{planet_id}` — `GetUniversePlanetsPlanetId` +- [x] `GET /universe/races` — `GetUniverseRaces` +- [x] `GET /universe/regions` — `GetUniverseRegions` +- [x] `GET /universe/regions/{region_id}` — `GetUniverseRegionsRegionId` +- [x] `GET /universe/stargates/{stargate_id}` — `GetUniverseStargatesStargateId` +- [x] `GET /universe/stars/{star_id}` — `GetUniverseStarsStarId` +- [x] `GET /universe/stations/{station_id}` — `GetUniverseStationsStationId` +- [x] `GET /universe/structures` — `GetUniverseStructures` +- [x] `GET /universe/structures/{structure_id}` — `GetUniverseStructuresStructureId` (esi-universe.read_structures.v1) +- [x] `GET /universe/system_jumps` — `GetUniverseSystemJumps` +- [x] `GET /universe/system_kills` — `GetUniverseSystemKills` +- [x] `GET /universe/systems` — `GetUniverseSystems` +- [x] `GET /universe/systems/{system_id}` — `GetUniverseSystemsSystemId` +- [x] `GET /universe/types` — `GetUniverseTypes` +- [x] `GET /universe/types/{type_id}` — `GetUniverseTypesTypeId` + +### User Interface (5/5) → `src/groups/user_interface.rs` +- [x] `POST /ui/autopilot/waypoint` — `PostUiAutopilotWaypoint` (esi-ui.write_waypoint.v1) +- [x] `POST /ui/openwindow/contract` — `PostUiOpenwindowContract` (esi-ui.open_window.v1) +- [x] `POST /ui/openwindow/information` — `PostUiOpenwindowInformation` (esi-ui.open_window.v1) +- [x] `POST /ui/openwindow/marketdetails` — `PostUiOpenwindowMarketdetails` (esi-ui.open_window.v1) +- [x] `POST /ui/openwindow/newmail` — `PostUiOpenwindowNewmail` (esi-ui.open_window.v1) + +### Wallet (6/6) → `src/groups/wallet.rs` +- [x] `GET /characters/{character_id}/wallet` — `GetCharactersCharacterIdWallet` (esi-wallet.read_character_wallet.v1) +- [x] `GET /characters/{character_id}/wallet/journal` — `GetCharactersCharacterIdWalletJournal` (esi-wallet.read_character_wallet.v1) +- [x] `GET /characters/{character_id}/wallet/transactions` — `GetCharactersCharacterIdWalletTransactions` (esi-wallet.read_character_wallet.v1) +- [x] `GET /corporations/{corporation_id}/wallets` — `GetCorporationsCorporationIdWallets` (esi-wallet.read_corporation_wallets.v1; roles: Accountant, Junior_Accountant) +- [x] `GET /corporations/{corporation_id}/wallets/{division}/journal` — `GetCorporationsCorporationIdWalletsDivisionJournal` (esi-wallet.read_corporation_wallets.v1; roles: Accountant, Junior_Accountant) +- [x] `GET /corporations/{corporation_id}/wallets/{division}/transactions` — `GetCorporationsCorporationIdWalletsDivisionTransactions` (esi-wallet.read_corporation_wallets.v1; roles: Accountant, Junior_Accountant) + +### Wars (3/3) → `src/groups/wars.rs` +- [x] `GET /wars` — `GetWars` +- [x] `GET /wars/{war_id}` — `GetWarsWarId` +- [x] `GET /wars/{war_id}/killmails` — `GetWarsWarIdKillmails` + +## Release housekeeping + +- [x] `CHANGELOG.md` updated for the 0.2.0 release and the README has a Coverage section. +- [x] Version 0.2.0 (breaking: integer types are `i64`, new macros and errors); `Cargo.toml` and `CHANGELOG.md` updated. Publishing is still a manual step (GitHub release). +- [x] The alias table (`src/legacy.rs`) only maps rfesi IDs; a test checks every target exists in the spec and the table is smaller than the spec. diff --git a/src/builders.rs b/src/builders.rs index 22021fa..2559f11 100644 --- a/src/builders.rs +++ b/src/builders.rs @@ -80,9 +80,62 @@ pub struct EsiBuilder { pub(crate) refresh_token: Option, pub(crate) user_agent: Option, pub(crate) http_timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) cache_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) cache_max_entries: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) cache_max_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) rate_limit_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) page_concurrency: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) tenant: Option, pub(crate) spec: Option, } +/// Languages ESI can answer in (the `Accept-Language` header). +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Language { + /// English (the default). + #[default] + En, + /// German. + De, + /// French. + Fr, + /// Japanese. + Ja, + /// Russian. + Ru, + /// Chinese. + Zh, + /// Korean. + Ko, + /// Spanish. + Es, +} + +impl Language { + /// The value of the `Accept-Language` header. + pub fn as_str(&self) -> &'static str { + match self { + Language::En => "en", + Language::De => "de", + Language::Fr => "fr", + Language::Ja => "ja", + Language::Ru => "ru", + Language::Zh => "zh", + Language::Ko => "ko", + Language::Es => "es", + } + } +} + impl EsiBuilder { /// Start a new builder. pub fn new() -> Self { @@ -226,6 +279,71 @@ impl EsiBuilder { Ok(builder.build()?) } + /// Limit how many responses the cache keeps (1024 by default). When it is + /// full, expired entries are dropped first and then the oldest ones. + /// Has no effect unless the cache is enabled with [`EsiBuilder::enable_cache`]. + pub fn cache_max_entries(mut self, val: usize) -> Self { + self.cache_max_entries = Some(val); + self + } + + /// Limit how many bytes the cache keeps (no limit by default), counting the + /// bodies, validators, headers and keys it stores. It applies together with + /// [`EsiBuilder::cache_max_entries`]: when a response does not fit in either + /// limit, entries are dropped in the same order. A response larger than the + /// limit by itself is not cached. Has no effect unless the cache is enabled + /// with [`EsiBuilder::enable_cache`]. + pub fn cache_max_bytes(mut self, val: usize) -> Self { + self.cache_max_bytes = Some(val); + self + } + + /// Choose what the client does when a route group has no rate-limit tokens + /// left for a request ([`RateLimitPolicy::Off`] by default): wait until the + /// request fits, or fail without calling ESI. + /// + /// The balance is tracked per route group and access token, from the + /// `X-Ratelimit-*` headers of the responses, and only for operations that + /// declare a group in the spec, so the spec must be loaded. + pub fn rate_limit_policy(mut self, val: RateLimitPolicy) -> Self { + self.rate_limit_policy = Some(val); + self + } + + /// How many pages `Esi::fetch_all_pages` requests at the same time once it + /// knows the page count (4 by default; 1 makes it sequential). + pub fn page_concurrency(mut self, val: usize) -> Self { + self.page_concurrency = Some(val); + self + } + + /// Set the language of the responses (the `Accept-Language` header). + /// + /// ESI answers in English when it is not set. + pub fn language(mut self, val: Language) -> Self { + self.language = Some(val); + self + } + + /// Set the tenant (the `X-Tenant` header). ESI uses `tranquility` when it is + /// not set. + pub fn tenant(mut self, val: &str) -> Self { + self.tenant = Some(val.to_owned()); + self + } + + /// Keep `GET` responses in memory and revalidate them with `ETag` / + /// `Last-Modified` (disabled by default). + /// + /// While an entry is younger than the operation's `x-client-cache-ttl` in the + /// spec (or the `max-age` of the response when the spec has none) it is served + /// without calling ESI; afterwards it is revalidated and a `304 Not Modified` + /// reuses the stored body. Entries are kept per access token. + pub fn enable_cache(mut self, val: bool) -> Self { + self.cache_enabled = Some(val); + self + } + /// Construct the `Esi` instance. /// /// There are a few things that could go wrong, like @@ -241,6 +359,21 @@ mod tests { use super::EsiBuilder; use crate::spec::Spec; + #[test] + fn test_language_and_tenant() { + let esi = EsiBuilder::new() + .user_agent("d") + .language(super::Language::De) + .tenant("singularity") + .build() + .unwrap(); + assert_eq!(esi.language, Some(super::Language::De)); + assert_eq!(esi.tenant.as_deref(), Some("singularity")); + assert_eq!(super::Language::Ko.as_str(), "ko"); + let json = serde_json::to_string(&EsiBuilder::new().language(super::Language::Fr)).unwrap(); + assert!(json.contains("\"language\":\"fr\"")); + } + #[test] fn test_builder_valid() { let b = EsiBuilder::new() diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..eddedfc --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,382 @@ +//! Optional client-side cache of `GET` responses. +//! +//! When enabled with [`EsiBuilder::enable_cache`](crate::prelude::EsiBuilder::enable_cache), +//! responses are kept in memory with their `ETag` and `Last-Modified`. A request made +//! before the entry expires is answered from the cache without calling ESI; a later +//! one is revalidated with `If-None-Match` / `If-Modified-Since`, and a `304 Not +//! Modified` reply reuses the stored body. +//! +//! The cache holds a limited number of responses and, optionally, a limited number +//! of bytes (the size of the bodies, headers and keys it stores). Only when a new +//! response does not fit in either limit, entries are dropped one at a time in this +//! order, and within each group the least recently used goes first: +//! +//! 1. expired entries that cannot be revalidated (no `ETag` and no `Last-Modified`), +//! 2. expired entries that can be revalidated, +//! 3. entries that have not expired. + +use reqwest::header::HeaderMap; +use std::collections::HashMap; + +/// A stored response. +#[derive(Debug, Clone)] +pub(crate) struct CacheEntry { + pub(crate) etag: Option, + pub(crate) last_modified: Option, + pub(crate) body: String, + /// Response headers, kept so `X-Pages` is still available on a hit. + pub(crate) headers: HeaderMap, + /// Unix time in milliseconds until which the entry is served without a request. + pub(crate) expires_at: i64, + /// When the entry was last stored, read or revalidated, as a counter: the + /// smaller, the longer it has gone unused. + pub(crate) last_used: i64, +} + +impl CacheEntry { + /// Whether a request can be revalidated with this entry (a `304` reuses it). + fn can_revalidate(&self) -> bool { + self.etag.is_some() || self.last_modified.is_some() + } + + /// Eviction group: lower goes first. See the module documentation. + fn eviction_group(&self, now: i64) -> u8 { + match (self.expires_at <= now, self.can_revalidate()) { + (true, false) => 0, + (true, true) => 1, + (false, _) => 2, + } + } +} + +impl CacheEntry { + /// Approximate memory the entry holds: body, validators and headers. + fn size(&self) -> usize { + self.body.len() + + self.etag.as_deref().map_or(0, str::len) + + self.last_modified.as_deref().map_or(0, str::len) + + self + .headers + .iter() + .map(|(name, value)| name.as_str().len() + value.len()) + .sum::() + } +} + +/// How many responses the cache keeps unless the builder says otherwise. +pub(crate) const DEFAULT_MAX_ENTRIES: usize = 1024; + +/// In-memory cache keyed by request. +#[derive(Debug)] +pub(crate) struct ResponseCache { + entries: HashMap, + max_entries: usize, + /// Limit on the size of the stored entries, if any. + max_bytes: Option, + /// Size of the stored entries (see [`CacheEntry::size`]) and their keys. + bytes: usize, + clock: i64, +} + +impl Default for ResponseCache { + fn default() -> Self { + Self::new(DEFAULT_MAX_ENTRIES) + } +} + +impl ResponseCache { + /// A cache that keeps at most `max_entries` responses (at least one). + pub(crate) fn new(max_entries: usize) -> Self { + Self::with_limits(max_entries, None) + } + + /// A cache that keeps at most `max_entries` responses (at least one) and, + /// when `max_bytes` is set, at most that many bytes. + pub(crate) fn with_limits(max_entries: usize, max_bytes: Option) -> Self { + ResponseCache { + entries: HashMap::new(), + max_entries: max_entries.max(1), + max_bytes, + bytes: 0, + clock: 0, + } + } + + /// The key of a request: access token, headers that change the response + /// (language and tenant), URL and sorted query parameters. + pub(crate) fn key( + token: Option<&str>, + variant: &str, + url: &str, + query: &[(&str, &str)], + ) -> String { + let mut pairs: Vec = query.iter().map(|(k, v)| format!("{k}={v}")).collect(); + pairs.sort(); + format!( + "{}|{variant}|{url}?{}", + token.unwrap_or(""), + pairs.join("&") + ) + } + + /// The entry for a key, without marking it as used. + #[cfg(test)] + pub(crate) fn get(&self, key: &str) -> Option<&CacheEntry> { + self.entries.get(key) + } + + /// A copy of the entry for a key, marking it as the most recently used. + pub(crate) fn lookup(&mut self, key: &str) -> Option { + let stamp = self.tick(); + let entry = self.entries.get_mut(key)?; + entry.last_used = stamp; + Some(entry.clone()) + } + + /// Store a response, marking it as the most recently used. If the cache has + /// no room for it, entries are dropped first (see the module documentation); + /// `now` is the current Unix time in milliseconds. Replacing a key never + /// drops other entries because of the entry count. A response larger than + /// the byte limit by itself is not stored, and the older one under its key + /// is dropped. + pub(crate) fn insert(&mut self, key: String, mut entry: CacheEntry, now: i64) { + self.remove(&key); + let size = key.len() + entry.size(); + if self.max_bytes.is_some_and(|max| size > max) { + return; + } + while self.entries.len() >= self.max_entries + || self.max_bytes.is_some_and(|max| self.bytes + size > max) + { + if !self.evict_one(now) { + break; + } + } + entry.last_used = self.tick(); + self.bytes += size; + self.entries.insert(key, entry); + } + + /// Extend the life of an entry after a `304 Not Modified`, marking it as used. + pub(crate) fn refresh(&mut self, key: &str, expires_at: i64) { + let stamp = self.tick(); + if let Some(entry) = self.entries.get_mut(key) { + entry.expires_at = expires_at; + entry.last_used = stamp; + } + } + + /// Drop the next entry in eviction order. Returns whether there was one. + fn evict_one(&mut self, now: i64) -> bool { + let victim = self + .entries + .iter() + .min_by_key(|(_, e)| (e.eviction_group(now), e.last_used)) + .map(|(k, _)| k.clone()); + victim.is_some_and(|key| self.remove(&key)) + } + + /// Drop an entry and release its bytes. Returns whether there was one. + fn remove(&mut self, key: &str) -> bool { + match self.entries.remove(key) { + Some(entry) => { + self.bytes -= key.len() + entry.size(); + true + } + None => false, + } + } + + fn tick(&mut self) -> i64 { + self.clock += 1; + self.clock + } + + /// The `max-age` of a `Cache-Control` header, in seconds. + pub(crate) fn max_age(headers: &HeaderMap) -> Option { + let value = headers.get("cache-control")?.to_str().ok()?; + value + .split(',') + .filter_map(|d| d.trim().strip_prefix("max-age=")) + .find_map(|v| v.trim().parse().ok()) + } + + pub(crate) fn len(&self) -> usize { + self.entries.len() + } + + /// The approximate size in bytes of what is stored. + pub(crate) fn bytes(&self) -> usize { + self.bytes + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl ResponseCache { + fn entry(expires_at: i64, etag: bool) -> CacheEntry { + CacheEntry { + etag: etag.then(|| "\"x\"".to_owned()), + last_modified: None, + body: "[]".into(), + headers: HeaderMap::new(), + expires_at, + last_used: 0, + } + } + } + + #[test] + fn test_key_ignores_query_order() { + let a = ResponseCache::key(Some("t"), "en", "u", &[("a", "1"), ("b", "2")]); + let b = ResponseCache::key(Some("t"), "en", "u", &[("b", "2"), ("a", "1")]); + assert_eq!(a, b); + assert_ne!( + a, + ResponseCache::key(None, "en", "u", &[("a", "1"), ("b", "2")]) + ); + assert_ne!( + a, + ResponseCache::key(Some("t"), "de", "u", &[("a", "1"), ("b", "2")]) + ); + } + + #[test] + fn test_max_age() { + let mut headers = HeaderMap::new(); + assert_eq!(ResponseCache::max_age(&headers), None); + headers.insert("cache-control", "public, max-age=300".parse().unwrap()); + assert_eq!(ResponseCache::max_age(&headers), Some(300)); + } + + #[test] + fn test_insert_and_refresh() { + let mut cache = ResponseCache::default(); + cache.insert("k".into(), ResponseCache::entry(10, true), 0); + cache.refresh("k", 99); + assert_eq!(cache.get("k").unwrap().expires_at, 99); + assert_eq!(cache.len(), 1); + } + + #[test] + fn test_nothing_is_dropped_while_there_is_room() { + let mut cache = ResponseCache::new(3); + cache.insert("expired".into(), ResponseCache::entry(1, false), 100); + cache.insert("live".into(), ResponseCache::entry(1_000, true), 100); + assert_eq!(cache.len(), 2); + assert!(cache.get("expired").is_some()); + } + + #[test] + fn test_full_cache_evicts_by_group_then_least_recently_used() { + let now = 100; + let mut cache = ResponseCache::new(4); + cache.insert("live_old".into(), ResponseCache::entry(1_000, true), now); + cache.insert("expired_etag".into(), ResponseCache::entry(5, true), now); + cache.insert("expired_plain".into(), ResponseCache::entry(5, false), now); + cache.insert("live_new".into(), ResponseCache::entry(1_000, true), now); + // 1st: the expired entry that cannot be revalidated. + cache.insert("a".into(), ResponseCache::entry(1_000, true), now); + assert!(cache.get("expired_plain").is_none()); + assert!(cache.get("expired_etag").is_some()); + // 2nd: the expired entry with an ETag, before any live entry. + cache.insert("b".into(), ResponseCache::entry(1_000, true), now); + assert!(cache.get("expired_etag").is_none()); + assert!(cache.get("live_old").is_some()); + // 3rd: only live entries are left; the least recently used goes. + cache.insert("c".into(), ResponseCache::entry(1_000, true), now); + assert!(cache.get("live_old").is_none()); + assert_eq!(cache.len(), 4); + } + + #[test] + fn test_reads_and_refreshes_protect_an_entry() { + let now = 100; + let mut cache = ResponseCache::new(3); + for key in ["a", "b", "c"] { + cache.insert(key.into(), ResponseCache::entry(1_000, true), now); + } + // "a" is the oldest stored, but it is read, so "b" is the least recently used. + assert!(cache.lookup("a").is_some()); + cache.insert("d".into(), ResponseCache::entry(1_000, true), now); + assert!(cache.get("b").is_none()); + assert!(cache.get("a").is_some()); + // A `304` refresh counts as a use too: now "c" is the least recently used. + cache.refresh("a", 2_000); + cache.refresh("d", 2_000); + cache.insert("e".into(), ResponseCache::entry(1_000, true), now); + assert!(cache.get("c").is_none()); + assert_eq!(cache.len(), 3); + } + + fn sized(body_len: usize, expires_at: i64, etag: bool) -> CacheEntry { + CacheEntry { + body: "x".repeat(body_len), + ..ResponseCache::entry(expires_at, etag) + } + } + + #[test] + fn test_byte_limit_evicts_in_the_same_order() { + // Keys are one byte; an entry is its body, a 3-byte ETag and the key. + let one = 1 + 3 + 10; + let mut cache = ResponseCache::with_limits(100, Some(3 * one)); + cache.insert("a".into(), sized(10, 1_000, true), 100); + cache.insert("b".into(), sized(10, 5, true), 100); + cache.insert("c".into(), sized(10, 5, false), 100); + assert_eq!(cache.bytes(), 3 * one - 3); + // "c" is expired and cannot be revalidated, so it goes first. + cache.insert("d".into(), sized(10, 1_000, true), 100); + assert!(cache.get("c").is_none() && cache.get("b").is_some()); + // Then "b" (expired, revalidatable) before the live "a". + cache.insert("e".into(), sized(10, 1_000, true), 100); + assert!(cache.get("b").is_none() && cache.get("a").is_some()); + assert!(cache.bytes() <= 3 * one); + } + + #[test] + fn test_a_large_entry_evicts_as_many_as_it_needs() { + // Each small entry is 14 bytes (key, 3-byte ETag, body); the big one is 66. + let mut cache = ResponseCache::with_limits(100, Some(90)); + for key in ["a", "b", "c"] { + cache.insert(key.into(), sized(10, 1_000, true), 0); + } + cache.insert("big".into(), sized(60, 1_000, true), 0); + assert!(cache.get("a").is_none() && cache.get("b").is_none()); + assert!(cache.get("c").is_some() && cache.get("big").is_some()); + assert_eq!(cache.bytes(), 14 + 66); + } + + #[test] + fn test_an_entry_over_the_byte_limit_is_not_stored() { + let mut cache = ResponseCache::with_limits(100, Some(50)); + cache.insert("a".into(), sized(10, 1_000, true), 0); + cache.insert("a".into(), sized(500, 1_000, true), 0); + assert_eq!((cache.len(), cache.bytes()), (0, 0)); + cache.insert("b".into(), sized(10, 1_000, true), 0); + assert_eq!(cache.len(), 1); + } + + #[test] + fn test_replacing_adjusts_the_byte_count() { + let mut cache = ResponseCache::with_limits(100, Some(1_000)); + cache.insert("a".into(), sized(10, 1_000, true), 0); + let before = cache.bytes(); + cache.insert("a".into(), sized(30, 1_000, true), 0); + assert_eq!(cache.bytes(), before + 20); + cache.refresh("a", 5); + assert_eq!(cache.bytes(), before + 20); + } + + #[test] + fn test_replacing_a_key_never_evicts() { + let mut cache = ResponseCache::new(2); + cache.insert("a".into(), ResponseCache::entry(1_000, true), 0); + cache.insert("b".into(), ResponseCache::entry(1_000, true), 0); + cache.insert("a".into(), ResponseCache::entry(2_000, true), 0); + assert_eq!(cache.len(), 2); + assert!(cache.get("b").is_some()); + } +} diff --git a/src/client.rs b/src/client.rs index 9661c58..340ecaa 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,12 +1,15 @@ //! Main logic use crate::{ + cache::{CacheEntry, ResponseCache}, client::ErrorLimitStatus::{Limited, NotLimited}, + cursor_page::CursorPage, groups::*, legacy, pkce::{self, PkceVerifier}, prelude::*, - spec::Spec, + rate_limiter::{Acquire, Permit, RateLimiter}, + spec::{Spec, SpecIndex}, }; use base64::engine::{general_purpose::STANDARD as base64, Engine}; use log::{debug, error, warn}; @@ -16,7 +19,7 @@ use reqwest::{ header::{self, HeaderMap, HeaderValue}, Client, Method, }; -use serde::de::DeserializeOwned; +use serde::{de::DeserializeOwned, Serialize}; use std::{ collections::HashMap, str::FromStr, @@ -37,6 +40,9 @@ const RATE_LIMIT_REMAINING_HEADER: &str = "x-ratelimit-remaining"; const RATE_LIMIT_USED_HEADER: &str = "x-ratelimit-used"; static COMPATIBILITY_HEADER: &str = "X-Compatibility-Date"; +static TENANT_HEADER: &str = "X-Tenant"; +/// The largest `limit` the spec allows on cursor-paginated operations. +const CURSOR_PAGE_LIMIT: &str = "100"; /// The default compatibility date to use if none is specified /// in the builder: the latest date listed by /// `https://esi.evetech.net/meta/compatibility-dates` when this @@ -78,6 +84,17 @@ pub enum ErrorLimitStatus { NotLimited, } +/// What is known about the download of the spec held by an [`Esi`]. +#[derive(Clone, Debug)] +struct SpecInfo { + /// The compatibility date the spec was requested with. + compatibility_date: String, + etag: Option, + last_modified: Option, + /// Unix time in milliseconds until which the spec needs no new request. + expires_at: i64, +} + /// Latest rate-limit state ESI reported for one route group. /// /// ESI uses a floating-window token bucket per application/character @@ -131,7 +148,7 @@ fn parse_rate_limit(value: &str) -> (Option, Option) { /// Which base URL to start with - the public URL for unauthenticated /// calls, or the authenticated URL for making calls to endpoints that /// require an access token. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RequestType { /// Endpoints that do not require authentication Public, @@ -190,10 +207,24 @@ pub struct Esi { /// HTTP client pub(crate) client: Client, pub(crate) spec: Option, - /// `operationId` -> URL path, built from `spec`. - op_index: HashMap, + /// How and when the spec was downloaded, if it was; see `update_spec`. + spec_info: Option, + /// Lookup tables built from `spec`. + index: SpecIndex, error_limit_state: Arc>>, rate_limits: Arc>>, + /// Budgets by route group and access token, used by `rate_limit_policy`. + limiter: Arc, + /// What to do when a route group has no tokens left. + pub(crate) rate_limit_policy: RateLimitPolicy, + /// Cached `GET` responses, when the cache is enabled. + cache: Option>>, + /// Pages requested at the same time by `fetch_all_pages`. + pub(crate) page_concurrency: usize, + /// Language of the responses (`Accept-Language`), if set. + pub(crate) language: Option, + /// Tenant (`X-Tenant`), if set. + pub(crate) tenant: Option, } impl Esi { @@ -203,10 +234,10 @@ impl Esi { let compatibility_date = builder .compatibility_date .unwrap_or_else(|| COMPATIBILITY_DATE_DEFAULT.to_owned()); - let op_index = builder + let index = builder .spec .as_ref() - .map(Spec::operation_index) + .map(SpecIndex::new) .unwrap_or_default(); let e = Esi { compatibility_date: compatibility_date.clone(), @@ -224,9 +255,23 @@ impl Esi { refresh_token: builder.refresh_token, client, spec: builder.spec, - op_index, + spec_info: None, + index, error_limit_state: Arc::new(RwLock::new(None)), rate_limits: Arc::new(RwLock::new(HashMap::new())), + limiter: Arc::new(RateLimiter::default()), + rate_limit_policy: builder.rate_limit_policy.unwrap_or_default(), + page_concurrency: builder.page_concurrency.unwrap_or(4).max(1), + language: builder.language, + tenant: builder.tenant.clone(), + cache: builder.cache_enabled.unwrap_or(false).then(|| { + Arc::new(RwLock::new(ResponseCache::with_limits( + builder + .cache_max_entries + .unwrap_or(crate::cache::DEFAULT_MAX_ENTRIES), + builder.cache_max_bytes, + ))) + }), }; Ok(e) } @@ -257,32 +302,87 @@ impl Esi { /// # .unwrap(); /// esi.update_spec().await.unwrap(); /// # } + /// ``` + /// + /// This always makes a request. If the spec was downloaded before with the + /// same compatibility date and the server gave it an `ETag` or + /// `Last-Modified`, the request is conditional (`If-None-Match` / + /// `If-Modified-Since`) and a `304 Not Modified` keeps the spec in memory + /// instead of downloading it again. To skip the request while the spec is + /// still fresh, use [`Esi::ensure_spec_fresh`]. pub async fn update_spec(&mut self) -> EsiResult<()> { debug!( "Updating spec with compatibility date {}", self.compatibility_date ); self.assert_not_error_limited().await?; - let resp = self - .client - .get(&self.spec_url) - .header( - COMPATIBILITY_HEADER, - HeaderValue::from_str(&self.compatibility_date)?, - ) - .send() - .await?; + let mut request = self.client.get(&self.spec_url).header( + COMPATIBILITY_HEADER, + HeaderValue::from_str(&self.compatibility_date)?, + ); + let known = self.spec_info.as_ref().filter(|info| { + self.spec.is_some() && info.compatibility_date == self.compatibility_date + }); + if let Some(info) = known { + if let Some(etag) = &info.etag { + request = request.header(header::IF_NONE_MATCH, HeaderValue::from_str(etag)?); + } + if let Some(modified) = &info.last_modified { + request = + request.header(header::IF_MODIFIED_SINCE, HeaderValue::from_str(modified)?); + } + } + let resp = request.send().await?; self.process_response_headers(resp.headers()).await?; + if resp.status() == reqwest::StatusCode::NOT_MODIFIED && known.is_some() { + debug!("The spec has not changed"); + let expires_at = Self::spec_expiry(resp.headers())?; + if let Some(info) = &mut self.spec_info { + info.expires_at = expires_at; + } + return Ok(()); + } if !resp.status().is_success() { error!("Got status {} when requesting spec", resp.status()); return Err(Self::status_error(resp.status().as_u16(), resp.headers())); } + let info = SpecInfo { + compatibility_date: self.compatibility_date.clone(), + etag: Self::header_text(resp.headers(), "etag"), + last_modified: Self::header_text(resp.headers(), "last-modified"), + expires_at: Self::spec_expiry(resp.headers())?, + }; let data: Spec = resp.json().await?; - self.op_index = data.operation_index(); + self.index = SpecIndex::new(&data); self.spec = Some(data); + self.spec_info = Some(info); Ok(()) } + /// Make sure the spec is loaded and still fresh, requesting it only if it is + /// not: it has not been downloaded by this struct (a spec given to the + /// builder has no known age), it was downloaded with another compatibility + /// date, or its `Cache-Control: max-age` has passed. Use it before a long + /// run of calls instead of [`Esi::update_spec`] to avoid needless downloads. + pub async fn ensure_spec_fresh(&mut self) -> EsiResult<()> { + let now = current_time_millis()?; + let fresh = self.spec.is_some() + && self.spec_info.as_ref().is_some_and(|info| { + info.compatibility_date == self.compatibility_date && info.expires_at > now + }); + if fresh { + debug!("The spec is still fresh"); + return Ok(()); + } + self.update_spec().await + } + + /// When a spec downloaded now needs a new request: `max-age` after now. + fn spec_expiry(headers: &HeaderMap) -> EsiResult { + let max_age = ResponseCache::max_age(headers).unwrap_or(0); + Ok(current_time_millis()?.saturating_add(max_age.saturating_mul(1000))) + } + /// Ensure the user has specified all required EVE Developer App information. fn check_client_info(&self) -> EsiResult<()> { for (name, value) in &[ @@ -607,10 +707,239 @@ impl Esi { query: Option<&[(&str, &str)]>, body: Option<&str>, ) -> EsiResult { + let (text, _) = self + .send_request(method, request_type, endpoint, query, body) + .await?; + Self::parse_body(&text) + } + + /// Like [`Esi::query`], but also returns the total number of pages + /// reported by the `X-Pages` response header, if present. + /// + /// # Example + /// ```rust,no_run + /// # async fn run() { + /// # use esi_openapi::prelude::*; + /// # let esi = EsiBuilder::new() + /// # .user_agent("some user agent") + /// # .client_id("your_client_id") + /// # .client_secret("your_client_secret") + /// # .callback_url("your_callback_url") + /// # .build() + /// # .unwrap(); + /// let (data, pages): (Vec, Option) = esi + /// .query_with_pages("GET", RequestType::Public, "some/path", Some(&[("page", "2")])) + /// .await + /// .unwrap(); + /// # } + /// ``` + pub async fn query_with_pages( + &self, + method: &str, + request_type: RequestType, + endpoint: &str, + query: Option<&[(&str, &str)]>, + ) -> EsiResult<(T, Option)> { + let (text, headers) = self + .send_request(method, request_type, endpoint, query, None) + .await?; + Ok((Self::parse_body(&text)?, Self::pages_header(&headers))) + } + + /// Fetch every page of an endpoint paginated with the `page` query + /// parameter and return the items of all pages, in order. + /// + /// The first page is requested with `page=1`; the `X-Pages` header of its + /// response says how many pages follow, and those are requested + /// concurrently, [`EsiBuilder::page_concurrency`] at a time. Pass the other + /// query parameters of the endpoint in `query`, without `page`. `max_pages` + /// limits how many pages are fetched (`None` for all of them). + pub async fn fetch_all_pages( + &self, + request_type: RequestType, + endpoint: &str, + query: Option<&[(&str, &str)]>, + max_pages: Option, + ) -> EsiResult> { + let (mut items, total) = self + .fetch_page::(request_type, endpoint, query, 1) + .await?; + let last = total.unwrap_or(1).min(max_pages.unwrap_or(i64::MAX)); + let step = i64::try_from(self.page_concurrency).unwrap_or(1); + let mut next: i64 = 2; + while next <= last { + let end = (next + step - 1).min(last); + let batches = futures_util::future::try_join_all( + (next..=end).map(|page| self.fetch_page::(request_type, endpoint, query, page)), + ) + .await?; + for (mut batch, _) in batches { + items.append(&mut batch); + } + next = end + 1; + } + Ok(items) + } + + /// Send a `POST` whose body is a JSON array and return the answers joined + /// in order, splitting `items` into requests of at most `chunk_size` items. + /// + /// Use it for endpoints that cap the length of the array (such as + /// `characters/affiliation`, at 1000 ids). The chunks are sent + /// concurrently, [`EsiBuilder::page_concurrency`] at a time. The answer to + /// each chunk must be an array; an empty `items` sends no request and + /// returns an empty list. Duplicates are not removed, and the spec asks for + /// unique items on most of these endpoints. + pub async fn post_chunked( + &self, + request_type: RequestType, + endpoint: &str, + items: &[B], + chunk_size: usize, + ) -> EsiResult> { + let chunks: Vec<&[B]> = items.chunks(chunk_size.max(1)).collect(); + let mut results: Vec = Vec::new(); + for group in chunks.chunks(self.page_concurrency) { + let answers = futures_util::future::try_join_all( + group + .iter() + .map(|chunk| self.post_chunk::(request_type, endpoint, chunk)), + ) + .await?; + for mut answer in answers { + results.append(&mut answer); + } + } + Ok(results) + } + + /// Send one chunk of a `POST` with an array body. + async fn post_chunk( + &self, + request_type: RequestType, + endpoint: &str, + chunk: &[B], + ) -> EsiResult> { + let body = serde_json::to_string(chunk)?; + self.query("POST", request_type, endpoint, None, Some(&body)) + .await + } + + /// Request one page of a `page`-paginated endpoint. + async fn fetch_page( + &self, + request_type: RequestType, + endpoint: &str, + query: Option<&[(&str, &str)]>, + page: i64, + ) -> EsiResult<(Vec, Option)> { + let page_text = page.to_string(); + let mut params: Vec<(&str, &str)> = query.unwrap_or(&[]).to_vec(); + params.push(("page", page_text.as_str())); + self.query_with_pages("GET", request_type, endpoint, Some(¶ms)) + .await + } + + /// Fetch every page of an endpoint paginated with cursors (`x-pagination: + /// cursor`) and return the items of all pages, in order. + /// + /// `items_key` is the name of the array in the response that holds the + /// records, such as `"projects"` or `"listings"`. The walk follows the + /// `cursor.after` value of each response until a page has no records or no + /// further cursor. Pass the other query parameters of the endpoint (such as + /// `limit`) in `query`, without `after` or `before`. Unless `query` has a + /// `limit`, the maximum the spec allows (100, against a default of 10) is + /// requested to need fewer calls. `max_pages` limits how many pages are + /// fetched (`None` for all of them). + pub async fn fetch_all_cursor( + &self, + request_type: RequestType, + endpoint: &str, + query: Option<&[(&str, &str)]>, + items_key: &str, + max_pages: Option, + ) -> EsiResult> { + let mut items: Vec = Vec::new(); + let mut after = String::from("0"); + let mut fetched: i64 = 0; + loop { + let mut params: Vec<(&str, &str)> = query.unwrap_or(&[]).to_vec(); + if !params.iter().any(|(key, _)| *key == "limit") { + params.push(("limit", CURSOR_PAGE_LIMIT)); + } + params.push(("after", after.as_str())); + let (text, _) = self + .send_request("GET", request_type, endpoint, Some(¶ms), None) + .await?; + let page = CursorPage::::parse(&text, items_key)?; + fetched += 1; + let empty = page.records.is_empty(); + let next = page.next; + items.extend(page.records); + match next { + Some(next) + if !empty && next != after && fetched < max_pages.unwrap_or(i64::MAX) => + { + after = next; + } + _ => return Ok(items), + } + } + } + + /// The total number of pages from the `X-Pages` header. + fn pages_header(headers: &HeaderMap) -> Option { + headers + .get("x-pages") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse().ok()) + } + + /// Read a response body as JSON. A body that is empty (such as `204 No + /// Content`) is read as JSON `null`, so `()` and `Option<_>` return types + /// work for it. + fn parse_body(text: &str) -> EsiResult { + let text = if text.trim().is_empty() { "null" } else { text }; + Ok(serde_json::from_str(text)?) + } + + /// Send a request and return the body text and the response headers. + async fn send_request( + &self, + method: &str, + request_type: RequestType, + endpoint: &str, + query: Option<&[(&str, &str)]>, + body: Option<&str>, + ) -> EsiResult<(String, HeaderMap)> { debug!("Making {request_type:?} {method} request to {endpoint} with query: {query:?}"); + let cache_key = self.cache_key(method, &request_type, endpoint, query); + let mut stored: Option = None; + if let Some(key) = &cache_key { + if let Some(entry) = self.cache_lookup(key).await { + if entry.expires_at > current_time_millis()? { + debug!("Serving {endpoint} from the cache"); + return Ok((entry.body, entry.headers)); + } + stored = Some(entry); + } + } self.assert_not_error_limited().await?; self.check_authentication(&request_type)?; - let headers = self.request_headers(&request_type)?; + let bucket = self.bucket_key(method, &request_type, endpoint); + let _permit = match &bucket { + Some(key) => Some(self.acquire_permit(key).await?), + None => None, + }; + let mut headers = self.request_headers(&request_type)?; + if let Some(entry) = &stored { + if let Some(etag) = &entry.etag { + headers.insert(header::IF_NONE_MATCH, HeaderValue::from_str(etag)?); + } + if let Some(modified) = &entry.last_modified { + headers.insert(header::IF_MODIFIED_SINCE, HeaderValue::from_str(modified)?); + } + } let url = format!("{}{endpoint}", self.base_api_url); let mut req_builder = self .client @@ -623,13 +952,131 @@ impl Esi { }; let req = req_builder.build()?; let resp = self.client.execute(req).await?; - self.process_response_headers(resp.headers()).await?; + let rate_limit = self.process_response_headers(resp.headers()).await?; + if let Some(key) = &bucket { + self.record_budget(key, rate_limit.as_ref(), resp.status(), resp.headers())?; + } + if resp.status() == reqwest::StatusCode::NOT_MODIFIED { + if let (Some(key), Some(entry)) = (&cache_key, stored) { + debug!("{endpoint} not modified; reusing the cached body"); + let expires_at = self.cache_expiry(endpoint, resp.headers())?; + self.cache_refresh(key, expires_at).await; + return Ok((entry.body, entry.headers)); + } + } if !resp.status().is_success() { - return Err(Self::status_error(resp.status().as_u16(), resp.headers())); + let status = resp.status().as_u16(); + if matches!(status, 404 | 410) { + if let Some(ttl) = self.index.tombstone_ttl(endpoint) { + return Err(EsiError::Gone { + status, + tombstone_ttl_secs: ttl, + }); + } + } + return Err(Self::status_error(status, resp.headers())); } + let headers = resp.headers().clone(); let text = resp.text().await?; - let data: T = serde_json::from_str(&text)?; - Ok(data) + if let Some(key) = cache_key { + let entry = CacheEntry { + etag: Self::header_text(&headers, "etag"), + last_modified: Self::header_text(&headers, "last-modified"), + body: text.clone(), + headers: headers.clone(), + expires_at: self.cache_expiry(endpoint, &headers)?, + last_used: 0, + }; + self.cache_store(key, entry).await?; + } + Ok((text, headers)) + } + + /// The cache key of a request, if the cache is enabled and the request is a `GET`. + fn cache_key( + &self, + method: &str, + request_type: &RequestType, + endpoint: &str, + query: Option<&[(&str, &str)]>, + ) -> Option { + self.cache.as_ref()?; + if !method.eq_ignore_ascii_case("GET") { + return None; + } + let token = match request_type { + RequestType::Authenticated => self.access_token.as_deref(), + RequestType::Public => None, + }; + let url = format!("{}{endpoint}", self.base_api_url); + let variant = format!( + "{}|{}", + self.language.map_or("", |l| l.as_str()), + self.tenant.as_deref().unwrap_or("") + ); + Some(ResponseCache::key( + token, + &variant, + &url, + query.unwrap_or(&[]), + )) + } + + /// The cached entry for a key, marking it as recently used. + async fn cache_lookup(&self, key: &str) -> Option { + self.cache.as_ref()?.write().await.lookup(key) + } + + async fn cache_store(&self, key: String, entry: CacheEntry) -> EsiResult<()> { + if let Some(cache) = &self.cache { + cache + .write() + .await + .insert(key, entry, current_time_millis()?); + } + Ok(()) + } + + async fn cache_refresh(&self, key: &str, expires_at: i64) { + if let Some(cache) = &self.cache { + cache.write().await.refresh(key, expires_at); + } + } + + /// When a response stored now stops being served without revalidation: the + /// `x-client-cache-ttl` of the operation in the spec, else the `max-age` + /// of the response, else immediately. + fn cache_expiry(&self, endpoint: &str, headers: &HeaderMap) -> EsiResult { + let ttl = self + .index + .client_cache_ttl(endpoint) + .or_else(|| ResponseCache::max_age(headers)) + .unwrap_or(0); + Ok(current_time_millis()? + ttl * 1000) + } + + fn header_text(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + } + + /// The number of responses held by the cache (0 when it is disabled). + pub async fn cache_len(&self) -> usize { + match &self.cache { + Some(cache) => cache.read().await.len(), + None => 0, + } + } + + /// The approximate size in bytes of the responses held by the cache (0 when + /// it is disabled). See [`EsiBuilder::cache_max_bytes`]. + pub async fn cache_bytes(&self) -> usize { + match &self.cache { + Some(cache) => cache.read().await.bytes(), + None => 0, + } } /// For an authenticated request, fails unless there is a valid, unexpired @@ -666,6 +1113,15 @@ impl Esi { COMPATIBILITY_HEADER, HeaderValue::from_str(&self.compatibility_date)?, ); + if let Some(language) = self.language { + map.insert( + header::ACCEPT_LANGUAGE, + HeaderValue::from_static(language.as_str()), + ); + } + if let Some(tenant) = &self.tenant { + map.insert(TENANT_HEADER, HeaderValue::from_str(tenant)?); + } Ok(map) } @@ -741,27 +1197,66 @@ impl Esi { if self.spec.is_none() { return Err(EsiError::EmptySpec); } - if let Some(path) = self.op_index.get(op_id) { - return Ok(path.clone()); + if let Some(path) = self.index.path(op_id) { + return Ok(path.to_owned()); } if let Some(new_id) = legacy::openapi_id_for(op_id) { warn!( "operationId '{op_id}' is a deprecated Swagger ID; use '{new_id}' instead (legacy IDs will be removed in 0.2.0)" ); - if let Some(path) = self.op_index.get(new_id) { - return Ok(path.clone()); + if let Some(path) = self.index.path(new_id) { + return Ok(path.to_owned()); } } Err(EsiError::UnknownOperationID(op_id.to_owned())) } + /// The operation's metadata from the spec. + fn spec_operation(&self, op_id: &str) -> EsiResult<&crate::spec::SpecPathMethod> { + let spec = self.spec.as_ref().ok_or(EsiError::EmptySpec)?; + self.index + .operation(spec, op_id) + .ok_or_else(|| EsiError::UnknownOperationID(op_id.to_owned())) + } + + /// The OAuth2 scopes an operation needs, from the spec. Public operations need none. + pub fn required_scopes(&self, op_id: &str) -> EsiResult> { + Ok(self.spec_operation(op_id)?.scopes()) + } + + /// The scopes an operation needs that are not in `granted`, a space-separated + /// scope list such as the `scope` value of a token. Use it to fail early instead + /// of making a request ESI will answer with `401`. + pub fn missing_scopes(&self, op_id: &str, granted: &str) -> EsiResult> { + let granted: Vec<&str> = granted.split_whitespace().collect(); + Ok(self + .required_scopes(op_id)? + .into_iter() + .filter(|scope| !granted.contains(&scope.as_str())) + .collect()) + } + + /// The corporation roles of which the character needs at least one for an + /// operation (empty when it needs none). + pub fn required_roles(&self, op_id: &str) -> EsiResult> { + Ok(self.spec_operation(op_id)?.required_roles.clone()) + } + + /// The rate limit each route group declares in the spec, keyed by group. + /// + /// These are the budgets before any response arrives; [`Esi::rate_limit_status`] + /// has the live values once ESI has answered for a group. + pub fn declared_rate_limits(&self) -> EsiResult> { + let spec = self.spec.as_ref().ok_or(EsiError::EmptySpec)?; + Ok(spec.rate_limit_groups()) + } + /// Build the error for a non-success response status. fn status_error(status: u16, headers: &HeaderMap) -> EsiError { if status == 429 { let header_str = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()); let group = header_str(RATE_LIMIT_GROUP_HEADER).map(str::to_owned); - let retry_after_secs = - header_str(header::RETRY_AFTER.as_str()).and_then(|v| v.trim().parse::().ok()); + let retry_after_secs = Self::retry_after_secs(headers); warn!("Rate limited by ESI (group {group:?}); retry after {retry_after_secs:?}s"); return EsiError::RateLimited { group, @@ -772,14 +1267,22 @@ impl Esi { } /// Record the error-limit and rate-limit headers of a response. - async fn process_response_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> { + /// + /// Returns the rate-limit status the response reported, if it has one. + async fn process_response_headers( + &self, + headers: &HeaderMap, + ) -> Result, EsiError> { self.process_error_limit_headers(headers).await?; self.process_rate_limit_headers(headers).await } - async fn process_rate_limit_headers(&self, headers: &HeaderMap) -> Result<(), EsiError> { + async fn process_rate_limit_headers( + &self, + headers: &HeaderMap, + ) -> Result, EsiError> { let Some(group) = headers.get(RATE_LIMIT_GROUP_HEADER) else { - return Ok(()); + return Ok(None); }; let group = group.to_str()?.to_owned(); let limit = match headers.get(RATE_LIMIT_LIMIT_HEADER) { @@ -809,10 +1312,101 @@ impl Esi { updated_at_millis: current_time_millis()?, }; debug!("Rate limit status: {status:?}"); - self.rate_limits.write().await.insert(group, status); + self.rate_limits.write().await.insert(group, status.clone()); + Ok(Some(status)) + } + + /// The key of the budget a request spends from, when throttling is on and the + /// spec says which route group the operation belongs to. + fn bucket_key( + &self, + method: &str, + request_type: &RequestType, + endpoint: &str, + ) -> Option { + if self.rate_limit_policy == RateLimitPolicy::Off { + return None; + } + let group = self.index.rate_limit_group(method, endpoint)?; + let token = match request_type { + RequestType::Authenticated => self.access_token.as_deref(), + RequestType::Public => None, + }; + Some(RateLimiter::key(group, token)) + } + + /// Reserve budget for a request, sleeping or failing as the policy says when + /// it does not fit. + async fn acquire_permit(&self, key: &str) -> EsiResult { + let started = current_time_millis()?; + loop { + let now = current_time_millis()?; + let Acquire::Wait(wait_ms) = self.limiter.try_acquire(key, now) else { + return Ok(Permit::new(Arc::clone(&self.limiter), key)); + }; + let allowed_ms = match self.rate_limit_policy { + RateLimitPolicy::Wait { max_wait } => { + i64::try_from(max_wait.as_millis()).unwrap_or(i64::MAX) + } + RateLimitPolicy::Off | RateLimitPolicy::Fail => 0, + }; + if (now - started).saturating_add(wait_ms) > allowed_ms { + let group = RateLimiter::group_of(key).to_owned(); + warn!("Not sending a request: group {group} has no tokens for {wait_ms}ms"); + return Err(EsiError::RateLimited { + group: Some(group), + retry_after_secs: Some(u64::try_from((wait_ms + 999) / 1000).unwrap_or(0)), + }); + } + debug!("Waiting {wait_ms}ms for rate-limit tokens of {key}"); + tokio::time::sleep(std::time::Duration::from_millis( + u64::try_from(wait_ms).unwrap_or(0), + )) + .await; + } + } + + /// Feed a response to the budget of its route group. + fn record_budget( + &self, + key: &str, + status: Option<&RateLimitStatus>, + http_status: reqwest::StatusCode, + headers: &HeaderMap, + ) -> EsiResult<()> { + let now = current_time_millis()?; + if let Some(status) = status { + let window_ms = status + .window_secs + .and_then(|secs| i64::try_from(secs).ok()) + .map_or(0, |secs| secs.saturating_mul(1000)); + self.limiter.record( + key, + status.remaining, + status.used, + window_ms, + http_status.is_success(), + now, + ); + } + if http_status.as_u16() == 429 { + if let Some(secs) = Self::retry_after_secs(headers) { + let secs = i64::try_from(secs).unwrap_or(0); + self.limiter + .block_until(key, now.saturating_add(secs.saturating_mul(1000))); + } + } Ok(()) } + /// The seconds in a `Retry-After` header, if it has them. + fn retry_after_secs(headers: &HeaderMap) -> Option { + headers + .get(header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse().ok()) + } + /// Latest rate-limit status ESI reported for a route group /// (the `X-Ratelimit-Group` header value, e.g. `market`). /// @@ -893,6 +1487,16 @@ impl Esi { self.spec.as_ref() } + /// Call endpoints under the "Access List" group in ESI. + pub fn group_access_list(&self) -> AccessListGroup<'_> { + AccessListGroup { esi: self } + } + + /// Call endpoints under the "Activities" group in ESI. + pub fn group_activities(&self) -> ActivitiesGroup<'_> { + ActivitiesGroup { esi: self } + } + /// Call endpoints under the "alliance" group in ESI. pub fn group_alliance(&self) -> AllianceGroup<'_> { AllianceGroup { esi: self } @@ -938,6 +1542,11 @@ impl Esi { CorporationGroup { esi: self } } + /// Call endpoints under the "Corporation Projects" group in ESI. + pub fn group_corporation_projects(&self) -> CorporationProjectsGroup<'_> { + CorporationProjectsGroup { esi: self } + } + /// Call endpoints under the "Dogma" group in ESI. pub fn group_dogma(&self) -> DogmaGroup<'_> { DogmaGroup { esi: self } @@ -1013,6 +1622,31 @@ impl Esi { RoutesGroup { esi: self } } + /// Call endpoints under the "Cosmetics" group in ESI. + pub fn group_cosmetics(&self) -> CosmeticsGroup<'_> { + CosmeticsGroup { esi: self } + } + + /// Call endpoints under the "Paragon Hub" group in ESI. + pub fn group_paragon_hub(&self) -> ParagonHubGroup<'_> { + ParagonHubGroup { esi: self } + } + + /// Call endpoints under the "Freelance Jobs" group in ESI. + pub fn group_freelance_jobs(&self) -> FreelanceJobsGroup<'_> { + FreelanceJobsGroup { esi: self } + } + + /// Call endpoints under the "Military Campaigns" group in ESI. + pub fn group_military_campaigns(&self) -> MilitaryCampaignsGroup<'_> { + MilitaryCampaignsGroup { esi: self } + } + + /// Call endpoints under the "Meta" group in ESI. + pub fn group_meta(&self) -> MetaGroup<'_> { + MetaGroup { esi: self } + } + /// Call endpoints under the "Search" group in ESI. pub fn group_search(&self) -> SearchGroup<'_> { SearchGroup { esi: self } @@ -1028,6 +1662,11 @@ impl Esi { SovereigntyGroup { esi: self } } + /// Call endpoints under the "Structures" group in ESI. + pub fn group_structures(&self) -> StructuresGroup<'_> { + StructuresGroup { esi: self } + } + /// Call endpoints under the "Status" group in ESI. pub fn group_status(&self) -> StatusGroup<'_> { StatusGroup { esi: self } @@ -1073,6 +1712,43 @@ mod tests { use crate::errors::EsiError; use crate::prelude::EsiBuilder; use crate::spec::Spec; + + #[test] + fn test_spec_metadata_helpers() { + let spec: Spec = serde_json::from_str(FIXTURE).unwrap(); + let esi = EsiBuilder::new() + .user_agent("t") + .spec(Some(spec)) + .build() + .unwrap(); + let op = "GetCorporationsCorporationIdBlueprints"; + assert_eq!(esi.required_roles(op).unwrap(), ["Director"]); + assert_eq!( + esi.missing_scopes(op, "esi-assets.read_assets.v1").unwrap(), + ["esi-corporations.read_blueprints.v1"] + ); + assert!(esi + .missing_scopes(op, "a esi-corporations.read_blueprints.v1") + .unwrap() + .is_empty()); + assert!(matches!( + esi.required_scopes("Nope"), + Err(EsiError::UnknownOperationID(_)) + )); + assert!(esi.declared_rate_limits().unwrap().len() > 5); + } + + #[test] + fn test_pages_header_and_empty_body() { + let mut headers = reqwest::header::HeaderMap::new(); + assert_eq!(Esi::pages_header(&headers), None); + headers.insert("x-pages", "7".parse().unwrap()); + assert_eq!(Esi::pages_header(&headers), Some(7)); + let unit: () = Esi::parse_body("").unwrap(); + assert_eq!(unit, ()); + let numbers: Vec = Esi::parse_body("[1, 2]").unwrap(); + assert_eq!(numbers, [1, 2]); + } use http::{HeaderMap, HeaderValue}; use std::time::Duration; diff --git a/src/cursor_page.rs b/src/cursor_page.rs new file mode 100644 index 0000000..742636c --- /dev/null +++ b/src/cursor_page.rs @@ -0,0 +1,135 @@ +//! Single-pass reading of cursor-paginated responses. +//! +//! A cursor page holds an array of records under a key that depends on the +//! endpoint (`projects`, `listings`, ...) next to a `cursor` object. Reading it +//! through a generic [`serde_json::Value`] would build the whole tree and then +//! convert it again; this reads the records straight into `T`. + +use crate::prelude::EsiResult; +use serde::de::{DeserializeOwned, DeserializeSeed, IgnoredAny, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use std::{fmt, marker::PhantomData}; + +/// One page of a cursor-paginated endpoint. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct CursorPage { + /// The records of the page. + pub(crate) records: Vec, + /// The `cursor.after` value, if the response has one. + pub(crate) next: Option, +} + +#[derive(Deserialize)] +struct CursorAfter { + #[serde(default)] + after: Option, +} + +impl CursorPage { + fn empty() -> Self { + CursorPage { + records: Vec::new(), + next: None, + } + } + + /// Read a response body, taking the records from `items_key`. A body that is + /// empty or `null` is a page without records; a missing `items_key` too. + pub(crate) fn parse(text: &str, items_key: &str) -> EsiResult { + if text.trim().is_empty() { + return Ok(Self::empty()); + } + let mut deserializer = serde_json::Deserializer::from_str(text); + let seed = CursorPageSeed { + items_key, + marker: PhantomData, + }; + let page = seed.deserialize(&mut deserializer)?; + deserializer.end()?; + Ok(page) + } +} + +/// Carries the name of the records key into the deserializer. +struct CursorPageSeed<'a, T> { + items_key: &'a str, + marker: PhantomData, +} + +impl<'de, T: DeserializeOwned> DeserializeSeed<'de> for CursorPageSeed<'_, T> { + type Value = CursorPage; + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } +} + +impl<'de, T: DeserializeOwned> Visitor<'de> for CursorPageSeed<'_, T> { + type Value = CursorPage; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a cursor-paginated response object") + } + + fn visit_unit(self) -> Result { + Ok(CursorPage::empty()) + } + + fn visit_map>(self, mut map: A) -> Result { + let mut page = CursorPage::empty(); + while let Some(key) = map.next_key::()? { + if key == self.items_key { + page.records = map.next_value::>>()?.unwrap_or_default(); + } else if key == "cursor" { + page.next = map + .next_value::>()? + .and_then(|cursor| cursor.after); + } else { + map.next_value::()?; + } + } + Ok(page) + } +} + +#[cfg(test)] +mod tests { + use super::CursorPage; + + #[derive(Debug, serde::Deserialize, PartialEq)] + struct Record { + id: i64, + } + + #[test] + fn test_reads_records_and_next_cursor() { + let text = r#"{"cursor": {"after": "abc", "before": "xyz"}, "projects": [{"id": 1}, {"id": 2}], "other": [1, 2, 3]}"#; + let page: CursorPage = CursorPage::parse(text, "projects").unwrap(); + assert_eq!(page.records, [Record { id: 1 }, Record { id: 2 }]); + assert_eq!(page.next.as_deref(), Some("abc")); + } + + #[test] + fn test_missing_parts_are_empty() { + let page: CursorPage = + CursorPage::parse(r#"{"projects": []}"#, "projects").unwrap(); + assert!(page.records.is_empty() && page.next.is_none()); + for text in ["{}", "", "null", r#"{"cursor": null, "projects": null}"#] { + let page: CursorPage = CursorPage::parse(text, "projects").unwrap(); + assert!(page.records.is_empty() && page.next.is_none(), "{text}"); + } + let page: CursorPage = CursorPage::parse( + r#"{"cursor": {"before": "x"}, "projects": [{"id": 7}]}"#, + "projects", + ) + .unwrap(); + assert_eq!(page.records.len(), 1); + assert!(page.next.is_none()); + } + + #[test] + fn test_invalid_json_and_record_errors() { + assert!(CursorPage::::parse("{", "projects").is_err()); + assert!(CursorPage::::parse(r#"{"projects": [{"id": "x"}]}"#, "projects").is_err()); + } +} diff --git a/src/errors.rs b/src/errors.rs index ceb9261..dd6bcdd 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -35,6 +35,17 @@ pub enum EsiError { /// [by reqwest]: https://docs.rs/reqwest/0.10.6/reqwest/struct.StatusCode.html#method.is_success #[error("Invalid HTTP status code received: {0}")] InvalidStatusCode(u16), + /// The resource was deleted. ESI keeps answering for it as a tombstone for + /// `tombstone_ttl_secs` seconds (`x-tombstone-ttl`), so this is not a plain + /// "not found". Only operations that declare a tombstone TTL return it, for + /// HTTP `404` and `410`. + #[error("The resource is gone (HTTP {status}); ESI keeps it as a tombstone for {tombstone_ttl_secs}s")] + Gone { + /// The HTTP status code of the response. + status: u16, + /// How long, in seconds, ESI keeps the tombstone. + tombstone_ttl_secs: i64, + }, /// Error for if the provided user-agent header value has invalid characters. #[error("Invalid HTTP header value")] InvalidUserAgentHeader(#[from] http::header::InvalidHeaderValue), diff --git a/src/groups/access_list.rs b/src/groups/access_list.rs new file mode 100644 index 0000000..a3f4be1 --- /dev/null +++ b/src/groups/access_list.rs @@ -0,0 +1,128 @@ +use crate::prelude::*; + +/// Endpoints for Access List +pub struct AccessListGroup<'a> { + pub(crate) esi: &'a Esi, +} + +/// A reference to an access list. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct AccessListSummary { + /// ID of the access list. + pub id: i64, +} + +#[derive(Debug, Deserialize)] +struct AccessLists { + access_lists: Vec, +} + +/// The access level granted to a member of an access list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum AccessLevel { + /// No level specified. + Unspecified, + /// Allowed. + Allowed, + /// Blocked. + Blocked, + /// Can manage the list. + Manager, + /// Full administrator of the list. + Admin, + /// A level this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// An alliance in an access list. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct AllianceAccess { + pub access: AccessLevel, + pub alliance_id: i64, +} + +/// A character in an access list. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct CharacterAccess { + pub access: AccessLevel, + pub character_id: i64, +} + +/// A corporation in an access list. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct CorporationAccess { + pub access: AccessLevel, + pub corporation_id: i64, +} + +/// Who belongs to an access list. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct AccessListMembership { + pub alliances: Vec, + pub allow_everyone: bool, + pub characters: Vec, + pub corporations: Vec, +} + +/// An access list with its members. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct AccessList { + pub description: String, + pub id: i64, + pub membership: AccessListMembership, + pub name: String, +} + +impl AccessListGroup<'_> { + /// List the access lists of a character. + pub async fn get_character_access_lists( + &self, + character_id: i64, + ) -> EsiResult> { + let path = self + .esi + .get_endpoint_for_op_id("GetCharactersAccessListsListing")? + .replace("{character_id}", &character_id.to_string()); + let wrapper: AccessLists = self + .esi + .query("GET", RequestType::Authenticated, &path, None, None) + .await?; + Ok(wrapper.access_lists) + } + + api_get!( + /// Get an access list of a character with its members. + get_character_access_list, + "GetCharactersAccessListsDetail", + RequestType::Authenticated, + AccessList, + (character_id: i64) => "{character_id}", + (access_list_id: i64) => "{access_list_id}" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_access_list() { + let list: AccessList = serde_json::from_str( + r#"{"description":"d","id":1,"name":"n","membership":{"allow_everyone":false, + "alliances":[],"corporations":[], + "characters":[{"access":"Admin","character_id":2},{"access":"Odd","character_id":3}]}}"#, + ) + .unwrap(); + assert_eq!(list.membership.characters[0].access, AccessLevel::Admin); + assert_eq!( + list.membership.characters[1].access, + AccessLevel::Unrecognized + ); + } +} diff --git a/src/groups/activities.rs b/src/groups/activities.rs new file mode 100644 index 0000000..524a298 --- /dev/null +++ b/src/groups/activities.rs @@ -0,0 +1,134 @@ +use crate::prelude::*; +use uuid::Uuid; + +/// Endpoints for Activities +pub struct ActivitiesGroup<'a> { + pub(crate) esi: &'a Esi, +} + +/// A mercenary tactical operation of a character, as listed. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TacticalOperationSummary { + /// ID of the operation. + pub id: Uuid, + /// ID of the mercenary den that hosts the operation. + pub mercenary_den_id: i64, +} + +#[derive(Debug, Deserialize)] +struct TacticalOperations { + operations: Vec, +} + +/// State of a mercenary tactical operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum TacticalOperationState { + /// No state specified. + Unspecified, + /// Available to start. + Available, + /// Started. + Started, + /// Completed. + Completed, + /// Expired. + Expired, + /// Removed. + Removed, + /// A state this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A mercenary tactical operation of a character. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct TacticalOperation { + pub dungeon_type_id: i64, + pub expires: String, + pub id: Uuid, + pub mercenary_den_id: i64, + pub state: TacticalOperationState, +} + +/// The window in which a skyhook can be raided. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TheftVulnerability { + /// When the window closes. + pub end: String, + /// When the window opens. + pub start: String, +} + +/// A skyhook that can currently be raided. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct RaidableSkyhook { + pub planet_id: i64, + pub solar_system_id: i64, + pub theft_vulnerability: TheftVulnerability, +} + +#[derive(Debug, Deserialize)] +struct RaidableSkyhooks { + skyhooks: Vec, +} + +impl ActivitiesGroup<'_> { + /// List the mercenary tactical operations of a character. + pub async fn get_character_tactical_operations( + &self, + character_id: i64, + ) -> EsiResult> { + let path = self + .esi + .get_endpoint_for_op_id("GetCharactersMercenaryTacticalOperationsListing")? + .replace("{character_id}", &character_id.to_string()); + let wrapper: TacticalOperations = self + .esi + .query("GET", RequestType::Authenticated, &path, None, None) + .await?; + Ok(wrapper.operations) + } + + api_get!( + /// Get a mercenary tactical operation of a character. + get_character_tactical_operation, + "GetCharactersMercenaryTacticalOperationsDetail", + RequestType::Authenticated, + TacticalOperation, + (character_id: i64) => "{character_id}", + (operation_id: Uuid) => "{operation_id}" + ); + + /// List the skyhooks that are currently vulnerable to theft. + pub async fn get_raidable_skyhooks(&self) -> EsiResult> { + let path = self.esi.get_endpoint_for_op_id("GetSkyhooksRaidable")?; + let wrapper: RaidableSkyhooks = self + .esi + .query("GET", RequestType::Public, &path, None, None) + .await?; + Ok(wrapper.skyhooks) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_operation_and_skyhook() { + let op: TacticalOperation = serde_json::from_str( + r#"{"dungeon_type_id":1,"expires":"x","id":"67e6b7a0-6f58-4b5b-a2c4-4f3a1f0f2b11", + "mercenary_den_id":2,"state":"Weird"}"#, + ) + .unwrap(); + assert_eq!(op.state, TacticalOperationState::Unrecognized); + let hooks: RaidableSkyhooks = serde_json::from_str( + r#"{"skyhooks":[{"planet_id":1,"solar_system_id":2, + "theft_vulnerability":{"start":"a","end":"b"}}]}"#, + ) + .unwrap(); + assert_eq!(hooks.skyhooks[0].solar_system_id, 2); + } +} diff --git a/src/groups/alliance.rs b/src/groups/alliance.rs index 1de2c2c..76efd1f 100644 --- a/src/groups/alliance.rs +++ b/src/groups/alliance.rs @@ -8,11 +8,11 @@ pub struct AllianceGroup<'a> { #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct AllianceInfo { - pub creator_corporation_id: i32, - pub creator_id: i32, + pub creator_corporation_id: i64, + pub creator_id: i64, pub date_founded: String, - pub executor_corporation_id: Option, - pub faction_id: Option, + pub executor_corporation_id: Option, + pub faction_id: Option, pub name: String, pub ticker: String, } @@ -30,7 +30,7 @@ impl AllianceGroup<'_> { list_ids, "GetAlliances", RequestType::Public, - Vec, + Vec, ); api_get!( @@ -39,7 +39,7 @@ impl AllianceGroup<'_> { "GetAlliancesAllianceId", RequestType::Public, AllianceInfo, - (alliance_id: i32) => "{alliance_id}" + (alliance_id: i64) => "{alliance_id}" ); api_get!( @@ -47,8 +47,8 @@ impl AllianceGroup<'_> { get_alliance_corporations, "GetAlliancesAllianceIdCorporations", RequestType::Public, - Vec, - (alliance_id: i32) => "{alliance_id}" + Vec, + (alliance_id: i64) => "{alliance_id}" ); api_get!( @@ -57,6 +57,6 @@ impl AllianceGroup<'_> { "GetAlliancesAllianceIdIcons", RequestType::Public, AllianceIcons, - (alliance_id: i32) => "{alliance_id}" + (alliance_id: i64) => "{alliance_id}" ); } diff --git a/src/groups/assets.rs b/src/groups/assets.rs index 1b51f33..79c9759 100644 --- a/src/groups/assets.rs +++ b/src/groups/assets.rs @@ -1,3 +1,4 @@ +use crate::groups::Position; use crate::prelude::*; /// Endpoints for Assets @@ -14,29 +15,21 @@ pub struct Asset { pub location_flag: String, pub location_id: i64, pub location_type: String, - pub quantity: i32, - pub type_id: i32, -} - -#[derive(Debug, Deserialize)] -#[allow(missing_docs)] -pub struct AssetLocationPosition { - pub x: f64, - pub y: f64, - pub z: f64, + pub quantity: i64, + pub type_id: i64, } #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct AssetLocation { pub item_id: i64, - pub position: AssetLocationPosition, + pub position: Position, } #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct AssetName { - pub item_id: u64, + pub item_id: i64, pub name: String, } @@ -47,7 +40,7 @@ impl AssetsGroup<'_> { "GetCharactersCharacterIdAssets", RequestType::Authenticated, Vec, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); api_post!( @@ -56,8 +49,8 @@ impl AssetsGroup<'_> { "PostCharactersCharacterIdAssetsLocations", RequestType::Authenticated, Vec, - (character_id: i32) => "{character_id}", - item_ids: &[i64], + (character_id: i64) => "{character_id}" + ; Chunked(item_ids: &[i64], 1000) ); api_post!( @@ -66,8 +59,8 @@ impl AssetsGroup<'_> { "PostCharactersCharacterIdAssetsNames", RequestType::Authenticated, Vec, - (character_id: i32) => "{character_id}", - item_ids: &[u64], + (character_id: i64) => "{character_id}" + ; Chunked(item_ids: &[i64], 1000) ); api_get!( @@ -78,7 +71,7 @@ impl AssetsGroup<'_> { "GetCorporationsCorporationIdAssets", RequestType::Authenticated, Vec, - (corporation_id: u64) => "{corporation_id}" + (corporation_id: i64) => "{corporation_id}" ); api_post!( @@ -89,8 +82,8 @@ impl AssetsGroup<'_> { "PostCorporationsCorporationIdAssetsLocations", RequestType::Authenticated, Vec, - (corporation_id: u64) => "{corporation_id}", - item_ids: &[u64], + (corporation_id: i64) => "{corporation_id}" + ; Chunked(item_ids: &[i64], 1000) ); api_post!( @@ -101,7 +94,7 @@ impl AssetsGroup<'_> { "PostCorporationsCorporationIdAssetsNames", RequestType::Authenticated, Vec, - (corporation_id: u64) => "{corporation_id}", - item_ids: &[u64], + (corporation_id: i64) => "{corporation_id}" + ; Chunked(item_ids: &[i64], 1000) ); } diff --git a/src/groups/calendar.rs b/src/groups/calendar.rs index 45e92cc..4ce812b 100644 --- a/src/groups/calendar.rs +++ b/src/groups/calendar.rs @@ -6,3 +6,192 @@ use crate::prelude::*; pub struct CalendarGroup<'a> { pub(crate) esi: &'a Esi, } + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +/// The response of a character to a calendar event. +pub enum EventResponse { + /// The character declined. + #[serde(rename = "declined")] + Declined, + /// The character has not responded yet. + #[serde(rename = "not_responded")] + NotResponded, + /// The character accepted. + #[serde(rename = "accepted")] + Accepted, + /// The character is tentative. + #[serde(rename = "tentative")] + Tentative, + /// A value not known to this version of the crate. + #[serde(other)] + Unrecognized, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +/// The response a character can send to an event. Unlike [`EventResponse`], +/// "not responded" cannot be sent. +pub enum EventReply { + /// Accept the event. + #[serde(rename = "accepted")] + Accepted, + /// Decline the event. + #[serde(rename = "declined")] + Declined, + /// Mark the event as tentative. + #[serde(rename = "tentative")] + Tentative, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +/// The kind of entity that owns a calendar event. +pub enum EventOwnerType { + /// The game server. + #[serde(rename = "eve_server")] + EveServer, + /// A corporation. + #[serde(rename = "corporation")] + Corporation, + /// A faction. + #[serde(rename = "faction")] + Faction, + /// A character. + #[serde(rename = "character")] + Character, + /// An alliance. + #[serde(rename = "alliance")] + Alliance, + /// A value not known to this version of the crate. + #[serde(other)] + Unrecognized, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// A calendar event summary, as returned by the event list. +pub struct CalendarEventSummary { + /// Date and time of the event. + pub event_date: Option, + /// ID of the event. + pub event_id: Option, + /// Response of the character to the event. + pub event_response: Option, + /// Importance of the event. + pub importance: Option, + /// Title of the event. + pub title: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// The details of a calendar event. +pub struct CalendarEvent { + /// Date and time of the event. + pub date: String, + /// Duration of the event in minutes. + pub duration: i64, + /// ID of the event. + pub event_id: i64, + /// Importance of the event. + pub importance: i64, + /// ID of the owner of the event. + pub owner_id: i64, + /// Name of the owner of the event. + pub owner_name: String, + /// Kind of the owner of the event. + pub owner_type: EventOwnerType, + /// Response of the character to the event. + pub response: String, + /// Description of the event. + pub text: String, + /// Title of the event. + pub title: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// An attendee of a calendar event. +pub struct EventAttendee { + /// ID of the attendee. + pub character_id: Option, + /// Response of the attendee. + pub event_response: Option, +} + +#[derive(Debug, Serialize, Clone, Copy)] +/// The body of a request to respond to an event. +pub struct EventResponseRequest { + /// The response to send. + pub response: EventReply, +} + +impl CalendarGroup<'_> { + api_get!( + /// Return up to 50 events from a character's calendar, starting at + /// `from_event` when given. + get_character_events, + "GetCharactersCharacterIdCalendar", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}"; + Optional(from_event: i64) => "from_event" + ); + + api_get!( + /// Return the details of a calendar event. + get_character_event, + "GetCharactersCharacterIdCalendarEventId", + RequestType::Authenticated, + CalendarEvent, + (character_id: i64) => "{character_id}", + (event_id: i64) => "{event_id}" + ); + + api_put!( + /// Respond to a calendar event. + respond_to_character_event, + "PutCharactersCharacterIdCalendarEventId", + RequestType::Authenticated, + (), + (character_id: i64) => "{character_id}", + (event_id: i64) => "{event_id}", + response: &EventResponseRequest, + ); + + api_get!( + /// Return the attendees of a calendar event. + get_character_event_attendees, + "GetCharactersCharacterIdCalendarEventIdAttendees", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}", + (event_id: i64) => "{event_id}" + ); +} + +#[cfg(test)] +mod calendar_tests { + use super::*; + + #[test] + fn test_parse_events() { + let json = r#"[{"event_date": "2026-10-01T00:00:00Z", "event_id": 3000000000, + "event_response": "not_responded", "importance": 1, "title": "t"}, + {"event_id": 1, "event_response": "maybe"}]"#; + let ev: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(ev[0].event_id, Some(3_000_000_000)); + assert_eq!(ev[0].event_response, Some(EventResponse::NotResponded)); + assert_eq!(ev[1].event_response, Some(EventResponse::Unrecognized)); + } + + #[test] + fn test_parse_event_and_serialize_reply() { + let json = r#"{"date": "d", "duration": 60, "event_id": 1, "importance": 0, "owner_id": 5, + "owner_name": "n", "owner_type": "eve_server", "response": "accepted", "text": "x", "title": "t"}"#; + let e: CalendarEvent = serde_json::from_str(json).unwrap(); + assert_eq!(e.owner_type, EventOwnerType::EveServer); + let body = EventResponseRequest { + response: EventReply::Tentative, + }; + assert_eq!( + serde_json::to_string(&body).unwrap(), + r#"{"response":"tentative"}"# + ); + } +} diff --git a/src/groups/character.rs b/src/groups/character.rs index 21ffdec..679bd71 100644 --- a/src/groups/character.rs +++ b/src/groups/character.rs @@ -9,26 +9,26 @@ pub struct CharacterGroup<'a> { #[allow(missing_docs)] pub struct CharacterPublicInfo { pub achievement_score: Option, - pub alliance_id: Option, + pub alliance_id: Option, pub birthday: String, - pub bloodline_id: i32, + pub bloodline_id: i64, pub character_title_id: Option, - pub corporation_id: i32, + pub corporation_id: i64, pub corporation_title: Option, pub description: Option, - pub faction_id: Option, + pub faction_id: Option, pub gender: String, pub name: String, - pub race_id: u16, + pub race_id: i64, pub security_status: Option, } #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct CharacterCorporationHistoryItem { - pub corporation_id: i32, + pub corporation_id: i64, pub is_deleted: Option, - pub record_id: i32, + pub record_id: i64, pub start_date: String, } @@ -44,23 +44,10 @@ pub struct CharacterPortraitInfo { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct CharacterAffiliation { - pub alliance_id: Option, - pub character_id: i32, - pub corporation_id: i32, - pub faction_id: Option, -} - -#[derive(Debug, Deserialize)] -#[allow(missing_docs)] -pub struct Blueprint { - pub item_id: i64, - pub location_flag: String, - pub location_id: i64, - pub material_efficiency: i32, - pub quantity: i32, - pub runs: i32, - pub time_efficiency: i32, - pub type_id: i32, + pub alliance_id: Option, + pub character_id: i64, + pub corporation_id: i64, + pub faction_id: Option, } #[derive(Debug, Deserialize)] @@ -69,7 +56,7 @@ pub struct Notification { #[serde(default)] pub is_read: bool, pub notification_id: i64, - pub sender_id: i32, + pub sender_id: i64, pub sender_type: String, pub text: Option, pub timestamp: String, @@ -80,15 +67,15 @@ pub struct Notification { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct WalletTransaction { - pub client_id: i32, + pub client_id: i64, pub date: String, pub is_buy: bool, pub is_personal: bool, pub journal_ref_id: i64, pub location_id: i64, - pub quantity: i32, + pub quantity: i64, pub transaction_id: i64, - pub type_id: i32, + pub type_id: i64, pub unit_price: f64, } @@ -99,7 +86,7 @@ impl CharacterGroup<'_> { "GetCharactersDetail", RequestType::Public, CharacterPublicInfo, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); api_get!( @@ -108,7 +95,7 @@ impl CharacterGroup<'_> { "GetCharactersCharacterIdCorporationhistory", RequestType::Public, Vec, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); api_get!( @@ -117,7 +104,7 @@ impl CharacterGroup<'_> { "GetCharactersCharacterIdPortrait", RequestType::Public, CharacterPortraitInfo, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); api_post!( @@ -126,8 +113,7 @@ impl CharacterGroup<'_> { "PostCharactersAffiliation", RequestType::Public, Vec, - , - character_ids: &[u64], + ; Chunked(character_ids: &[i64], 1000) ); api_get!( @@ -136,7 +122,7 @@ impl CharacterGroup<'_> { "GetCharactersCharacterIdBlueprints", RequestType::Authenticated, Vec, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); api_get!( @@ -145,7 +131,7 @@ impl CharacterGroup<'_> { "GetCharactersCharacterIdNotifications", RequestType::Authenticated, Vec, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); api_get!( @@ -154,6 +140,371 @@ impl CharacterGroup<'_> { "GetCharactersCharacterIdWalletTransactions", RequestType::Authenticated, Vec, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); } + +/// A research agent a character works with. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct ResearchAgent { + pub agent_id: i64, + pub points_per_day: f64, + pub remainder_points: f64, + pub skill_type_id: i64, + pub started_at: String, +} + +/// Jump fatigue of a character. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct Fatigue { + pub jump_fatigue_expire_date: Option, + pub last_jump_date: Option, + pub last_update_date: Option, +} + +/// Whether a medal is visible to everyone. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MedalStatus { + /// Visible to everyone. + Public, + /// Visible only to the owner. + Private, + /// A status this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A layer of a medal's artwork. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct MedalGraphic { + pub color: Option, + pub graphic: String, + pub layer: i64, + pub part: i64, +} + +/// A medal awarded to a character. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct Medal { + pub corporation_id: i64, + pub date: String, + pub description: String, + pub graphics: Vec, + /// Character ID of the issuer. + pub issuer_id: i64, + pub medal_id: i64, + pub reason: String, + pub status: MedalStatus, + pub title: String, +} + +/// A notification about a change in contact standings. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct ContactNotification { + pub message: String, + pub notification_id: i64, + pub send_date: String, + /// Character ID of the sender. + pub sender_character_id: i64, + pub standing_level: f64, +} + +/// A corporation role. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[allow(missing_docs)] +pub enum CorporationRole { + #[serde(rename = "Account_Take_1")] + AccountTake1, + #[serde(rename = "Account_Take_2")] + AccountTake2, + #[serde(rename = "Account_Take_3")] + AccountTake3, + #[serde(rename = "Account_Take_4")] + AccountTake4, + #[serde(rename = "Account_Take_5")] + AccountTake5, + #[serde(rename = "Account_Take_6")] + AccountTake6, + #[serde(rename = "Account_Take_7")] + AccountTake7, + #[serde(rename = "Accountant")] + Accountant, + #[serde(rename = "Auditor")] + Auditor, + #[serde(rename = "Brand_Manager")] + BrandManager, + #[serde(rename = "Communications_Officer")] + CommunicationsOfficer, + #[serde(rename = "Config_Equipment")] + ConfigEquipment, + #[serde(rename = "Config_Starbase_Equipment")] + ConfigStarbaseEquipment, + #[serde(rename = "Container_Take_1")] + ContainerTake1, + #[serde(rename = "Container_Take_2")] + ContainerTake2, + #[serde(rename = "Container_Take_3")] + ContainerTake3, + #[serde(rename = "Container_Take_4")] + ContainerTake4, + #[serde(rename = "Container_Take_5")] + ContainerTake5, + #[serde(rename = "Container_Take_6")] + ContainerTake6, + #[serde(rename = "Container_Take_7")] + ContainerTake7, + #[serde(rename = "Contract_Manager")] + ContractManager, + #[serde(rename = "Deliveries_Container_Take")] + DeliveriesContainerTake, + #[serde(rename = "Deliveries_Query")] + DeliveriesQuery, + #[serde(rename = "Deliveries_Take")] + DeliveriesTake, + #[serde(rename = "Diplomat")] + Diplomat, + #[serde(rename = "Director")] + Director, + #[serde(rename = "Factory_Manager")] + FactoryManager, + #[serde(rename = "Fitting_Manager")] + FittingManager, + #[serde(rename = "Hangar_Query_1")] + HangarQuery1, + #[serde(rename = "Hangar_Query_2")] + HangarQuery2, + #[serde(rename = "Hangar_Query_3")] + HangarQuery3, + #[serde(rename = "Hangar_Query_4")] + HangarQuery4, + #[serde(rename = "Hangar_Query_5")] + HangarQuery5, + #[serde(rename = "Hangar_Query_6")] + HangarQuery6, + #[serde(rename = "Hangar_Query_7")] + HangarQuery7, + #[serde(rename = "Hangar_Take_1")] + HangarTake1, + #[serde(rename = "Hangar_Take_2")] + HangarTake2, + #[serde(rename = "Hangar_Take_3")] + HangarTake3, + #[serde(rename = "Hangar_Take_4")] + HangarTake4, + #[serde(rename = "Hangar_Take_5")] + HangarTake5, + #[serde(rename = "Hangar_Take_6")] + HangarTake6, + #[serde(rename = "Hangar_Take_7")] + HangarTake7, + #[serde(rename = "Junior_Accountant")] + JuniorAccountant, + #[serde(rename = "Personnel_Manager")] + PersonnelManager, + #[serde(rename = "Project_Manager")] + ProjectManager, + #[serde(rename = "Rent_Factory_Facility")] + RentFactoryFacility, + #[serde(rename = "Rent_Office")] + RentOffice, + #[serde(rename = "Rent_Research_Facility")] + RentResearchFacility, + #[serde(rename = "Security_Officer")] + SecurityOfficer, + #[serde(rename = "Skill_Plan_Manager")] + SkillPlanManager, + #[serde(rename = "Starbase_Defense_Operator")] + StarbaseDefenseOperator, + #[serde(rename = "Starbase_Fuel_Technician")] + StarbaseFuelTechnician, + #[serde(rename = "Station_Manager")] + StationManager, + #[serde(rename = "Trader")] + Trader, + /// A role this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// The corporation roles of a character. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct CharacterRoles { + pub roles: Option>, + pub roles_at_base: Option>, + pub roles_at_hq: Option>, + pub roles_at_other: Option>, +} + +/// Where a standing comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StandingSource { + /// An agent. + Agent, + /// An NPC corporation. + NpcCorp, + /// A faction. + Faction, + /// A source this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A corporation title held by a character. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct CharacterTitle { + pub name: Option, + pub title_id: Option, +} + +impl CharacterGroup<'_> { + api_get!( + /// List the research agents of a character. + get_agents_research, + "GetCharactersCharacterIdAgentsResearch", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// Get a character's jump fatigue. + get_fatigue, + "GetCharactersCharacterIdFatigue", + RequestType::Authenticated, + Fatigue, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// List the medals of a character. + get_medals, + "GetCharactersCharacterIdMedals", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// List the contact standing change notifications of a character. + get_contact_notifications, + "GetCharactersCharacterIdNotificationsContacts", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// Get the corporation roles of a character. + get_roles, + "GetCharactersCharacterIdRoles", + RequestType::Authenticated, + CharacterRoles, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// List the standings of a character with agents, NPC corporations and factions. + get_standings, + "GetCharactersCharacterIdStandings", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// List the corporation titles of a character. + get_titles, + "GetCharactersCharacterIdTitles", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" + ); + + api_post!( + /// Calculate the CONCORD spam (CSPA) charge for contacting the given characters. + /// Returns the charge in ISK. + calculate_cspa_charge, + "PostCharactersCharacterIdCspa", + RequestType::Authenticated, + f64, + (character_id: i64) => "{character_id}", + characters: &[i64], + ); +} + +/// A blueprint owned by a character or a corporation. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct Blueprint { + /// ID of the item. + pub item_id: i64, + /// Where the blueprint is (see the ESI location flags). + pub location_flag: String, + /// ID of the location. + pub location_id: i64, + /// Material efficiency level. + pub material_efficiency: i64, + /// Quantity; `-1` for originals, `-2` for copies. + pub quantity: i64, + /// Remaining runs; `-1` for originals. + pub runs: i64, + /// Time efficiency level. + pub time_efficiency: i64, + /// Type ID of the blueprint. + pub type_id: i64, +} + +/// A standing a character or a corporation has with an agent, NPC corporation or faction. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct Standing { + /// ID of the entity the standing comes from. + pub from_id: i64, + /// Kind of that entity. + pub from_type: StandingSource, + /// Standing, from -10.0 to 10.0. + pub standing: f64, +} + +#[cfg(test)] +mod character_basic_tests { + use super::{CharacterRoles, CorporationRole, Medal, MedalStatus, Standing, StandingSource}; + + #[test] + fn test_parse_roles_with_unknown_role() { + let json = + r#"{"roles": ["Director", "Account_Take_1", "Brand_New_Role"], "roles_at_hq": []}"#; + let roles: CharacterRoles = serde_json::from_str(json).unwrap(); + let list = roles.roles.unwrap(); + assert_eq!(list[0], CorporationRole::Director); + assert_eq!(list[1], CorporationRole::AccountTake1); + assert_eq!(list[2], CorporationRole::Unrecognized); + assert!(roles.roles_at_base.is_none()); + } + + #[test] + fn test_parse_standing() { + let standing: Standing = serde_json::from_str( + r#"{"from_id": 500001, "from_type": "npc_corp", "standing": 1.5}"#, + ) + .unwrap(); + assert_eq!(standing.from_type, StandingSource::NpcCorp); + } + + #[test] + fn test_parse_medal() { + let json = r#"{"corporation_id": 1, "date": "2026-01-01T00:00:00Z", "description": "d", + "graphics": [{"graphic": "g", "layer": 0, "part": 1}], "issuer_id": 2112625428, + "medal_id": 7, "reason": "r", "status": "private", "title": "t"}"#; + let medal: Medal = serde_json::from_str(json).unwrap(); + assert_eq!(medal.status, MedalStatus::Private); + assert!(medal.graphics[0].color.is_none()); + } +} diff --git a/src/groups/clones.rs b/src/groups/clones.rs index 3585e59..bcb769a 100644 --- a/src/groups/clones.rs +++ b/src/groups/clones.rs @@ -15,8 +15,8 @@ pub struct CloneHome { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct JumpClone { - pub implants: Vec, - pub jump_clone_id: i32, + pub implants: Vec, + pub jump_clone_id: i64, pub location_id: i64, pub location_type: String, pub name: Option, @@ -38,7 +38,7 @@ impl ClonesGroup<'_> { "GetCharactersCharacterIdClones", RequestType::Authenticated, Clones, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); api_get!( @@ -46,7 +46,7 @@ impl ClonesGroup<'_> { get_clone_implants, "GetCharactersCharacterIdImplants", RequestType::Authenticated, - Vec, - (character_id: i32) => "{character_id}" + Vec, + (character_id: i64) => "{character_id}" ); } diff --git a/src/groups/contacts.rs b/src/groups/contacts.rs index 53f9c0f..e9d3c8f 100644 --- a/src/groups/contacts.rs +++ b/src/groups/contacts.rs @@ -6,3 +6,170 @@ use crate::prelude::*; pub struct ContactsGroup<'a> { pub(crate) esi: &'a Esi, } + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +/// The kind of entity a contact is. +pub enum ContactType { + /// A character. + #[serde(rename = "character")] + Character, + /// A corporation. + #[serde(rename = "corporation")] + Corporation, + /// An alliance. + #[serde(rename = "alliance")] + Alliance, + /// A faction. + #[serde(rename = "faction")] + Faction, + /// A value not known to this version of the crate. + #[serde(other)] + Unrecognized, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// A contact of a character, corporation or alliance. +pub struct Contact { + /// ID of the contact. + pub contact_id: i64, + /// Kind of the contact. + pub contact_type: ContactType, + /// Whether the contact is blocked (character contacts only). + #[serde(default)] + pub is_blocked: Option, + /// Whether the contact is watched (character and corporation contacts only). + #[serde(default)] + pub is_watched: Option, + /// IDs of the labels attached to the contact. + #[serde(default)] + pub label_ids: Vec, + /// Standing of the contact, from -10.0 to 10.0. + pub standing: f64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// A contact label. +pub struct ContactLabel { + /// ID of the label. + pub label_id: i64, + /// Name of the label. + pub label_name: String, +} + +impl ContactsGroup<'_> { + api_get!( + /// Return the contacts of an alliance. + get_alliance_contacts, + "GetAlliancesAllianceIdContacts", + RequestType::Authenticated, + Vec, + (alliance_id: i64) => "{alliance_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Return the contact labels of an alliance. + get_alliance_contact_labels, + "GetAlliancesAllianceIdContactsLabels", + RequestType::Authenticated, + Vec, + (alliance_id: i64) => "{alliance_id}" + ); + + api_delete!( + /// Delete contacts of a character. + delete_character_contacts, + "DeleteCharactersCharacterIdContacts", + RequestType::Authenticated, + (), + (character_id: i64) => "{character_id}"; + Many(contact_ids: &[i64]) => "contact_ids" + ); + + api_get!( + /// Return the contacts of a character. + get_character_contacts, + "GetCharactersCharacterIdContacts", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}"; + Optional(page: i32) => "page" + ); + + api_post!( + /// Add contacts to a character. Returns the IDs of the created contacts. + add_character_contacts, + "PostCharactersCharacterIdContacts", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}"; + Required(standing: f64) => "standing", + OptionalMany(label_ids: &[i64]) => "label_ids", + Optional(watched: bool) => "watched"; + contact_ids: &[i64] + ); + + api_put!( + /// Edit contacts of a character. + update_character_contacts, + "PutCharactersCharacterIdContacts", + RequestType::Authenticated, + (), + (character_id: i64) => "{character_id}"; + Required(standing: f64) => "standing", + OptionalMany(label_ids: &[i64]) => "label_ids", + Optional(watched: bool) => "watched"; + contact_ids: &[i64] + ); + + api_get!( + /// Return the contact labels of a character. + get_character_contact_labels, + "GetCharactersCharacterIdContactsLabels", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// Return the contacts of a corporation. + get_corporation_contacts, + "GetCorporationsCorporationIdContacts", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Return the contact labels of a corporation. + get_corporation_contact_labels, + "GetCorporationsCorporationIdContactsLabels", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}" + ); +} + +#[cfg(test)] +mod contacts_tests { + use super::*; + + #[test] + fn test_parse_contacts() { + let json = r#"[{"contact_id": 2112625428, "contact_type": "character", "standing": 10.0, + "is_blocked": false, "label_ids": [1]}, + {"contact_id": 1, "contact_type": "other", "standing": -5.5}]"#; + let contacts: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(contacts[0].contact_type, ContactType::Character); + assert_eq!(contacts[1].contact_type, ContactType::Unrecognized); + assert!(contacts[1].label_ids.is_empty()); + } + + #[test] + fn test_parse_labels() { + let l: Vec = + serde_json::from_str(r#"[{"label_id": 5000000000, "label_name": "Friends"}]"#).unwrap(); + assert_eq!(l[0].label_id, 5_000_000_000); + } +} diff --git a/src/groups/contracts.rs b/src/groups/contracts.rs index eed4171..c3a0fad 100644 --- a/src/groups/contracts.rs +++ b/src/groups/contracts.rs @@ -1,8 +1,350 @@ -#![allow(unused)] - use crate::prelude::*; /// Endpoints for Contracts pub struct ContractsGroup<'a> { pub(crate) esi: &'a Esi, } + +/// The type of a contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ContractType { + /// The type is not known. + Unknown, + /// An item exchange. + ItemExchange, + /// An auction. + Auction, + /// A courier contract. + Courier, + /// A loan. + Loan, + /// A type this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A public contract in a region. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct PublicContract { + pub buyout: Option, + pub collateral: Option, + pub contract_id: i64, + pub date_expired: String, + pub date_issued: String, + pub days_to_complete: Option, + pub end_location_id: Option, + pub for_corporation: Option, + pub issuer_corporation_id: i64, + /// Character ID of the issuer. + pub issuer_id: i64, + pub price: Option, + pub reward: Option, + pub start_location_id: Option, + pub title: Option, + #[serde(rename = "type")] + pub contract_type: ContractType, + pub volume: Option, +} + +/// A bid on a public auction contract. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct PublicContractBid { + pub amount: f64, + pub bid_id: i64, + pub date_bid: String, +} + +/// An item of a public contract. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct PublicContractItem { + pub is_blueprint_copy: Option, + pub is_included: bool, + pub item_id: Option, + pub material_efficiency: Option, + pub quantity: i64, + pub record_id: i64, + pub runs: Option, + pub time_efficiency: Option, + pub type_id: i64, +} + +/// Who can accept a contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ContractAvailability { + /// Anyone. + Public, + /// A specific character or corporation. + Personal, + /// Members of a corporation. + Corporation, + /// Members of an alliance. + Alliance, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// The status of a contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ContractStatus { + /// Waiting for an acceptor. + Outstanding, + /// Accepted and in progress. + InProgress, + /// Finished by the issuer. + FinishedIssuer, + /// Finished by the contractor. + FinishedContractor, + /// Finished. + Finished, + /// Cancelled. + Cancelled, + /// Rejected. + Rejected, + /// Failed. + Failed, + /// Deleted. + Deleted, + /// Reversed. + Reversed, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A contract of a character or a corporation. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct Contract { + /// ID of the character or corporation that accepted the contract + /// (`0` if nobody did). + pub acceptor_id: i64, + /// ID of the character, corporation or alliance the contract is assigned to. + pub assignee_id: i64, + /// Who can accept the contract. + pub availability: ContractAvailability, + /// Buyout price (auctions). + pub buyout: Option, + /// Collateral (couriers). + pub collateral: Option, + /// ID of the contract. + pub contract_id: i64, + /// Date the contract was accepted. + pub date_accepted: Option, + /// Date the contract was completed. + pub date_completed: Option, + /// Date the contract expires. + pub date_expired: String, + /// Date the contract was issued. + pub date_issued: String, + /// Days to complete (couriers). + pub days_to_complete: Option, + /// Destination location (couriers). + pub end_location_id: Option, + /// Whether the contract was issued on behalf of a corporation. + pub for_corporation: bool, + /// Corporation ID of the issuer. + pub issuer_corporation_id: i64, + /// Character ID of the issuer. + pub issuer_id: i64, + /// Price (item exchanges and auctions). + pub price: Option, + /// Reward (couriers). + pub reward: Option, + /// Start location. + pub start_location_id: Option, + /// Status of the contract. + pub status: ContractStatus, + /// Title of the contract. + pub title: Option, + /// Type of the contract. + #[serde(rename = "type")] + pub contract_type: ContractType, + /// Volume of the items, in m3. + pub volume: Option, +} + +/// A bid on an auction contract. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ContractBid { + /// Amount of the bid. + pub amount: f64, + /// ID of the bid. + pub bid_id: i64, + /// Character ID of the bidder. + pub bidder_id: i64, + /// Date of the bid. + pub date_bid: String, +} + +/// An item of a contract. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ContractItem { + /// Whether the item is offered (`true`) or requested (`false`). + pub is_included: bool, + /// Whether the item is a singleton (assembled). + pub is_singleton: bool, + /// Quantity of the item. + pub quantity: i64, + /// Raw quantity, used for blueprints and damaged items. + pub raw_quantity: Option, + /// ID of the record. + pub record_id: i64, + /// Type ID of the item. + pub type_id: i64, +} + +impl ContractsGroup<'_> { + api_get!( + /// List the bids on a public auction contract. + get_public_contract_bids, + "GetContractsPublicBidsContractId", + RequestType::Public, + Vec, + (contract_id: i64) => "{contract_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// List the items of a public contract. + get_public_contract_items, + "GetContractsPublicItemsContractId", + RequestType::Public, + Vec, + (contract_id: i64) => "{contract_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// List the public contracts in a region. + get_public_contracts, + "GetContractsPublicRegionId", + RequestType::Public, + Vec, + (region_id: i64) => "{region_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// List the contracts of a character. + get_character_contracts, + "GetCharactersCharacterIdContracts", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// List the bids on an auction contract of a character. + get_character_contract_bids, + "GetCharactersCharacterIdContractsContractIdBids", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}", + (contract_id: i64) => "{contract_id}" + ); + + api_get!( + /// List the items of a contract of a character. + get_character_contract_items, + "GetCharactersCharacterIdContractsContractIdItems", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}", + (contract_id: i64) => "{contract_id}" + ); + + api_get!( + /// List the contracts of a corporation. + get_corporation_contracts, + "GetCorporationsCorporationIdContracts", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// List the bids on an auction contract of a corporation. + get_corporation_contract_bids, + "GetCorporationsCorporationIdContractsContractIdBids", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}", + (contract_id: i64) => "{contract_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// List the items of a contract of a corporation. + get_corporation_contract_items, + "GetCorporationsCorporationIdContractsContractIdItems", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}", + (contract_id: i64) => "{contract_id}" + ); +} + +#[cfg(test)] +mod tests { + use super::{ContractType, PublicContract}; + + #[test] + fn test_parse_contract_and_unknown_type() { + let base = |kind: &str| { + format!( + r#"{{"contract_id": 1, "date_expired": "2026-01-02T00:00:00Z", + "date_issued": "2026-01-01T00:00:00Z", "issuer_corporation_id": 98000001, + "issuer_id": 2112000000, "type": "{kind}"}}"# + ) + }; + let known: PublicContract = serde_json::from_str(&base("item_exchange")).unwrap(); + assert_eq!(known.contract_type, ContractType::ItemExchange); + let unknown: PublicContract = serde_json::from_str(&base("brand_new")).unwrap(); + assert_eq!(unknown.contract_type, ContractType::Unrecognized); + } +} + +#[cfg(test)] +mod private_contract_tests { + use super::*; + + #[test] + fn test_parse_private_contract() { + let json = r#"[{"acceptor_id": 0, "assignee_id": 2112625428, "availability": "personal", + "contract_id": 300000000, "date_expired": "e", "date_issued": "i", + "for_corporation": false, "issuer_corporation_id": 98000001, "issuer_id": 2112000000, + "status": "finished_issuer", "type": "courier", "reward": 1000.0}, + {"acceptor_id": 0, "assignee_id": 0, "availability": "x", "contract_id": 1, + "date_expired": "e", "date_issued": "i", "for_corporation": true, + "issuer_corporation_id": 1, "issuer_id": 1, "status": "later", "type": "loan"}]"#; + let c: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(c[0].status, ContractStatus::FinishedIssuer); + assert_eq!(c[0].availability, ContractAvailability::Personal); + assert_eq!(c[1].status, ContractStatus::Unrecognized); + assert_eq!(c[1].availability, ContractAvailability::Unrecognized); + } + + #[test] + fn test_parse_bids_and_items() { + let b: Vec = serde_json::from_str( + r#"[{"amount": 5.5, "bid_id": 1, "bidder_id": 2112000000, "date_bid": "d"}]"#, + ) + .unwrap(); + assert_eq!(b[0].bidder_id, 2_112_000_000); + let i: Vec = serde_json::from_str( + r#"[{"is_included": true, "is_singleton": false, "quantity": 5000000000, + "record_id": 9, "type_id": 34}]"#, + ) + .unwrap(); + assert_eq!(i[0].quantity, 5_000_000_000); + assert_eq!(i[0].raw_quantity, None); + } +} diff --git a/src/groups/corporation.rs b/src/groups/corporation.rs index 712553c..b3c4ea2 100644 --- a/src/groups/corporation.rs +++ b/src/groups/corporation.rs @@ -1,21 +1,22 @@ +use crate::groups::{Blueprint, CorporationRole, MedalStatus, Standing}; use crate::prelude::*; #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct CorporationPublicInfo { - pub alliance_id: Option, - pub ceo_id: Option, - pub creator_id: Option, + pub alliance_id: Option, + pub ceo_id: Option, + pub creator_id: Option, pub date_founded: Option, pub description: Option, - pub enlisted_faction_id: Option, + pub enlisted_faction_id: Option, /// `legal` or `illegal`. pub friendly_fire: Option, - pub home_station_id: Option, - pub member_count: i32, + pub home_station_id: Option, + pub member_count: i64, pub name: String, pub palette: Option, - pub shares: Option, + pub shares: Option, /// `active` or `closed`. pub state: Option, pub tax_rates: Option, @@ -50,12 +51,510 @@ pub struct CorporationPalette { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct CorporationHistoryItem { - pub alliance_id: Option, + pub alliance_id: Option, pub is_deleted: Option, - pub record_id: i32, + pub record_id: i64, pub start_date: String, } +/// Icon URLs of a corporation. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct CorporationIcons { + /// 128x128 icon. + pub px128x128: Option, + /// 256x256 icon. + pub px256x256: Option, + /// 64x64 icon. + pub px64x64: Option, +} + +/// The action recorded in a container log entry. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ContainerAction { + /// Item added. + Add, + /// Container assembled. + Assemble, + /// Container configured. + Configure, + /// Password entered. + EnterPassword, + /// Container locked. + Lock, + /// Item moved. + Move, + /// Container repackaged. + Repackage, + /// Container renamed. + SetName, + /// Password set. + SetPassword, + /// Container unlocked. + Unlock, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// The kind of password involved in a container log entry. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ContainerPasswordType { + /// Configuration password. + Config, + /// General password. + General, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// An entry of the corporation container log. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct ContainerLogEntry { + /// What happened. + pub action: ContainerAction, + /// Character that did it. + pub character_id: i64, + /// ID of the container. + pub container_id: i64, + /// Type ID of the container. + pub container_type_id: i64, + /// Where the container is (see the ESI location flags). + pub location_flag: String, + /// ID of the location. + pub location_id: i64, + /// When it happened. + pub logged_at: String, + /// Configuration bitmask after the change. + pub new_config_bitmask: Option, + /// Configuration bitmask before the change. + pub old_config_bitmask: Option, + /// Kind of password involved. + pub password_type: ContainerPasswordType, + /// Quantity moved. + pub quantity: Option, + /// Type ID of the item moved. + pub type_id: Option, +} + +/// A hangar or wallet division. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct Division { + /// Number of the division. + pub division: Option, + /// Name of the division. + pub name: Option, +} + +/// The hangar and wallet divisions of a corporation. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CorporationDivisions { + /// Hangar divisions. + #[serde(default)] + pub hangar: Vec, + /// Wallet divisions. + #[serde(default)] + pub wallet: Vec, +} + +/// An industry facility of a corporation. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CorporationFacility { + /// ID of the facility. + pub facility_id: i64, + /// Solar system of the facility. + pub system_id: i64, + /// Type ID of the facility. + pub type_id: i64, +} + +/// A medal created by a corporation. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CorporationMedal { + /// When the medal was created. + pub created_at: String, + /// Character that created the medal. + pub creator_id: i64, + /// Description of the medal. + pub description: String, + /// ID of the medal. + pub medal_id: i64, + /// Title of the medal. + pub title: String, +} + +/// A medal issued to a character. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct IssuedMedal { + /// Character the medal was issued to. + pub character_id: i64, + /// When the medal was issued. + pub issued_at: String, + /// Character that issued the medal. + pub issuer_id: i64, + /// ID of the medal. + pub medal_id: i64, + /// Reason for the medal. + pub reason: String, + /// Visibility of the medal. + pub status: MedalStatus, +} + +/// The titles of a corporation member. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct MemberTitles { + /// Character ID of the member. + pub character_id: i64, + /// Title IDs held by the member. + pub titles: Vec, +} + +/// Tracking information about a corporation member. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct MemberTracking { + /// Base (home station) of the member. + pub base_id: Option, + /// Character ID of the member. + pub character_id: i64, + /// Current location of the member. + pub location_id: Option, + /// Last logoff. + pub logoff_date: Option, + /// Last logon. + pub logon_date: Option, + /// Type ID of the ship the member flies. + pub ship_type_id: Option, + /// When the member joined. + pub start_date: Option, +} + +/// The roles of a corporation member. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct MemberRoles { + /// Character ID of the member. + pub character_id: i64, + /// Roles the member can grant. + #[serde(default)] + pub grantable_roles: Vec, + /// Roles the member can grant at the base. + #[serde(default)] + pub grantable_roles_at_base: Vec, + /// Roles the member can grant at the headquarters. + #[serde(default)] + pub grantable_roles_at_hq: Vec, + /// Roles the member can grant at other locations. + #[serde(default)] + pub grantable_roles_at_other: Vec, + /// Roles of the member. + #[serde(default)] + pub roles: Vec, + /// Roles of the member at the base. + #[serde(default)] + pub roles_at_base: Vec, + /// Roles of the member at the headquarters. + #[serde(default)] + pub roles_at_hq: Vec, + /// Roles of the member at other locations. + #[serde(default)] + pub roles_at_other: Vec, +} + +/// Which set of roles a role history entry changed. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RoleType { + /// Grantable roles. + GrantableRoles, + /// Grantable roles at the base. + GrantableRolesAtBase, + /// Grantable roles at the headquarters. + GrantableRolesAtHq, + /// Grantable roles at other locations. + GrantableRolesAtOther, + /// Roles. + Roles, + /// Roles at the base. + RolesAtBase, + /// Roles at the headquarters. + RolesAtHq, + /// Roles at other locations. + RolesAtOther, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// An entry of the corporation roles history. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct RoleHistoryEntry { + /// When the change happened. + pub changed_at: String, + /// Character whose roles changed. + pub character_id: i64, + /// Character that made the change. + pub issuer_id: i64, + /// Roles after the change. + pub new_roles: Vec, + /// Roles before the change. + pub old_roles: Vec, + /// Which set of roles changed. + pub role_type: RoleType, +} + +/// The kind of entity that holds shares. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ShareholderType { + /// A character. + Character, + /// A corporation. + Corporation, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A shareholder of a corporation. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct Shareholder { + /// Number of shares held. + pub share_count: i64, + /// ID of the shareholder. + pub shareholder_id: i64, + /// Kind of the shareholder. + pub shareholder_type: ShareholderType, +} + +/// The state of a starbase. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StarbaseState { + /// Offline. + Offline, + /// Online. + Online, + /// Onlining. + Onlining, + /// Reinforced. + Reinforced, + /// Unanchoring. + Unanchoring, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A starbase (POS) of a corporation. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct Starbase { + /// Moon the starbase is anchored at. + pub moon_id: Option, + /// When the starbase went online. + pub onlined_since: Option, + /// Until when the starbase is reinforced. + pub reinforced_until: Option, + /// ID of the starbase. + pub starbase_id: i64, + /// State of the starbase. + pub state: Option, + /// Solar system of the starbase. + pub system_id: i64, + /// Type ID of the starbase. + pub type_id: i64, + /// When the starbase unanchors. + pub unanchor_at: Option, +} + +/// Who is allowed to perform an action on a starbase. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StarbasePermission { + /// Alliance members. + AllianceMember, + /// Members with the Config Starbase Equipment role. + ConfigStarbaseEquipmentRole, + /// Corporation members. + CorporationMember, + /// Members with the Starbase Fuel Technician role. + StarbaseFuelTechnicianRole, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// Fuel in a starbase. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct StarbaseFuel { + /// Quantity of fuel. + pub quantity: i64, + /// Type ID of the fuel. + pub type_id: i64, +} + +/// The settings of a starbase. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct StarbaseDetails { + /// Whether alliance members may use the starbase. + pub allow_alliance_members: bool, + /// Whether corporation members may use the starbase. + pub allow_corporation_members: bool, + /// Who can anchor. + pub anchor: StarbasePermission, + /// Whether the starbase attacks entities at war with it. + pub attack_if_at_war: bool, + /// Whether the starbase attacks entities with dropping security status. + pub attack_if_other_security_status_dropping: bool, + /// Security status threshold for attacks. + pub attack_security_status_threshold: Option, + /// Standing threshold for attacks. + pub attack_standing_threshold: Option, + /// Who can take from the fuel bay. + pub fuel_bay_take: StarbasePermission, + /// Who can view the fuel bay. + pub fuel_bay_view: StarbasePermission, + /// Fuel in the starbase. + #[serde(default)] + pub fuels: Vec, + /// Who can take it offline. + pub offline: StarbasePermission, + /// Who can bring it online. + pub online: StarbasePermission, + /// Who can unanchor. + pub unanchor: StarbasePermission, + /// Whether alliance standings are used. + pub use_alliance_standings: bool, +} + +/// The state of a structure service. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ServiceState { + /// Online. + Online, + /// Offline. + Offline, + /// Cleanup. + Cleanup, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A service installed in a structure. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct StructureService { + /// Name of the service. + pub name: String, + /// State of the service. + pub state: ServiceState, +} + +/// The state of a structure. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StructureState { + /// Anchor vulnerable. + AnchorVulnerable, + /// Anchoring. + Anchoring, + /// Armor reinforce. + ArmorReinforce, + /// Armor vulnerable. + ArmorVulnerable, + /// Deploy vulnerable. + DeployVulnerable, + /// Fitting invulnerable. + FittingInvulnerable, + /// Hull reinforce. + HullReinforce, + /// Hull vulnerable. + HullVulnerable, + /// Online (deprecated). + OnlineDeprecated, + /// Onlining vulnerable. + OnliningVulnerable, + /// Shield vulnerable. + ShieldVulnerable, + /// Unanchored. + Unanchored, + /// Unknown. + Unknown, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A structure owned by a corporation. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CorporationStructure { + /// Corporation that owns the structure. + pub corporation_id: i64, + /// When the fuel runs out. + pub fuel_expires: Option, + /// Name of the structure. + pub name: Option, + /// When the next reinforcement hour applies. + pub next_reinforce_apply: Option, + /// Next reinforcement hour. + pub next_reinforce_hour: Option, + /// ID of the structure's vulnerability profile. + pub profile_id: i64, + /// Current reinforcement hour. + pub reinforce_hour: Option, + /// Services of the structure. + #[serde(default)] + pub services: Vec, + /// State of the structure. + pub state: StructureState, + /// When the current state timer ends. + pub state_timer_end: Option, + /// When the current state timer started. + pub state_timer_start: Option, + /// ID of the structure. + pub structure_id: i64, + /// Solar system of the structure. + pub system_id: i64, + /// Type ID of the structure. + pub type_id: i64, + /// When the structure unanchors. + pub unanchors_at: Option, +} + +/// A corporation title. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct CorporationTitle { + /// Roles the title can grant. + #[serde(default)] + pub grantable_roles: Vec, + /// Roles the title can grant at the base. + #[serde(default)] + pub grantable_roles_at_base: Vec, + /// Roles the title can grant at the headquarters. + #[serde(default)] + pub grantable_roles_at_hq: Vec, + /// Roles the title can grant at other locations. + #[serde(default)] + pub grantable_roles_at_other: Vec, + /// Name of the title. + pub name: Option, + /// Roles of the title. + #[serde(default)] + pub roles: Vec, + /// Roles of the title at the base. + #[serde(default)] + pub roles_at_base: Vec, + /// Roles of the title at the headquarters. + #[serde(default)] + pub roles_at_hq: Vec, + /// Roles of the title at other locations. + #[serde(default)] + pub roles_at_other: Vec, + /// ID of the title. + pub title_id: Option, +} + /// Endpoints for Corporation pub struct CorporationGroup<'a> { pub(crate) esi: &'a Esi, @@ -68,7 +567,7 @@ impl CorporationGroup<'_> { "GetCorporationsCorporationId", RequestType::Public, CorporationPublicInfo, - (corporation_id: i32) => "{corporation_id}" + (corporation_id: i64) => "{corporation_id}" ); api_get!( @@ -77,7 +576,7 @@ impl CorporationGroup<'_> { "GetCorporationsCorporationIdAlliancehistory", RequestType::Public, Vec, - (corporation_id: i32) => "{corporation_id}" + (corporation_id: i64) => "{corporation_id}" ); api_get!( @@ -87,8 +586,8 @@ impl CorporationGroup<'_> { get_members, "GetCorporationsCorporationIdMembers", RequestType::Authenticated, - Vec, - (corporation_id: i32) => "{corporation_id}" + Vec, + (corporation_id: i64) => "{corporation_id}" ); api_get!( @@ -96,8 +595,282 @@ impl CorporationGroup<'_> { get_npc_corps, "GetCorporationsNpccorps", RequestType::Public, - Vec, + Vec, + ); + + api_get!( + /// Get the blueprints of a corporation. + /// + /// Requires the Director role. + get_blueprints, + "GetCorporationsCorporationIdBlueprints", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Get the container logs of a corporation. + /// + /// Requires the Director role. + get_container_logs, + "GetCorporationsCorporationIdContainersLogs", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Get the hangar and wallet divisions of a corporation. + /// + /// Requires the Director role. + get_divisions, + "GetCorporationsCorporationIdDivisions", + RequestType::Authenticated, + CorporationDivisions, + (corporation_id: i64) => "{corporation_id}" + ); + + api_get!( + /// Get the industry facilities of a corporation. + /// + /// Requires the Factory Manager role. + get_facilities, + "GetCorporationsCorporationIdFacilities", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}" + ); + + api_get!( + /// Get the icons of a corporation. + get_icons, + "GetCorporationsCorporationIdIcons", + RequestType::Public, + CorporationIcons, + (corporation_id: i64) => "{corporation_id}" + ); + + api_get!( + /// Get the medals created by a corporation. + get_medals, + "GetCorporationsCorporationIdMedals", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Get the medals issued by a corporation. + /// + /// Requires the Director role. + get_issued_medals, + "GetCorporationsCorporationIdMedalsIssued", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Get the member limit of a corporation. + /// + /// Requires the Director role. + get_members_limit, + "GetCorporationsCorporationIdMembersLimit", + RequestType::Authenticated, + i64, + (corporation_id: i64) => "{corporation_id}" + ); + + api_get!( + /// Get the titles of the members of a corporation. + /// + /// Requires the Director role. + get_member_titles, + "GetCorporationsCorporationIdMembersTitles", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}" + ); + + api_get!( + /// Track the members of a corporation. + /// + /// Requires the Director role. + get_member_tracking, + "GetCorporationsCorporationIdMembertracking", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}" + ); + + api_get!( + /// Get the roles of the members of a corporation. + get_roles, + "GetCorporationsCorporationIdRoles", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}" + ); + + api_get!( + /// Get the role changes of a corporation in the last 30 days. + /// + /// Requires the Director role. + get_roles_history, + "GetCorporationsCorporationIdRolesHistory", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Get the shareholders of a corporation. + /// + /// Requires the Director role. + get_shareholders, + "GetCorporationsCorporationIdShareholders", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Get the standings of a corporation. + get_standings, + "GetCorporationsCorporationIdStandings", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Get the starbases of a corporation. + /// + /// Requires the Director role. + get_starbases, + "GetCorporationsCorporationIdStarbases", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Get the settings of a starbase. `system_id` is the solar system + /// the starbase is in. + /// + /// Requires the Director role. + get_starbase, + "GetCorporationsCorporationIdStarbasesStarbaseId", + RequestType::Authenticated, + StarbaseDetails, + (corporation_id: i64) => "{corporation_id}", + (starbase_id: i64) => "{starbase_id}"; + Required(system_id: i64) => "system_id" + ); + + api_get!( + /// Get the structures of a corporation. + /// + /// Requires the Station Manager role. + get_structures, + "GetCorporationsCorporationIdStructures", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" ); - // more endpoints ... + api_get!( + /// Get the titles of a corporation. + /// + /// Requires the Director role. + get_titles, + "GetCorporationsCorporationIdTitles", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}" + ); +} + +#[cfg(test)] +mod corporation_management_tests { + use super::*; + + #[test] + fn test_parse_roles_and_history() { + let r: Vec = serde_json::from_str( + r#"[{"character_id": 2124758468, "roles": ["Director", "Brand_New_Role"], + "roles_at_hq": ["Trader"]}]"#, + ) + .unwrap(); + assert_eq!(r[0].roles[0], CorporationRole::Director); + assert_eq!(r[0].roles[1], CorporationRole::Unrecognized); + assert!(r[0].grantable_roles.is_empty()); + let h: Vec = serde_json::from_str( + r#"[{"changed_at": "t", "character_id": 1, "issuer_id": 2, "new_roles": ["Trader"], + "old_roles": [], "role_type": "roles_at_hq"}]"#, + ) + .unwrap(); + assert_eq!(h[0].role_type, RoleType::RolesAtHq); + } + + #[test] + fn test_parse_structures_and_starbases() { + let s: Vec = serde_json::from_str( + r#"[{"corporation_id": 98000001, "profile_id": 1, "state": "shield_vulnerable", + "structure_id": 1040000000000, "system_id": 30000142, "type_id": 35832, + "services": [{"name": "Market", "state": "online"}]}, + {"corporation_id": 98000001, "profile_id": 1, "state": "something_new", + "structure_id": 1, "system_id": 1, "type_id": 1}]"#, + ) + .unwrap(); + assert_eq!(s[0].structure_id, 1_040_000_000_000); + assert_eq!(s[0].services[0].state, ServiceState::Online); + assert_eq!(s[1].state, StructureState::Unrecognized); + let d: StarbaseDetails = serde_json::from_str( + r#"{"allow_alliance_members": true, "allow_corporation_members": true, + "anchor": "alliance_member", "attack_if_at_war": false, + "attack_if_other_security_status_dropping": false, + "fuel_bay_take": "corporation_member", "fuel_bay_view": "corporation_member", + "offline": "starbase_fuel_technician_role", "online": "x", "unanchor": "alliance_member", + "use_alliance_standings": false, "fuels": [{"quantity": 4000000000, "type_id": 4247}]}"#, + ) + .unwrap(); + assert_eq!(d.offline, StarbasePermission::StarbaseFuelTechnicianRole); + assert_eq!(d.online, StarbasePermission::Unrecognized); + assert_eq!(d.fuels[0].quantity, 4_000_000_000); + } + + #[test] + fn test_parse_misc() { + let d: CorporationDivisions = + serde_json::from_str(r#"{"hangar": [{"division": 1, "name": "Main"}]}"#).unwrap(); + assert_eq!(d.hangar[0].division, Some(1)); + assert!(d.wallet.is_empty()); + let l: Vec = serde_json::from_str( + r#"[{"action": "enter_password", "character_id": 1, "container_id": 1000000000000, + "container_type_id": 17366, "location_flag": "CorpSAG1", "location_id": 2, + "logged_at": "t", "password_type": "config"}]"#, + ) + .unwrap(); + assert_eq!(l[0].action, ContainerAction::EnterPassword); + let t: Vec = + serde_json::from_str(r#"[{"title_id": 1, "name": "CEO", "roles": ["Director"]}]"#) + .unwrap(); + assert_eq!(t[0].roles, vec![CorporationRole::Director]); + let sh: Vec = serde_json::from_str( + r#"[{"share_count": 5000000000, "shareholder_id": 1, "shareholder_type": "character"}]"#, + ) + .unwrap(); + assert_eq!(sh[0].share_count, 5_000_000_000); + } } diff --git a/src/groups/corporation_projects.rs b/src/groups/corporation_projects.rs new file mode 100644 index 0000000..8073f9d --- /dev/null +++ b/src/groups/corporation_projects.rs @@ -0,0 +1,573 @@ +use crate::groups::Page; +use crate::prelude::*; +use uuid::Uuid; + +/// Endpoints for Corporation Projects +pub struct CorporationProjectsGroup<'a> { + pub(crate) esi: &'a Esi, +} + +/// Which projects to list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectStateFilter { + /// All projects. + All, + /// Only active projects. + Active, +} + +impl std::fmt::Display for ProjectStateFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ProjectStateFilter::All => "All", + ProjectStateFilter::Active => "Active", + }) + } +} + +/// The state of a corporation project. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum ProjectState { + /// Not specified. + Unspecified, + /// Open. + Active, + /// Closed. + Closed, + /// Completed. + Completed, + /// Expired. + Expired, + /// Deleted. + Deleted, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// Progress of a project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectProgress { + /// Current progress. + pub current: i64, + /// Progress needed to complete the project. + pub desired: i64, +} + +/// Reward of a project, in ISK. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ProjectReward { + /// Initial reward. + pub initial: f64, + /// Reward left. + pub remaining: f64, +} + +/// A project in the project listing. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ProjectSummary { + /// ID of the project. + pub id: Uuid, + /// When the project was last modified. + pub last_modified: String, + /// Name of the project. + pub name: String, + /// Progress of the project. + pub progress: ProjectProgress, + /// Reward of the project. + pub reward: Option, + /// State of the project. + pub state: ProjectState, +} + +/// A place a project refers to. Exactly one of the fields is set. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectLocation { + /// A solar system. + pub solar_system_id: Option, + /// A constellation. + pub constellation_id: Option, + /// A region. + pub region_id: Option, +} + +/// An entity a project refers to. Exactly one of the fields is set. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectIdentity { + /// A character. + pub character_id: Option, + /// A corporation. + pub corporation_id: Option, + /// An alliance. + pub alliance_id: Option, + /// A faction. + pub faction_id: Option, +} + +/// A type or a group of types. Exactly one of the fields is set. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TypeOrGroup { + /// A type. + pub type_id: Option, + /// A group of types. + pub group_id: Option, +} + +/// A place to dock. Exactly one of the fields is set. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct DockingLocation { + /// A structure. + pub structure_id: Option, + /// A station. + pub station_id: Option, +} + +/// An archetype of a faction warfare complex. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectArchetype { + /// ID of the archetype. + pub archetype_id: Option, +} + +/// A faction a project refers to. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectFaction { + /// ID of the faction. + pub faction_id: Option, +} + +/// A signature type a project refers to. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectSignature { + /// ID of the signature type. + pub signature_type_id: Option, +} + +/// A corporation a project refers to. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectCorporation { + /// ID of the corporation. + pub corporation_id: Option, +} + +/// Configuration of the faction warfare complex projects. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct FwComplexConfig { + /// Archetypes of the complexes. + #[serde(default)] + pub archetypes: Vec, + /// Factions involved. + #[serde(default)] + pub factions: Vec, + /// Where the complexes are. + #[serde(default)] + pub locations: Vec, +} + +/// Configuration of projects that involve ships and other pilots. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ShipActivityConfig { + /// Entities involved. + #[serde(default)] + pub identities: Vec, + /// Where it happens. + #[serde(default)] + pub locations: Vec, + /// Ships involved. + #[serde(default)] + pub ships: Vec, +} + +/// Configuration of projects that only refer to locations. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct LocationsConfig { + /// Where it happens. + #[serde(default)] + pub locations: Vec, +} + +/// Configuration of an item delivery project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct DeliverItemConfig { + /// Where the items can be delivered. + #[serde(default)] + pub docking_locations: Vec, + /// Items to deliver. + #[serde(default)] + pub items: Vec, + /// Office to deliver to. + pub office_id: Option, +} + +/// Configuration of a loyalty point project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct EarnLoyaltyPointConfig { + /// Corporations whose loyalty points count. + #[serde(default)] + pub corporations: Vec, +} + +/// Who must own the items of a manufacturing project. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum ManufactureOwner { + /// Anyone. + Any, + /// A corporation. + Corporation, + /// A character. + Character, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// Configuration of a manufacturing project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ManufactureItemConfig { + /// Where the items can be delivered. + #[serde(default)] + pub docking_locations: Vec, + /// Items to manufacture. + #[serde(default)] + pub items: Vec, + /// Who must own the items. + pub owner: ManufactureOwner, +} + +/// Configuration of a mining project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct MineMaterialConfig { + /// Where to mine. + #[serde(default)] + pub locations: Vec, + /// Materials to mine. + #[serde(default)] + pub materials: Vec, +} + +/// Configuration of a signature scanning project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ScanSignatureConfig { + /// Where to scan. + #[serde(default)] + pub locations: Vec, + /// Signature types to scan. + #[serde(default)] + pub signatures: Vec, +} + +/// The kind of conflict a ship insurance project covers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum ConflictType { + /// Any conflict. + Any, + /// Player versus player. + Pvp, + /// Player versus environment. + Pve, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// Configuration of a ship insurance project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ShipInsuranceConfig { + /// Kind of conflict covered. + pub conflict_type: ConflictType, + /// Entities involved. + #[serde(default)] + pub identities: Vec, + /// Where it applies. + #[serde(default)] + pub locations: Vec, + /// Whether implants are reimbursed. + pub reimburse_implants: bool, + /// Ships covered. + #[serde(default)] + pub ships: Vec, +} + +/// A project type this version of the crate does not know about. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct UnknownProjectConfig { + /// Raw data of the configuration. + pub data: Option, + /// Name of the project type. + #[serde(rename = "type")] + pub config_type: String, +} + +/// The configuration of a project. Exactly one of the fields is set, depending +/// on the project type; the fields that are not set are `None`. +#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)] +pub struct ProjectConfiguration { + /// Capture a faction warfare complex. + pub capture_fw_complex: Option, + /// Damage ships. + pub damage_ship: Option, + /// Defend a faction warfare complex. + pub defend_fw_complex: Option, + /// Deliver items. + pub deliver_item: Option, + /// Destroy NPCs. + pub destroy_npc: Option, + /// Destroy ships. + pub destroy_ship: Option, + /// Earn loyalty points. + pub earn_loyalty_point: Option, + /// Lose ships. + pub lost_ship: Option, + /// Manually tracked project (no data). + pub manual: Option, + /// Manufacture items. + pub manufacture_item: Option, + /// Mine materials. + pub mine_material: Option, + /// Remote boost shields. + pub remote_boost_shield: Option, + /// Remote repair armor. + pub remote_repair_armor: Option, + /// Salvage wrecks. + pub salvage_wreck: Option, + /// Scan signatures. + pub scan_signature: Option, + /// Insure ships. + pub ship_insurance: Option, + /// A project type this version of the crate does not know about. + pub unknown: Option, +} + +/// Limits and rewards for contributions to a project. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ContributionRules { + /// Maximum number of participants. + pub participation_limit: Option, + /// ISK paid per contribution. + pub reward_per_contribution: Option, + /// Maximum number of submissions per participant. + pub submission_limit: Option, + /// Multiplier applied to submissions. + pub submission_multiplier: Option, +} + +/// The character that created a project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectCreator { + /// Character ID of the creator. + pub id: i64, + /// Name of the creator. + pub name: String, +} + +/// The career a project belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum ProjectCareer { + /// Not specified. + Unspecified, + /// Explorer. + Explorer, + /// Industrialist. + Industrialist, + /// Enforcer. + Enforcer, + /// Soldier of Fortune. + #[serde(rename = "Soldier of Fortune")] + SoldierOfFortune, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// General details of a project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectDetails { + /// Career of the project. + pub career: ProjectCareer, + /// When the project was created. + pub created: String, + /// Description of the project. + pub description: String, + /// When the project expires. + pub expires: Option, + /// When the project finished. + pub finished: Option, +} + +/// A corporation project. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct Project { + /// Configuration of the project. + pub configuration: ProjectConfiguration, + /// Contribution rules. + pub contribution: Option, + /// Creator of the project. + pub creator: ProjectCreator, + /// General details. + pub details: ProjectDetails, + /// ID of the project. + pub id: Uuid, + /// When the project was last modified. + pub last_modified: String, + /// Name of the project. + pub name: String, + /// Progress of the project. + pub progress: ProjectProgress, + /// Reward of the project. + pub reward: Option, + /// State of the project. + pub state: ProjectState, +} + +/// What a character contributed to a project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectContribution { + /// Amount contributed. + pub contributed: i64, + /// When the contribution was last modified. + pub last_modified: Option, +} + +/// A contributor to a project. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProjectContributor { + /// Amount contributed. + pub contributed: i64, + /// Character ID of the contributor. + pub id: i64, + /// Name of the contributor. + pub name: String, +} + +impl CorporationProjectsGroup<'_> { + api_get!( + /// List the projects of a corporation. Use the cursor of the returned + /// page as `after` or `before` to walk through the list. + list, + "GetCorporationsProjectsListing", + RequestType::Authenticated, + Page, + (corporation_id: i64) => "{corporation_id}"; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit", + Optional(state: ProjectStateFilter) => "state" + ); + + api_get!( + /// Get the details of a project of a corporation. + get_project, + "GetCorporationsProjectsDetail", + RequestType::Authenticated, + Project, + (corporation_id: i64) => "{corporation_id}", + (project_id: Uuid) => "{project_id}" + ); + + api_get!( + /// Get what a character contributed to a project. + get_contribution, + "GetCorporationsProjectsContribution", + RequestType::Authenticated, + ProjectContribution, + (corporation_id: i64) => "{corporation_id}", + (project_id: Uuid) => "{project_id}", + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// List the contributors to a project. Use the cursor of the returned + /// page as `after` or `before` to walk through the list. + /// + /// Requires the Project Manager role. + list_contributors, + "GetCorporationsProjectsContributors", + RequestType::Authenticated, + Page, + (corporation_id: i64) => "{corporation_id}", + (project_id: Uuid) => "{project_id}"; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_project_listing() { + let json = r#"{"cursor": {"after": "abc"}, "projects": [ + {"id": "6f1d8a5e-0b7c-4d3e-9a2f-1c5b7e9d0a11", "last_modified": "t", "name": "n", + "progress": {"current": 5000000000, "desired": 9000000000}, + "reward": {"initial": 1000.5, "remaining": 10.0}, "state": "Active"}, + {"id": "6f1d8a5e-0b7c-4d3e-9a2f-1c5b7e9d0a12", "last_modified": "t", "name": "m", + "progress": {"current": 0, "desired": 1}, "state": "Archived"}]}"#; + let page: Page = serde_json::from_str(json).unwrap(); + assert_eq!(page.items[0].progress.current, 5_000_000_000); + assert_eq!(page.items[0].state, ProjectState::Active); + assert_eq!(page.items[1].state, ProjectState::Unrecognized); + assert_eq!(page.cursor.unwrap().after.as_deref(), Some("abc")); + } + + #[test] + fn test_parse_project_with_each_configuration_shape() { + let base = |config: &str| { + format!( + r#"{{"configuration": {config}, + "creator": {{"id": 2124758468, "name": "c"}}, + "details": {{"career": "Soldier of Fortune", "created": "t", "description": "d"}}, + "id": "6f1d8a5e-0b7c-4d3e-9a2f-1c5b7e9d0a11", "last_modified": "t", "name": "n", + "progress": {{"current": 1, "desired": 2}}, "state": "Closed"}}"# + ) + }; + let mining: Project = serde_json::from_str(&base( + r#"{"mine_material": {"locations": [{"region_id": 10000002}], + "materials": [{"group_id": 450}]}}"#, + )) + .unwrap(); + assert_eq!(mining.details.career, ProjectCareer::SoldierOfFortune); + let cfg = mining.configuration.mine_material.unwrap(); + assert_eq!(cfg.locations[0].region_id, Some(10000002)); + assert_eq!(cfg.materials[0].group_id, Some(450)); + + let insurance: Project = serde_json::from_str(&base( + r#"{"ship_insurance": {"conflict_type": "Pvp", "reimburse_implants": true}}"#, + )) + .unwrap(); + assert_eq!( + insurance + .configuration + .ship_insurance + .unwrap() + .conflict_type, + ConflictType::Pvp + ); + + let manual: Project = serde_json::from_str(&base(r#"{"manual": {}}"#)).unwrap(); + assert!(manual.configuration.manual.is_some()); + assert!(manual.configuration.mine_material.is_none()); + + let future: Project = serde_json::from_str(&base(r#"{"teleport_cat": {"x": 1}}"#)).unwrap(); + assert_eq!(future.configuration, ProjectConfiguration::default()); + } + + #[test] + fn test_parse_contribution_and_contributors() { + let c: ProjectContribution = serde_json::from_str(r#"{"contributed": 7}"#).unwrap(); + assert_eq!(c.last_modified, None); + let page: Page = serde_json::from_str( + r#"{"contributors": [{"contributed": 3000000000, "id": 1, "name": "x"}]}"#, + ) + .unwrap(); + assert_eq!(page.items[0].contributed, 3_000_000_000); + assert!(page.cursor.is_none()); + } + + #[test] + fn test_state_filter_display() { + assert_eq!(ProjectStateFilter::Active.to_string(), "Active"); + assert_eq!(ProjectStateFilter::All.to_string(), "All"); + } +} diff --git a/src/groups/cosmetics.rs b/src/groups/cosmetics.rs new file mode 100644 index 0000000..907bf69 --- /dev/null +++ b/src/groups/cosmetics.rs @@ -0,0 +1,299 @@ +use crate::prelude::*; + +/// Endpoints for Cosmetics +pub struct CosmeticsGroup<'a> { + pub(crate) esi: &'a Esi, +} + +/// How a pattern is blended with the layers below it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PatternBlendMode { + /// Normal blending. + Normal, + /// Subtract. + Subtract, + /// Exclusion. + Exclusion, + /// Nested. + Nested, + /// Nested, inverted. + NestedInverted, + /// A mode this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A 3-component vector. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct Vector3 { + pub x: f64, + pub y: f64, + pub z: f64, +} + +/// A 4-component vector (a quaternion for rotations). +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct Vector4 { + pub w: f64, + pub x: f64, + pub y: f64, + pub z: f64, +} + +/// Which slots a pattern is projected onto. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct PatternProjection { + pub slot1: bool, + pub slot2: bool, + pub slot3: bool, + pub slot4: bool, +} + +/// Transformation applied to a pattern. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct PatternTransform { + pub position: Vector3, + pub rotation: Vector4, + pub scaling: Vector3, +} + +/// Configuration of a pattern component. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct PatternConfiguration { + pub mirrored: bool, + pub projection: PatternProjection, + pub transform: PatternTransform, +} + +/// A nanocoating component (see the SDE for details). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct NanocoatingComponent { + pub id: i64, +} + +/// A pattern component (see the SDE for details). +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct PatternComponent { + pub configuration: PatternConfiguration, + pub id: i64, +} + +/// What a slot of a SKINR design holds. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[serde(untagged)] +#[allow(missing_docs)] +pub enum SlotConfiguration { + Nanocoating { nanocoating: NanocoatingComponent }, + Pattern { pattern: PatternComponent }, +} + +/// A slot of a SKINR design. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct SkinrSlot { + pub configuration: SlotConfiguration, + pub id: i64, +} + +/// Layout of a SKINR design. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct SkinrLayout { + pub pattern_blend_mode: PatternBlendMode, + pub slots: Vec, +} + +/// Tier of a SKINR design. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct SkinrTier { + pub level: i64, +} + +/// A SKINR design. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct Skinr { + /// Character ID of the creator. + pub creator_id: i64, + pub id: String, + pub layout: SkinrLayout, + pub line: Option, + pub name: String, + pub ship_type_id: i64, + pub tier: SkinrTier, +} + +impl CosmeticsGroup<'_> { + api_get!( + /// Get a SKINR design. + get_skinr, + "GetCosmeticsSkinr", + RequestType::Public, + Skinr, + (skinr_id: &str) => "{skinr_id}" + ); +} + +#[cfg(test)] +mod tests { + use super::{Skinr, SlotConfiguration}; + use serde_json::json; + + #[test] + fn test_parse_skinr_with_both_slot_kinds() { + let vec3 = json!({"x": 0.0, "y": 0.0, "z": 0.0}); + let value = json!({ + "creator_id": 2_112_000_000_i64, + "id": "abc", + "name": "Design", + "ship_type_id": 587, + "tier": {"level": 2}, + "layout": { + "pattern_blend_mode": "nested_inverted", + "slots": [ + {"id": 1, "configuration": {"nanocoating": {"id": 10}}}, + {"id": 2, "configuration": {"pattern": { + "id": 20, + "configuration": { + "mirrored": false, + "projection": { + "slot1": true, "slot2": false, "slot3": false, "slot4": false + }, + "transform": { + "position": vec3, + "scaling": vec3, + "rotation": {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0} + } + } + }}} + ] + } + }); + let skinr: Skinr = serde_json::from_value(value).unwrap(); + assert!(skinr.line.is_none()); + assert!(matches!( + skinr.layout.slots[0].configuration, + SlotConfiguration::Nanocoating { .. } + )); + assert!(matches!( + skinr.layout.slots[1].configuration, + SlotConfiguration::Pattern { .. } + )); + } +} + +/// A SKINR license of a character. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct CharacterSkinrLicense { + /// Whether the license is activated. + pub activated: bool, + /// ID of the SKINR design. + pub skinr_id: String, + /// Number of unactivated licenses. + pub unactivated: i64, +} + +#[derive(Debug, Deserialize)] +struct SkinrLicenses { + licenses: Vec, +} + +/// Kind of a SKINR component. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SkinrComponentType { + /// A nanocoating. + Nanocoating, + /// A pattern. + Pattern, + /// A kind this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// How many times a component can be used: either `remaining` or `unlimited` is set. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct ComponentRuns { + /// Remaining uses. + pub remaining: Option, + /// Whether the component has unlimited uses. + pub unlimited: Option, +} + +/// A SKINR component license of a character. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct SkinrComponentLicense { + pub component_id: i64, + pub runs: ComponentRuns, + #[serde(rename = "type")] + pub component_type: SkinrComponentType, +} + +#[derive(Debug, Deserialize)] +struct SkinrComponentLicenses { + licenses: Vec, +} + +impl CosmeticsGroup<'_> { + /// List the SKINR licenses of a character. + pub async fn get_character_skinr( + &self, + character_id: i64, + ) -> EsiResult> { + let path = self + .esi + .get_endpoint_for_op_id("GetCharactersCosmeticsSkinr")? + .replace("{character_id}", &character_id.to_string()); + let wrapper: SkinrLicenses = self + .esi + .query("GET", RequestType::Authenticated, &path, None, None) + .await?; + Ok(wrapper.licenses) + } + + /// List the SKINR component licenses of a character. + pub async fn get_character_skinr_components( + &self, + character_id: i64, + ) -> EsiResult> { + let path = self + .esi + .get_endpoint_for_op_id("GetCharactersCosmeticsSkinrComponents")? + .replace("{character_id}", &character_id.to_string()); + let wrapper: SkinrComponentLicenses = self + .esi + .query("GET", RequestType::Authenticated, &path, None, None) + .await?; + Ok(wrapper.licenses) + } +} + +#[cfg(test)] +mod license_tests { + use super::*; + + #[test] + fn test_parse_component_licenses() { + let w: SkinrComponentLicenses = serde_json::from_str( + r#"{"licenses":[{"component_id":1,"type":"pattern","runs":{"unlimited":true}}, + {"component_id":2,"type":"x","runs":{"remaining":3}}]}"#, + ) + .unwrap(); + assert_eq!(w.licenses[0].runs.unlimited, Some(true)); + assert_eq!(w.licenses[1].runs.remaining, Some(3)); + assert_eq!( + w.licenses[1].component_type, + SkinrComponentType::Unrecognized + ); + } +} diff --git a/src/groups/dogma.rs b/src/groups/dogma.rs index 27479c6..3e68cc1 100644 --- a/src/groups/dogma.rs +++ b/src/groups/dogma.rs @@ -1,8 +1,161 @@ -#![allow(unused)] - use crate::prelude::*; /// Endpoints for Dogma pub struct DogmaGroup<'a> { pub(crate) esi: &'a Esi, } + +/// Information about a dogma attribute. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct DogmaAttribute { + pub attribute_id: i64, + pub default_value: Option, + pub description: Option, + pub display_name: Option, + pub high_is_good: Option, + pub icon_id: Option, + pub name: Option, + pub published: Option, + pub stackable: Option, + pub unit_id: Option, +} + +/// A modifier applied by a dogma effect. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct DogmaEffectModifier { + pub domain: Option, + pub effect_id: Option, + pub func: String, + pub modified_attribute_id: Option, + pub modifying_attribute_id: Option, + pub operator: Option, +} + +/// Information about a dogma effect. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct DogmaEffect { + pub description: Option, + pub disallow_auto_repeat: Option, + pub discharge_attribute_id: Option, + pub display_name: Option, + pub duration_attribute_id: Option, + pub effect_category: Option, + pub effect_id: i64, + pub electronic_chance: Option, + pub falloff_attribute_id: Option, + pub icon_id: Option, + pub is_assistance: Option, + pub is_offensive: Option, + pub is_warp_safe: Option, + pub modifiers: Option>, + pub name: Option, + pub post_expression: Option, + pub pre_expression: Option, + pub published: Option, + pub range_attribute_id: Option, + pub range_chance: Option, + pub tracking_speed_attribute_id: Option, +} + +/// An attribute value of a dynamic (mutated) item. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct DynamicItemAttribute { + pub attribute_id: i64, + pub value: f64, +} + +/// An effect of a dynamic (mutated) item. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct DynamicItemEffect { + pub effect_id: i64, + pub is_default: bool, +} + +/// Dynamic (mutated) item information. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct DynamicItem { + /// Character ID of the creator. + pub created_by: i64, + pub dogma_attributes: Vec, + pub dogma_effects: Vec, + pub mutator_type_id: i64, + pub source_type_id: i64, +} + +impl DogmaGroup<'_> { + api_get!( + /// List the IDs of all dogma attributes. + get_attributes, + "GetDogmaAttributes", + RequestType::Public, + Vec, + ); + + api_get!( + /// Get information about a dogma attribute. + get_attribute, + "GetDogmaAttributesAttributeId", + RequestType::Public, + DogmaAttribute, + (attribute_id: i64) => "{attribute_id}" + ); + + api_get!( + /// Get information about a dynamic (mutated) item. + get_dynamic_item, + "GetDogmaDynamicItemsTypeIdItemId", + RequestType::Public, + DynamicItem, + (type_id: i64) => "{type_id}", + (item_id: i64) => "{item_id}" + ); + + api_get!( + /// List the IDs of all dogma effects. + get_effects, + "GetDogmaEffects", + RequestType::Public, + Vec, + ); + + api_get!( + /// Get information about a dogma effect. + get_effect, + "GetDogmaEffectsEffectId", + RequestType::Public, + DogmaEffect, + (effect_id: i64) => "{effect_id}" + ); +} + +#[cfg(test)] +mod tests { + use super::{DogmaEffect, DynamicItem}; + + #[test] + fn test_parse_dynamic_item() { + let json = r#"{ + "created_by": 2112000000, + "dogma_attributes": [{"attribute_id": 9, "value": 1.5}], + "dogma_effects": [{"effect_id": 11, "is_default": true}], + "mutator_type_id": 49730, + "source_type_id": 2048 + }"#; + let item: DynamicItem = serde_json::from_str(json).unwrap(); + assert_eq!(item.created_by, 2_112_000_000); + assert!(item.dogma_effects[0].is_default); + } + + #[test] + fn test_parse_effect_with_only_required_fields() { + let effect: DogmaEffect = serde_json::from_str(r#"{"effect_id": 11}"#).unwrap(); + assert_eq!(effect.effect_id, 11); + assert!(effect.modifiers.is_none()); + } +} diff --git a/src/groups/faction_warfare.rs b/src/groups/faction_warfare.rs index ee226dc..bafdd7b 100644 --- a/src/groups/faction_warfare.rs +++ b/src/groups/faction_warfare.rs @@ -8,8 +8,8 @@ pub struct FactionWarfareGroup<'a> { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct FactionLeaderboardItem { - pub amount: Option, - pub faction_id: Option, + pub amount: Option, + pub faction_id: Option, } #[derive(Debug, Deserialize)] @@ -30,8 +30,8 @@ pub struct FWLeaderboards { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct CharacterLeaderboardItem { - pub amount: i32, - pub character_id: i32, + pub amount: i64, + pub character_id: i64, } #[derive(Debug, Deserialize)] @@ -52,8 +52,8 @@ pub struct FWCharacterLeaderboards { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct CorporationLeaderboardItem { - pub amount: i32, - pub corporation_id: i32, + pub amount: i64, + pub corporation_id: i64, } #[derive(Debug, Deserialize)] @@ -74,18 +74,18 @@ pub struct FWCorporationLeaderboards { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct FWStatsItemRange { - pub total: i32, - pub last_week: i32, - pub yesterday: i32, + pub total: i64, + pub last_week: i64, + pub yesterday: i64, } #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct FWStatsItem { - pub faction_id: u32, + pub faction_id: i64, pub kills: FWStatsItemRange, - pub pilots: u32, - pub systems_controlled: u32, + pub pilots: i64, + pub systems_controlled: i64, pub victory_points: FWStatsItemRange, } @@ -93,18 +93,18 @@ pub struct FWStatsItem { #[allow(missing_docs)] pub struct FWSystem { pub contested: String, - pub occupier_faction_id: u8, - pub owner_faction_id: u32, - pub solar_system_id: u32, - pub victory_points: u32, - pub victory_points_threshold: u32, + pub occupier_faction_id: i64, + pub owner_faction_id: i64, + pub solar_system_id: i64, + pub victory_points: i64, + pub victory_points_threshold: i64, } #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct FWWar { - pub faction_id: i32, - pub against_id: i32, + pub faction_id: i64, + pub against_id: i64, } impl FactionWarfareGroup<'_> { @@ -158,3 +158,62 @@ impl FactionWarfareGroup<'_> { // more endpoints ... } + +/// Faction warfare statistics of a character. +#[derive(Debug, Deserialize)] +#[allow(missing_docs)] +pub struct CharacterFwStats { + pub current_rank: Option, + pub enlisted_on: Option, + pub faction_id: Option, + pub highest_rank: Option, + pub kills: FWStatsItemRange, + pub victory_points: FWStatsItemRange, +} + +impl FactionWarfareGroup<'_> { + api_get!( + /// Get the faction warfare statistics of a character. + get_character_stats, + "GetCharactersCharacterIdFwStats", + RequestType::Authenticated, + CharacterFwStats, + (character_id: i64) => "{character_id}" + ); +} + +#[cfg(test)] +mod character_basic_tests { + use super::CharacterFwStats; + + #[test] + fn test_parse_stats_of_unenlisted_character() { + let json = r#"{"kills": {"last_week": 0, "total": 3, "yesterday": 0}, + "victory_points": {"last_week": 0, "total": 10, "yesterday": 0}}"#; + let stats: CharacterFwStats = serde_json::from_str(json).unwrap(); + assert!(stats.faction_id.is_none()); + assert_eq!(stats.kills.total, 3); + } +} + +/// Faction warfare statistics of a corporation. +#[derive(Debug, Deserialize)] +#[allow(missing_docs)] +pub struct CorporationFwStats { + pub enlisted_on: Option, + pub faction_id: Option, + pub kills: FWStatsItemRange, + pub pilots: Option, + pub victory_points: FWStatsItemRange, +} + +impl FactionWarfareGroup<'_> { + api_get!( + /// Get the faction warfare statistics of a corporation. + get_corporation_stats, + "GetCorporationsCorporationIdFwStats", + RequestType::Authenticated, + CorporationFwStats, + (corporation_id: i64) => "{corporation_id}" + ); +} diff --git a/src/groups/fittings.rs b/src/groups/fittings.rs index 699dc3f..df0fa40 100644 --- a/src/groups/fittings.rs +++ b/src/groups/fittings.rs @@ -6,3 +6,158 @@ use crate::prelude::*; pub struct FittingsGroup<'a> { pub(crate) esi: &'a Esi, } + +#[allow(missing_docs)] +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +/// The slot or bay an item occupies in a fitting. Variant names match the +/// spec values exactly. +pub enum FittingFlag { + Cargo, + DroneBay, + FighterBay, + HiSlot0, + HiSlot1, + HiSlot2, + HiSlot3, + HiSlot4, + HiSlot5, + HiSlot6, + HiSlot7, + Invalid, + LoSlot0, + LoSlot1, + LoSlot2, + LoSlot3, + LoSlot4, + LoSlot5, + LoSlot6, + LoSlot7, + MedSlot0, + MedSlot1, + MedSlot2, + MedSlot3, + MedSlot4, + MedSlot5, + MedSlot6, + MedSlot7, + RigSlot0, + RigSlot1, + RigSlot2, + ServiceSlot0, + ServiceSlot1, + ServiceSlot2, + ServiceSlot3, + ServiceSlot4, + ServiceSlot5, + ServiceSlot6, + ServiceSlot7, + SubSystemSlot0, + SubSystemSlot1, + SubSystemSlot2, + SubSystemSlot3, + /// A value not known to this version of the crate. + #[serde(other)] + Unrecognized, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// An item in a fitting. +pub struct FittingItem { + /// Slot or bay of the item. + pub flag: FittingFlag, + /// Quantity of the item. + pub quantity: i64, + /// Type ID of the item. + pub type_id: i64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// A saved ship fitting. +pub struct Fitting { + /// Description of the fitting. + pub description: String, + /// ID of the fitting. + pub fitting_id: i64, + /// Items of the fitting. + pub items: Vec, + /// Name of the fitting. + pub name: String, + /// Type ID of the ship. + pub ship_type_id: i64, +} + +#[derive(Debug, Serialize, Clone)] +/// The body of a request to save a new fitting. +pub struct NewFitting { + /// Description of the fitting. + pub description: String, + /// Items of the fitting. + pub items: Vec, + /// Name of the fitting. + pub name: String, + /// Type ID of the ship. + pub ship_type_id: i64, +} + +impl FittingsGroup<'_> { + api_get!( + /// Return the fittings saved by a character. + get_character_fittings, + "GetCharactersCharacterIdFittings", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" + ); + + api_post!( + /// Save a new fitting. Returns the ID of the created fitting. + create_character_fitting, + "PostCharactersCharacterIdFittings", + RequestType::Authenticated, + i64, + (character_id: i64) => "{character_id}", + fitting: &NewFitting, + ); + + api_delete!( + /// Delete a saved fitting. + delete_character_fitting, + "DeleteCharactersCharacterIdFittingsFittingId", + RequestType::Authenticated, + (), + (character_id: i64) => "{character_id}", + (fitting_id: i64) => "{fitting_id}" + ); +} + +#[cfg(test)] +mod fittings_tests { + use super::*; + + #[test] + fn test_parse_fitting() { + let json = r#"[{"description": "d", "fitting_id": 3000000000, "name": "n", "ship_type_id": 587, + "items": [{"flag": "HiSlot0", "quantity": 1, "type_id": 3001}, + {"flag": "NewSlot", "quantity": 2, "type_id": 1}]}]"#; + let fits: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(fits[0].fitting_id, 3_000_000_000); + assert_eq!(fits[0].items[0].flag, FittingFlag::HiSlot0); + assert_eq!(fits[0].items[1].flag, FittingFlag::Unrecognized); + } + + #[test] + fn test_serialize_new_fitting() { + let f = NewFitting { + description: "d".into(), + items: vec![FittingItem { + flag: FittingFlag::SubSystemSlot3, + quantity: 1, + type_id: 5, + }], + name: "n".into(), + ship_type_id: 1, + }; + let v = serde_json::to_value(&f).unwrap(); + assert_eq!(v["items"][0]["flag"], "SubSystemSlot3"); + } +} diff --git a/src/groups/fleets.rs b/src/groups/fleets.rs index 7569c15..20522e1 100644 --- a/src/groups/fleets.rs +++ b/src/groups/fleets.rs @@ -6,3 +6,355 @@ use crate::prelude::*; pub struct FleetsGroup<'a> { pub(crate) esi: &'a Esi, } + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +/// The role of a character in a fleet. +pub enum FleetRole { + /// Fleet commander. + #[serde(rename = "fleet_commander")] + FleetCommander, + /// Wing commander. + #[serde(rename = "wing_commander")] + WingCommander, + /// Squad commander. + #[serde(rename = "squad_commander")] + SquadCommander, + /// Squad member. + #[serde(rename = "squad_member")] + SquadMember, + /// A value not known to this version of the crate. + #[serde(other)] + Unrecognized, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// The fleet a character is in. +pub struct CharacterFleet { + /// Character ID of the fleet boss. + pub fleet_boss_id: i64, + /// ID of the fleet. + pub fleet_id: i64, + /// Role of the character in the fleet. + pub role: FleetRole, + /// ID of the squad the character is in (`-1` if none). + pub squad_id: i64, + /// ID of the wing the character is in (`-1` if none). + pub wing_id: i64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// Fleet settings. +pub struct FleetInfo { + /// Whether free-move is enabled. + pub is_free_move: bool, + /// Whether the fleet is registered in the fleet finder. + pub is_registered: bool, + /// Whether voice is enabled. + pub is_voice_enabled: bool, + /// Message of the day. + pub motd: String, +} + +#[derive(Debug, Serialize, Clone, Default)] +/// The body of a request to update fleet settings. +pub struct FleetUpdate { + /// Whether free-move is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub is_free_move: Option, + /// Message of the day. + #[serde(skip_serializing_if = "Option::is_none")] + pub motd: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// A member of a fleet. +pub struct FleetMember { + /// Character ID of the member. + pub character_id: i64, + /// Date and time the member joined. + pub join_time: String, + /// Role of the member. + pub role: FleetRole, + /// Localized role name. + pub role_name: String, + /// Type ID of the ship the member flies. + pub ship_type_id: i64, + /// Solar system the member is in. + pub solar_system_id: i64, + /// Squad of the member (`-1` if none). + pub squad_id: i64, + /// Station the member is docked at, if any. + #[serde(default)] + pub station_id: Option, + /// Whether the member takes fleet warps. + pub takes_fleet_warp: bool, + /// Wing of the member (`-1` if none). + pub wing_id: i64, +} + +#[derive(Debug, Serialize, Clone)] +/// The body of a request to invite a character to a fleet. +pub struct FleetInvitation { + /// Character to invite. + pub character_id: i64, + /// Role to give the character. + pub role: FleetRole, + /// Squad to place the character in, when the role requires it. + #[serde(skip_serializing_if = "Option::is_none")] + pub squad_id: Option, + /// Wing to place the character in, when the role requires it. + #[serde(skip_serializing_if = "Option::is_none")] + pub wing_id: Option, +} + +#[derive(Debug, Serialize, Clone)] +/// The body of a request to move a fleet member. +pub struct FleetMovement { + /// New role of the member. + pub role: FleetRole, + /// New squad of the member, when the role requires it. + #[serde(skip_serializing_if = "Option::is_none")] + pub squad_id: Option, + /// New wing of the member, when the role requires it. + #[serde(skip_serializing_if = "Option::is_none")] + pub wing_id: Option, +} + +#[derive(Debug, Serialize, Clone)] +/// The body of a request to rename a wing or squad. +pub struct FleetNaming { + /// New name. + pub name: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// A squad of a fleet wing. +pub struct FleetSquad { + /// ID of the squad. + pub id: i64, + /// Name of the squad. + pub name: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// A wing of a fleet. +pub struct FleetWing { + /// ID of the wing. + pub id: i64, + /// Name of the wing. + pub name: String, + /// Squads of the wing. + pub squads: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] +/// The result of creating a wing. +pub struct CreatedWing { + /// ID of the new wing. + pub wing_id: i64, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] +/// The result of creating a squad. +pub struct CreatedSquad { + /// ID of the new squad. + pub squad_id: i64, +} + +impl FleetsGroup<'_> { + api_get!( + /// Return the fleet a character is in. + get_character_fleet, + "GetCharactersCharacterIdFleet", + RequestType::Authenticated, + CharacterFleet, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// Return the settings of a fleet. + get_fleet, + "GetFleetsFleetId", + RequestType::Authenticated, + FleetInfo, + (fleet_id: i64) => "{fleet_id}" + ); + + api_put!( + /// Update the settings of a fleet. + update_fleet, + "PutFleetsFleetId", + RequestType::Authenticated, + (), + (fleet_id: i64) => "{fleet_id}", + settings: &FleetUpdate, + ); + + api_get!( + /// Return the members of a fleet. + get_members, + "GetFleetsFleetIdMembers", + RequestType::Authenticated, + Vec, + (fleet_id: i64) => "{fleet_id}" + ); + + api_post!( + /// Invite a character to a fleet. + invite_member, + "PostFleetsFleetIdMembers", + RequestType::Authenticated, + (), + (fleet_id: i64) => "{fleet_id}", + invitation: &FleetInvitation, + ); + + api_delete!( + /// Kick a member from a fleet. + kick_member, + "DeleteFleetsFleetIdMembersMemberId", + RequestType::Authenticated, + (), + (fleet_id: i64) => "{fleet_id}", + (member_id: i64) => "{member_id}" + ); + + api_put!( + /// Move a fleet member to another role, squad or wing. + move_member, + "PutFleetsFleetIdMembersMemberId", + RequestType::Authenticated, + (), + (fleet_id: i64) => "{fleet_id}", + (member_id: i64) => "{member_id}", + movement: &FleetMovement, + ); + + api_delete!( + /// Delete a fleet squad (only empty squads can be deleted). + delete_squad, + "DeleteFleetsFleetIdSquadsSquadId", + RequestType::Authenticated, + (), + (fleet_id: i64) => "{fleet_id}", + (squad_id: i64) => "{squad_id}" + ); + + api_put!( + /// Rename a fleet squad. + rename_squad, + "PutFleetsFleetIdSquadsSquadId", + RequestType::Authenticated, + (), + (fleet_id: i64) => "{fleet_id}", + (squad_id: i64) => "{squad_id}", + naming: &FleetNaming, + ); + + api_get!( + /// Return the wings and squads of a fleet. + get_wings, + "GetFleetsFleetIdWings", + RequestType::Authenticated, + Vec, + (fleet_id: i64) => "{fleet_id}" + ); + + api_post!( + /// Create a new wing in a fleet. + create_wing, + "PostFleetsFleetIdWings", + RequestType::Authenticated, + CreatedWing, + (fleet_id: i64) => "{fleet_id}"; + NoBody + ); + + api_delete!( + /// Delete a fleet wing (only empty wings can be deleted). + delete_wing, + "DeleteFleetsFleetIdWingsWingId", + RequestType::Authenticated, + (), + (fleet_id: i64) => "{fleet_id}", + (wing_id: i64) => "{wing_id}" + ); + + api_put!( + /// Rename a fleet wing. + rename_wing, + "PutFleetsFleetIdWingsWingId", + RequestType::Authenticated, + (), + (fleet_id: i64) => "{fleet_id}", + (wing_id: i64) => "{wing_id}", + naming: &FleetNaming, + ); + + api_post!( + /// Create a new squad in a fleet wing. + create_squad, + "PostFleetsFleetIdWingsWingIdSquads", + RequestType::Authenticated, + CreatedSquad, + (fleet_id: i64) => "{fleet_id}", + (wing_id: i64) => "{wing_id}"; + NoBody + ); +} + +#[cfg(test)] +mod fleets_tests { + use super::*; + + #[test] + fn test_parse_fleet_and_members() { + let f: CharacterFleet = serde_json::from_str( + r#"{"fleet_boss_id": 1, "fleet_id": 1234567890123, "role": "squad_member", + "squad_id": -1, "wing_id": 2000000000000}"#, + ) + .unwrap(); + assert_eq!(f.fleet_id, 1_234_567_890_123); + assert_eq!(f.role, FleetRole::SquadMember); + let m: Vec = serde_json::from_str( + r#"[{"character_id": 1, "join_time": "t", "role": "new_role", "role_name": "r", + "ship_type_id": 587, "solar_system_id": 30000142, "squad_id": 1, "wing_id": 2, + "takes_fleet_warp": true}]"#, + ) + .unwrap(); + assert_eq!(m[0].role, FleetRole::Unrecognized); + assert_eq!(m[0].station_id, None); + } + + #[test] + fn test_serialize_bodies() { + let inv = FleetInvitation { + character_id: 1, + role: FleetRole::SquadMember, + squad_id: Some(5), + wing_id: None, + }; + assert_eq!( + serde_json::to_string(&inv).unwrap(), + r#"{"character_id":1,"role":"squad_member","squad_id":5}"# + ); + let u = FleetUpdate { + is_free_move: Some(true), + motd: None, + }; + assert_eq!( + serde_json::to_string(&u).unwrap(), + r#"{"is_free_move":true}"# + ); + } + + #[test] + fn test_parse_wings_and_created() { + let w: Vec = + serde_json::from_str(r#"[{"id": 1, "name": "w", "squads": [{"id": 2, "name": "s"}]}]"#) + .unwrap(); + assert_eq!(w[0].squads[0].id, 2); + let c: CreatedWing = serde_json::from_str(r#"{"wing_id": 2000000000000}"#).unwrap(); + assert_eq!(c.wing_id, 2_000_000_000_000); + } +} diff --git a/src/groups/freelance_jobs.rs b/src/groups/freelance_jobs.rs new file mode 100644 index 0000000..338e90f --- /dev/null +++ b/src/groups/freelance_jobs.rs @@ -0,0 +1,316 @@ +use crate::groups::Page; +use crate::prelude::*; +use uuid::Uuid; + +/// Endpoints for Freelance Jobs +pub struct FreelanceJobsGroup<'a> { + pub(crate) esi: &'a Esi, +} + +/// The state of a freelance job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum JobState { + /// The state is not specified. + Unspecified, + /// The job is open. + Active, + /// The job was closed. + Closed, + /// The job was completed. + Completed, + /// The job expired. + Expired, + /// The job was deleted. + Deleted, + /// A state this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// The career a freelance job belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum JobCareer { + /// The career is not specified. + Unspecified, + /// Explorer. + Explorer, + /// Industrialist. + Industrialist, + /// Enforcer. + Enforcer, + /// Soldier of Fortune. + #[serde(rename = "Soldier of Fortune")] + SoldierOfFortune, + /// A career this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// Progress towards completing a job. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct JobProgress { + pub current: i64, + pub desired: i64, +} + +/// The ISK reward of a job. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct JobReward { + pub initial: f64, + pub remaining: f64, +} + +/// A freelance job as listed by [`FreelanceJobsGroup::list`]. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct FreelanceJobSummary { + pub id: Uuid, + pub last_modified: String, + pub name: String, + pub progress: JobProgress, + pub reward: Option, + pub state: JobState, +} + +/// A solar system a job is broadcast in. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct BroadcastLocation { + /// Solar system ID. + pub id: i64, + pub name: String, +} + +/// Age restrictions on who can take a job. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct JobRestrictions { + pub maximum_age: Option, + pub minimum_age: Option, +} + +/// Who can see and take a job. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct AccessAndVisibility { + pub acl_protected: bool, + pub broadcast_locations: Option>, + pub restrictions: Option, +} + +/// How a job is evaluated. `parameters` depends on `method`, so it is kept as raw JSON. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct JobConfiguration { + pub method: String, + pub parameters: serde_json::Value, + pub version: i64, +} + +/// Limits on participants' contributions. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct JobContribution { + pub contribution_per_participant_limit: Option, + pub max_committed_participants: i64, + pub reward_per_contribution: Option, + pub submission_limit: Option, + pub submission_multiplier: Option, +} + +/// The character that created a job. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct JobCreatorCharacter { + pub id: i64, + pub name: String, +} + +/// The corporation that created a job. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct JobCreatorCorporation { + pub id: i64, + pub name: String, +} + +/// Who created a job. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct JobCreator { + pub character: JobCreatorCharacter, + pub corporation: JobCreatorCorporation, +} + +/// Descriptive details of a job. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct JobDetails { + pub career: JobCareer, + pub created: String, + pub creator: JobCreator, + pub description: String, + pub expires: Option, + pub finished: Option, +} + +/// Full information about a freelance job. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct FreelanceJob { + pub access_and_visibility: AccessAndVisibility, + pub configuration: JobConfiguration, + pub contribution: Option, + pub details: JobDetails, + pub id: Uuid, + pub last_modified: String, + pub name: String, + pub progress: JobProgress, + pub reward: Option, + pub state: JobState, +} + +impl FreelanceJobsGroup<'_> { + api_get!( + /// List freelance jobs. Use the cursor of the returned page as `after` or + /// `before` to walk through the list; `corporation_id` filters by creator. + list, + "GetFreelanceJobsListing", + RequestType::Public, + Page, + ; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit", + Optional(corporation_id: i64) => "corporation_id" + ); + + api_get!( + /// Get the details of a freelance job. + get_job, + "GetFreelanceJobsDetail", + RequestType::Public, + FreelanceJob, + (job_id: Uuid) => "{job_id}" + ); +} + +#[cfg(test)] +mod tests { + use super::JobState; + use crate::groups::{FreelanceJobSummary, Page}; + + #[test] + fn test_parse_page_with_unknown_state() { + let json = r#"{ + "cursor": {"after": "abc"}, + "freelance_jobs": [{ + "id": "3868eaed-8278-4cb7-9709-7d7de9c20dc7", + "last_modified": "2026-10-01T00:00:00Z", + "name": "Haul", + "progress": {"current": 1, "desired": 10}, + "state": "Archived" + }] + }"#; + let page: Page = serde_json::from_str(json).unwrap(); + assert_eq!(page.items[0].state, JobState::Unrecognized); + assert!(page.items[0].reward.is_none()); + assert_eq!(page.cursor.unwrap().after.as_deref(), Some("abc")); + } +} + +/// The state of a character's participation in a freelance job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum ParticipationState { + /// No state specified. + Unspecified, + /// Committed to the job. + Committed, + /// Kicked from the job. + Kicked, + /// Resigned from the job. + Resigned, + /// A state this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A character's participation in a freelance job. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct JobParticipation { + pub contributed: i64, + pub last_modified: String, + pub state: ParticipationState, +} + +/// A participant of a corporation's freelance job. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct JobParticipant { + pub contributed: i64, + /// Character ID of the participant. + pub id: i64, + pub name: String, + pub state: ParticipationState, +} + +#[derive(Debug, Deserialize)] +struct CharacterFreelanceJobs { + freelance_jobs: Vec, +} + +impl FreelanceJobsGroup<'_> { + /// List the freelance jobs a character takes part in. + pub async fn get_character_jobs( + &self, + character_id: i64, + ) -> EsiResult> { + let path = self + .esi + .get_endpoint_for_op_id("GetCharactersFreelanceJobsListing")? + .replace("{character_id}", &character_id.to_string()); + let wrapper: CharacterFreelanceJobs = self + .esi + .query("GET", RequestType::Authenticated, &path, None, None) + .await?; + Ok(wrapper.freelance_jobs) + } + + api_get!( + /// Get the participation of a character in a freelance job. + get_character_participation, + "GetCharactersFreelanceJobsParticipation", + RequestType::Authenticated, + JobParticipation, + (character_id: i64) => "{character_id}", + (job_id: Uuid) => "{job_id}" + ); + + api_get!( + /// List the freelance jobs of a corporation (requires the Project_Manager role). + list_corporation_jobs, + "GetCorporationsFreelanceJobsListing", + RequestType::Authenticated, + Page, + (corporation_id: i64) => "{corporation_id}"; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit" + ); + + api_get!( + /// List the participants of a corporation's freelance job (requires the Project_Manager role). + list_corporation_job_participants, + "GetCorporationsFreelanceJobsParticipants", + RequestType::Authenticated, + Page, + (corporation_id: i64) => "{corporation_id}", + (job_id: Uuid) => "{job_id}"; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit" + ); +} diff --git a/src/groups/incursions.rs b/src/groups/incursions.rs index 741d03c..9bdc293 100644 --- a/src/groups/incursions.rs +++ b/src/groups/incursions.rs @@ -8,12 +8,12 @@ pub struct IncursionsGroup<'a> { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct Incursion { - pub constellation_id: i32, - pub faction_id: i32, + pub constellation_id: i64, + pub faction_id: i64, pub has_boss: bool, - pub infested_solar_systems: Vec, + pub infested_solar_systems: Vec, pub influence: f64, - pub staging_solar_system_id: i32, + pub staging_solar_system_id: i64, pub state: String, #[serde(rename = "type")] pub incursion_type: String, diff --git a/src/groups/industry.rs b/src/groups/industry.rs index 61e35c0..4cac35c 100644 --- a/src/groups/industry.rs +++ b/src/groups/industry.rs @@ -18,34 +18,34 @@ pub struct CostIndex { #[allow(missing_docs)] pub struct IndustrialSystem { pub cost_indices: Vec, - pub solar_system_id: i32, + pub solar_system_id: i64, } #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct IndustryJob { - pub activity_id: i32, + pub activity_id: i64, pub blueprint_id: i64, pub blueprint_location_id: i64, - pub blueprint_type_id: i32, - pub completed_character_id: Option, + pub blueprint_type_id: i64, + pub completed_character_id: Option, pub completed_date: Option, pub cost: Option, - pub duration: i32, + pub duration: i64, pub end_date: String, pub facility_id: i64, - pub installer_id: i32, - pub job_id: i32, - pub licensed_runs: Option, + pub installer_id: i64, + pub job_id: i64, + pub licensed_runs: Option, pub output_location_id: i64, pub pause_date: Option, pub probability: Option, - pub product_type_id: Option, - pub runs: i32, + pub product_type_id: Option, + pub runs: i64, pub start_date: String, pub station_id: i64, pub status: String, - pub successful_runs: Option, + pub successful_runs: Option, } impl IndustryGroup<'_> { @@ -64,7 +64,206 @@ impl IndustryGroup<'_> { "GetCharactersCharacterIdIndustryJobs", RequestType::Authenticated, Vec, - (character_id: i32) => "{character_id}"; + (character_id: i64) => "{character_id}"; Optional(include_completed: bool) => "include_completed" ); } + +/// A public industry facility. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct IndustryFacility { + pub facility_id: i64, + pub owner_id: i64, + pub region_id: i64, + pub solar_system_id: i64, + pub tax: Option, + pub type_id: i64, +} + +impl IndustryGroup<'_> { + api_get!( + /// List the public industry facilities. + get_facilities, + "GetIndustryFacilities", + RequestType::Public, + Vec, + ); +} + +/// A day of mining by a character. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct MiningEntry { + pub date: String, + pub quantity: i64, + pub solar_system_id: i64, + pub type_id: i64, +} + +/// A moon mining extraction of a corporation. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct MiningExtraction { + pub chunk_arrival_time: String, + pub extraction_start_time: String, + pub moon_id: i64, + pub natural_decay_time: String, + pub structure_id: i64, +} + +/// The kind of structure that observes mining. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ObserverType { + /// A structure. + Structure, + /// A kind this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A structure that observed mining for a corporation. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct MiningObserver { + pub last_updated: String, + pub observer_id: i64, + pub observer_type: ObserverType, +} + +/// Mining recorded by an observer for one character. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct ObservedMining { + pub character_id: i64, + pub last_updated: String, + pub quantity: i64, + pub recorded_corporation_id: i64, + pub type_id: i64, +} + +/// Status of a corporation industry job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IndustryJobStatus { + /// The job is running. + Active, + /// The job was cancelled. + Cancelled, + /// The job was delivered. + Delivered, + /// The job is paused. + Paused, + /// The job is ready to be delivered. + Ready, + /// The job was reverted. + Reverted, + /// A status this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// An industry job placed by a corporation. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct CorporationIndustryJob { + pub activity_id: i64, + pub blueprint_id: i64, + pub blueprint_location_id: i64, + pub blueprint_type_id: i64, + pub completed_character_id: Option, + pub completed_date: Option, + pub cost: Option, + pub duration: i64, + pub end_date: String, + pub facility_id: i64, + pub installer_id: i64, + pub job_id: i64, + pub licensed_runs: Option, + pub location_id: i64, + pub output_location_id: i64, + pub pause_date: Option, + pub probability: Option, + pub product_type_id: Option, + pub runs: i64, + pub start_date: String, + pub status: IndustryJobStatus, + pub successful_runs: Option, +} + +impl IndustryGroup<'_> { + api_get!( + /// Paginated record of the mining done by a character in the last 30 days. + get_character_mining, + "GetCharactersCharacterIdMining", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Moon mining extractions of a corporation. + get_corporation_mining_extractions, + "GetCorporationCorporationIdMiningExtractions", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Structures that observed mining for a corporation. + get_corporation_mining_observers, + "GetCorporationCorporationIdMiningObservers", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Mining recorded by one observer structure of a corporation. + get_corporation_mining_observer, + "GetCorporationCorporationIdMiningObserversObserverId", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}", + (observer_id: i64) => "{observer_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// List industry jobs placed by a corporation. + get_corporation_industry_jobs, + "GetCorporationsCorporationIdIndustryJobs", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(include_completed: bool) => "include_completed", + Optional(page: i32) => "page" + ); +} + +#[cfg(test)] +mod mining_tests { + use super::*; + + #[test] + fn test_parse_mining_and_jobs() { + let observer: MiningObserver = serde_json::from_str( + r#"{"last_updated":"2026-10-01","observer_id":1,"observer_type":"other"}"#, + ) + .unwrap(); + assert_eq!(observer.observer_type, ObserverType::Unrecognized); + let job: CorporationIndustryJob = serde_json::from_str( + r#"{"activity_id":1,"blueprint_id":2,"blueprint_location_id":3,"blueprint_type_id":4, + "duration":5,"end_date":"x","facility_id":6,"installer_id":7,"job_id":8,"location_id":9, + "output_location_id":10,"runs":1,"start_date":"y","status":"ready"}"#, + ) + .unwrap(); + assert_eq!(job.status, IndustryJobStatus::Ready); + assert!(job.cost.is_none()); + } +} diff --git a/src/groups/insurance.rs b/src/groups/insurance.rs index 12dc450..916caec 100644 --- a/src/groups/insurance.rs +++ b/src/groups/insurance.rs @@ -1,8 +1,33 @@ -#![allow(unused)] - use crate::prelude::*; /// Endpoints for Insurance pub struct InsuranceGroup<'a> { pub(crate) esi: &'a Esi, } + +/// One insurance level for a ship type. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct InsuranceLevel { + pub cost: f64, + pub name: String, + pub payout: f64, +} + +/// Insurance prices for a ship type. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct InsurancePrice { + pub levels: Vec, + pub type_id: i64, +} + +impl InsuranceGroup<'_> { + api_get!( + /// List the insurance levels for all ship types. + get_prices, + "GetInsurancePrices", + RequestType::Public, + Vec, + ); +} diff --git a/src/groups/killmails.rs b/src/groups/killmails.rs index a632bbc..e68a1df 100644 --- a/src/groups/killmails.rs +++ b/src/groups/killmails.rs @@ -1,3 +1,4 @@ +use crate::groups::Position; use crate::prelude::*; /// Endpoints for Killmails @@ -9,31 +10,31 @@ pub struct KillmailsGroup<'a> { #[allow(missing_docs)] pub struct RecentKillMail { pub killmail_hash: String, - pub killmail_id: i32, + pub killmail_id: i64, } #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct KillmailAttacker { - pub alliance_id: Option, - pub character_id: Option, - pub corporation_id: Option, - pub damage_done: i32, - pub faction_id: Option, + pub alliance_id: Option, + pub character_id: Option, + pub corporation_id: Option, + pub damage_done: i64, + pub faction_id: Option, pub final_blow: bool, pub security_status: f64, - pub ship_type_id: Option, - pub weapon_type_id: Option, + pub ship_type_id: Option, + pub weapon_type_id: Option, } #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct KillmailItem { - pub flag: i32, - pub item_type_id: i32, + pub flag: i64, + pub item_type_id: i64, pub quantity_destroyed: Option, pub quantity_dropped: Option, - pub singleton: i32, + pub singleton: i64, /// Items inside this item (e.g. the contents of a container). pub items: Option>, } @@ -41,32 +42,24 @@ pub struct KillmailItem { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct KillmailVictim { - pub alliance_id: Option, - pub character_id: Option, - pub corporation_id: Option, - pub damage_taken: i32, - pub faction_id: Option, + pub alliance_id: Option, + pub character_id: Option, + pub corporation_id: Option, + pub damage_taken: i64, + pub faction_id: Option, pub items: Option>, - pub position: Option, - pub ship_type_id: i32, -} - -#[derive(Debug, Deserialize)] -#[allow(missing_docs)] -pub struct KillmailPosition { - pub x: f64, - pub y: f64, - pub z: f64, + pub position: Option, + pub ship_type_id: i64, } #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct Killmail { - pub killmail_id: i32, + pub killmail_id: i64, pub killmail_time: String, - pub solar_system_id: i32, - pub moon_id: Option, - pub war_id: Option, + pub solar_system_id: i64, + pub moon_id: Option, + pub war_id: Option, pub attackers: Vec, pub victim: KillmailVictim, } @@ -79,7 +72,7 @@ impl KillmailsGroup<'_> { "GetCharactersCharacterIdKillmailsRecent", RequestType::Authenticated, Vec, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); api_get!( @@ -88,9 +81,21 @@ impl KillmailsGroup<'_> { "GetKillmailsKillmailIdKillmailHash", RequestType::Public, Killmail, - (killmail_id: i32) => "{killmail_id}", + (killmail_id: i64) => "{killmail_id}", (killmail_hash: &str) => "{killmail_hash}" ); // more endpoints ... } + +impl KillmailsGroup<'_> { + api_get!( + /// Get a corporation's recent kills & losses. + get_corporation_recent, + "GetCorporationsCorporationIdKillmailsRecent", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); +} diff --git a/src/groups/location.rs b/src/groups/location.rs index f58331a..cec9caf 100644 --- a/src/groups/location.rs +++ b/src/groups/location.rs @@ -8,8 +8,8 @@ pub struct LocationGroup<'a> { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct LocationInfo { - pub solar_system_id: i32, - pub station_id: Option, + pub solar_system_id: i64, + pub station_id: Option, pub structure_id: Option, } @@ -18,7 +18,7 @@ pub struct LocationInfo { pub struct OnlineStatus { pub last_login: Option, pub last_logout: Option, - pub logins: Option, + pub logins: Option, pub online: bool, } @@ -27,7 +27,7 @@ pub struct OnlineStatus { pub struct CurrentShip { pub ship_item_id: i64, pub ship_name: String, - pub ship_type_id: i32, + pub ship_type_id: i64, } impl LocationGroup<'_> { @@ -37,7 +37,7 @@ impl LocationGroup<'_> { "GetCharactersCharacterIdLocation", RequestType::Authenticated, LocationInfo, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); api_get!( @@ -46,7 +46,7 @@ impl LocationGroup<'_> { "GetCharactersCharacterIdOnline", RequestType::Authenticated, OnlineStatus, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); api_get!( @@ -55,6 +55,6 @@ impl LocationGroup<'_> { "GetCharactersCharacterIdShip", RequestType::Authenticated, CurrentShip, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); } diff --git a/src/groups/loyalty.rs b/src/groups/loyalty.rs index edc7194..d25eee6 100644 --- a/src/groups/loyalty.rs +++ b/src/groups/loyalty.rs @@ -1,8 +1,71 @@ -#![allow(unused)] - use crate::prelude::*; /// Endpoints for Loyalty pub struct LoyaltyGroup<'a> { pub(crate) esi: &'a Esi, } + +/// An item required, besides LP and ISK, to take an offer. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct RequiredItem { + pub quantity: i64, + pub type_id: i64, +} + +/// An offer in a loyalty points store. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct LoyaltyOffer { + pub ak_cost: Option, + pub isk_cost: i64, + pub lp_cost: i64, + pub offer_id: i64, + pub quantity: i64, + pub required_items: Vec, + pub type_id: i64, +} + +/// A character's loyalty points with a corporation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct LoyaltyPoints { + pub corporation_id: i64, + pub loyalty_points: i64, +} + +impl LoyaltyGroup<'_> { + api_get!( + /// List a character's loyalty points per corporation. + get_character_points, + "GetCharactersCharacterIdLoyaltyPoints", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// List the offers of a corporation's loyalty points store. + get_store_offers, + "GetLoyaltyStoresCorporationIdOffers", + RequestType::Public, + Vec, + (corporation_id: i64) => "{corporation_id}" + ); +} + +#[cfg(test)] +mod tests { + use super::LoyaltyOffer; + + #[test] + fn test_parse_offer() { + let json = r#"[{ + "isk_cost": 5000000000, "lp_cost": 1000, "offer_id": 3751, + "quantity": 1, "required_items": [{"quantity": 2, "type_id": 34}], "type_id": 23 + }]"#; + let offers: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(offers[0].isk_cost, 5_000_000_000); + assert!(offers[0].ak_cost.is_none()); + } +} diff --git a/src/groups/mail.rs b/src/groups/mail.rs index 88a2192..3a660d4 100644 --- a/src/groups/mail.rs +++ b/src/groups/mail.rs @@ -14,7 +14,7 @@ pub struct MailLabels { #[serde(default)] pub labels: Vec, /// Total unread count across all labels. - pub total_unread_count: Option, + pub total_unread_count: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -23,15 +23,216 @@ pub struct MailLabel { /// Color of the label as RGB Hex (`#rrggbb`). pub color: String, /// ID of the label. - pub label_id: i32, + pub label_id: i64, /// Name of the label. pub name: String, /// Number of unread messages with this label. #[serde(default)] - pub unread_count: Option, + pub unread_count: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +/// The kind of entity a mail is addressed to. +pub enum RecipientType { + /// An alliance. + #[serde(rename = "alliance")] + Alliance, + /// A character. + #[serde(rename = "character")] + Character, + /// A corporation. + #[serde(rename = "corporation")] + Corporation, + /// A mailing list. + #[serde(rename = "mailing_list")] + MailingList, + /// A value not known to this version of the crate. + #[serde(other)] + Unrecognized, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// A recipient of a mail. +pub struct MailRecipient { + /// ID of the recipient. + pub recipient_id: i64, + /// Kind of the recipient. + pub recipient_type: RecipientType, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// A mail header, as returned by the mail list. +pub struct MailHeader { + /// Character ID of the sender. + pub from: Option, + /// Whether the mail has been read. + pub is_read: Option, + /// Label IDs attached to the mail. + #[serde(default)] + pub labels: Vec, + /// ID of the mail. + pub mail_id: Option, + /// Recipients of the mail. + #[serde(default)] + pub recipients: Vec, + /// Subject of the mail. + pub subject: Option, + /// Date and time the mail was sent. + pub timestamp: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// A full mail, as returned by the mail details endpoint. +pub struct Mail { + /// Body of the mail. + pub body: Option, + /// Character ID of the sender. + pub from: Option, + /// Label IDs attached to the mail. + #[serde(default)] + pub labels: Vec, + /// Whether the mail has been read. + pub read: Option, + /// Recipients of the mail. + #[serde(default)] + pub recipients: Vec, + /// Subject of the mail. + pub subject: Option, + /// Date and time the mail was sent. + pub timestamp: Option, +} + +#[derive(Debug, Serialize, Clone)] +/// The body of a request to send a new mail. +pub struct NewMail { + /// Body of the mail. + pub body: String, + /// Recipients of the mail. + pub recipients: Vec, + /// Subject of the mail. + pub subject: String, + /// Maximum CSPA charge (in ISK) the sender accepts to pay. + #[serde(skip_serializing_if = "Option::is_none")] + pub approved_cost: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +/// The colors allowed for a mail label. +pub enum MailLabelColor { + /// `#0000fe` + #[serde(rename = "#0000fe")] + Blue, + /// `#006634` + #[serde(rename = "#006634")] + DarkGreen, + /// `#0099ff` + #[serde(rename = "#0099ff")] + LightBlue, + /// `#00ff33` + #[serde(rename = "#00ff33")] + BrightGreen, + /// `#01ffff` + #[serde(rename = "#01ffff")] + Cyan, + /// `#349800` + #[serde(rename = "#349800")] + Green, + /// `#660066` + #[serde(rename = "#660066")] + Purple, + /// `#666666` + #[serde(rename = "#666666")] + DarkGrey, + /// `#999999` + #[serde(rename = "#999999")] + Grey, + /// `#99ffff` + #[serde(rename = "#99ffff")] + PaleCyan, + /// `#9a0000` + #[serde(rename = "#9a0000")] + DarkRed, + /// `#ccff9a` + #[serde(rename = "#ccff9a")] + PaleGreen, + /// `#e6e6e6` + #[serde(rename = "#e6e6e6")] + LightGrey, + /// `#fe0000` + #[serde(rename = "#fe0000")] + Red, + /// `#ff6600` + #[serde(rename = "#ff6600")] + Orange, + /// `#ffff01` + #[serde(rename = "#ffff01")] + Yellow, + /// `#ffffcd` + #[serde(rename = "#ffffcd")] + PaleYellow, + /// `#ffffff` + #[serde(rename = "#ffffff")] + White, + /// A value not known to this version of the crate. + #[serde(other)] + Unrecognized, +} + +#[derive(Debug, Serialize, Clone)] +/// The body of a request to create a mail label. +pub struct NewMailLabel { + /// Name of the label. + pub name: String, + /// Color of the label. + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +#[derive(Debug, Serialize, Clone, Default)] +/// The body of a request to update the metadata of a mail. +pub struct MailUpdate { + /// Label IDs to set on the mail. + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, + /// Whether the mail is read. + #[serde(skip_serializing_if = "Option::is_none")] + pub read: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// A mailing list the character is subscribed to. +pub struct MailingList { + /// ID of the mailing list. + pub mailing_list_id: i64, + /// Name of the mailing list. + pub name: String, } impl MailGroup<'_> { + api_get!( + /// Return the 50 most recent mail headers of a character. + /// + /// Use `labels` to filter by label and `last_mail_id` to page back + /// from the oldest mail already seen. + get_character_mail, + "GetCharactersCharacterIdMail", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}"; + OptionalMany(labels: &[i64]) => "labels", + Optional(last_mail_id: i64) => "last_mail_id" + ); + + api_post!( + /// Send a new mail. Returns the ID of the created mail. + send_character_mail, + "PostCharactersCharacterIdMail", + RequestType::Authenticated, + i64, + (character_id: i64) => "{character_id}", + mail: &NewMail, + ); + api_get!( /// Return a list of the users mail labels, unread counts for each /// label and a total unread count. @@ -39,6 +240,124 @@ impl MailGroup<'_> { "GetCharactersCharacterIdMailLabels", RequestType::Authenticated, MailLabels, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" + ); + + api_post!( + /// Create a mail label. Returns the ID of the created label. + create_character_mail_label, + "PostCharactersCharacterIdMailLabels", + RequestType::Authenticated, + i64, + (character_id: i64) => "{character_id}", + label: &NewMailLabel, + ); + + api_delete!( + /// Delete a mail label. + delete_character_mail_label, + "DeleteCharactersCharacterIdMailLabelsLabelId", + RequestType::Authenticated, + (), + (character_id: i64) => "{character_id}", + (label_id: i64) => "{label_id}" + ); + + api_get!( + /// Return all mailing lists the character is subscribed to. + get_character_mailing_lists, + "GetCharactersCharacterIdMailLists", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" ); + + api_delete!( + /// Delete a mail. + delete_character_mail, + "DeleteCharactersCharacterIdMailMailId", + RequestType::Authenticated, + (), + (character_id: i64) => "{character_id}", + (mail_id: i64) => "{mail_id}" + ); + + api_get!( + /// Return the contents of a single mail. + get_character_mail_by_id, + "GetCharactersCharacterIdMailMailId", + RequestType::Authenticated, + Mail, + (character_id: i64) => "{character_id}", + (mail_id: i64) => "{mail_id}" + ); + + api_put!( + /// Update the labels and read state of a mail. + update_character_mail, + "PutCharactersCharacterIdMailMailId", + RequestType::Authenticated, + (), + (character_id: i64) => "{character_id}", + (mail_id: i64) => "{mail_id}", + update: &MailUpdate, + ); +} + +#[cfg(test)] +mod mail_tests { + use super::*; + + #[test] + fn test_parse_mail_header_and_unknown_recipient() { + let json = r#"[{"from": 2112625428, "is_read": true, "labels": [3, 9000000000], + "mail_id": 7000000000, "subject": "Hi", "timestamp": "2026-10-01T00:00:00Z", + "recipients": [{"recipient_id": 1, "recipient_type": "mailing_list"}, + {"recipient_id": 2, "recipient_type": "something_new"}]}]"#; + let headers: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(headers[0].mail_id, Some(7_000_000_000)); + assert_eq!( + headers[0].recipients[0].recipient_type, + RecipientType::MailingList + ); + assert_eq!( + headers[0].recipients[1].recipient_type, + RecipientType::Unrecognized + ); + } + + #[test] + fn test_serialize_new_mail_and_label() { + let mail = NewMail { + body: "b".into(), + recipients: vec![MailRecipient { + recipient_id: 1, + recipient_type: RecipientType::Character, + }], + subject: "s".into(), + approved_cost: None, + }; + let v = serde_json::to_value(&mail).unwrap(); + assert_eq!(v["recipients"][0]["recipient_type"], "character"); + assert!(v.get("approved_cost").is_none()); + let label = NewMailLabel { + name: "x".into(), + color: Some(MailLabelColor::Red), + }; + assert_eq!(serde_json::to_value(&label).unwrap()["color"], "#fe0000"); + let upd = MailUpdate { + labels: None, + read: Some(true), + }; + assert_eq!(serde_json::to_string(&upd).unwrap(), r#"{"read":true}"#); + } + + #[test] + fn test_parse_mail_and_lists() { + let m: Mail = serde_json::from_str(r#"{"body": "x", "from": 1, "read": false}"#).unwrap(); + assert_eq!(m.read, Some(false)); + let l: Vec = + serde_json::from_str(r#"[{"mailing_list_id": 5000000000, "name": "n"}]"#).unwrap(); + assert_eq!(l[0].mailing_list_id, 5_000_000_000); + } } diff --git a/src/groups/market.rs b/src/groups/market.rs index cff73d3..f9dc051 100644 --- a/src/groups/market.rs +++ b/src/groups/market.rs @@ -16,18 +16,18 @@ pub struct HistoryItem { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct MarketOrder { - pub duration: i32, + pub duration: i64, pub is_buy_order: bool, pub issued: String, pub location_id: i64, - pub min_volume: i32, + pub min_volume: i64, pub order_id: i64, pub price: f64, pub range: String, - pub system_id: i32, - pub type_id: i32, - pub volume_remain: i32, - pub volume_total: i32, + pub system_id: i64, + pub type_id: i64, + pub volume_remain: i64, + pub volume_total: i64, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -35,26 +35,26 @@ pub struct MarketOrder { pub struct PriceItem { pub adjusted_price: Option, pub average_price: Option, - pub type_id: i32, + pub type_id: i64, } #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct CharacterOrder { - pub duration: i32, + pub duration: i64, pub escrow: Option, pub is_buy_order: Option, pub is_corporation: bool, pub issued: String, pub location_id: i64, - pub min_volume: Option, + pub min_volume: Option, pub order_id: i64, pub price: f64, pub range: String, - pub region_id: i32, - pub type_id: i32, - pub volume_remain: i32, - pub volume_total: i32, + pub region_id: i64, + pub type_id: i64, + pub volume_remain: i64, + pub volume_total: i64, } /// Endpoints for Market @@ -69,8 +69,8 @@ impl MarketGroup<'_> { "GetMarketsRegionIdHistory", RequestType::Public, Vec, - (region_id: i32) => "{region_id}"; - (type_id: i32) => "type_id" + (region_id: i64) => "{region_id}"; + (type_id: i64) => "type_id" ); api_get!( @@ -79,10 +79,10 @@ impl MarketGroup<'_> { "GetMarketsRegionIdOrders", RequestType::Public, Vec, - (region_id: i32) => "{region_id}"; + (region_id: i64) => "{region_id}"; Optional(order_type: String) => "order_type", Optional(page: i32) => "page", - Optional(type_id: i32) => "type_id" + Optional(type_id: i64) => "type_id" ); api_get!( @@ -99,6 +99,253 @@ impl MarketGroup<'_> { "GetCharactersCharacterIdOrders", RequestType::Authenticated, Vec, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); } + +/// Information about a market group. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct MarketGroupInfo { + pub description: String, + pub market_group_id: i64, + pub name: String, + pub parent_group_id: Option, + pub types: Vec, +} + +impl MarketGroup<'_> { + api_get!( + /// List the IDs of all market groups. + get_groups, + "GetMarketsGroups", + RequestType::Public, + Vec, + ); + + api_get!( + /// Get information about a market group. + get_group, + "GetMarketsGroupsMarketGroupId", + RequestType::Public, + MarketGroupInfo, + (market_group_id: i64) => "{market_group_id}" + ); + + api_get!( + /// List the type IDs that have active orders in a region. + get_region_types, + "GetMarketsRegionIdTypes", + RequestType::Public, + Vec, + (region_id: i64) => "{region_id}"; + Optional(page: i32) => "page" + ); +} + +/// How far away a market order can be matched. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum OrderRange { + /// Only at the order's station. + #[serde(rename = "station")] + Station, + /// Within the whole region. + #[serde(rename = "region")] + Region, + /// Within the solar system. + #[serde(rename = "solarsystem")] + SolarSystem, + /// Within 1 jump. + #[serde(rename = "1")] + Jumps1, + /// Within 2 jumps. + #[serde(rename = "2")] + Jumps2, + /// Within 3 jumps. + #[serde(rename = "3")] + Jumps3, + /// Within 4 jumps. + #[serde(rename = "4")] + Jumps4, + /// Within 5 jumps. + #[serde(rename = "5")] + Jumps5, + /// Within 10 jumps. + #[serde(rename = "10")] + Jumps10, + /// Within 20 jumps. + #[serde(rename = "20")] + Jumps20, + /// Within 30 jumps. + #[serde(rename = "30")] + Jumps30, + /// Within 40 jumps. + #[serde(rename = "40")] + Jumps40, + /// A range this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// Final state of a market order that is no longer open. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ClosedOrderState { + /// The order was cancelled. + Cancelled, + /// The order expired. + Expired, + /// A state this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A closed market order of a character. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct CharacterOrderHistory { + pub duration: i64, + pub escrow: Option, + pub is_buy_order: Option, + pub is_corporation: bool, + pub issued: String, + pub location_id: i64, + pub min_volume: Option, + pub order_id: i64, + pub price: f64, + pub range: OrderRange, + pub region_id: i64, + pub state: ClosedOrderState, + pub type_id: i64, + pub volume_remain: i64, + pub volume_total: i64, +} + +/// An open market order of a corporation. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct CorporationOrder { + pub duration: i64, + pub escrow: Option, + pub is_buy_order: Option, + pub issued: String, + pub issued_by: i64, + pub location_id: i64, + pub min_volume: Option, + pub order_id: i64, + pub price: f64, + pub range: OrderRange, + pub region_id: i64, + pub type_id: i64, + pub volume_remain: i64, + pub volume_total: i64, + pub wallet_division: i64, +} + +/// A closed market order of a corporation. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct CorporationOrderHistory { + pub duration: i64, + pub escrow: Option, + pub is_buy_order: Option, + pub issued: String, + pub issued_by: Option, + pub location_id: i64, + pub min_volume: Option, + pub order_id: i64, + pub price: f64, + pub range: OrderRange, + pub region_id: i64, + pub state: ClosedOrderState, + pub type_id: i64, + pub volume_remain: i64, + pub volume_total: i64, + pub wallet_division: i64, +} + +/// An order in a player-owned structure market. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct StructureOrder { + pub duration: i64, + pub is_buy_order: bool, + pub issued: String, + pub location_id: i64, + pub min_volume: i64, + pub order_id: i64, + pub price: f64, + pub range: OrderRange, + pub type_id: i64, + pub volume_remain: i64, + pub volume_total: i64, +} + +impl MarketGroup<'_> { + api_get!( + /// List cancelled and expired market orders placed by a character (up to 90 days). + get_character_orders_history, + "GetCharactersCharacterIdOrdersHistory", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// List open market orders placed on behalf of a corporation. + get_corporation_orders, + "GetCorporationsCorporationIdOrders", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// List cancelled and expired market orders placed on behalf of a corporation. + get_corporation_orders_history, + "GetCorporationsCorporationIdOrdersHistory", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// List orders in a structure's market. + get_structure_orders, + "GetMarketsStructuresStructureId", + RequestType::Authenticated, + Vec, + (structure_id: i64) => "{structure_id}"; + Optional(page: i32) => "page" + ); +} + +#[cfg(test)] +mod order_history_tests { + use super::*; + + #[test] + fn test_parse_ranges_and_states() { + let ranges: Vec = + serde_json::from_str(r#"["station","solarsystem","10","weird"]"#).unwrap(); + assert_eq!( + ranges, + [ + OrderRange::Station, + OrderRange::SolarSystem, + OrderRange::Jumps10, + OrderRange::Unrecognized + ] + ); + let order: CorporationOrderHistory = serde_json::from_str( + r#"{"duration":90,"issued":"x","location_id":1,"order_id":2,"price":1.5,"range":"region", + "region_id":3,"state":"expired","type_id":4,"volume_remain":0,"volume_total":5,"wallet_division":1}"#, + ) + .unwrap(); + assert_eq!(order.state, ClosedOrderState::Expired); + assert!(order.issued_by.is_none()); + } +} diff --git a/src/groups/meta.rs b/src/groups/meta.rs new file mode 100644 index 0000000..57f4ccc --- /dev/null +++ b/src/groups/meta.rs @@ -0,0 +1,193 @@ +use crate::prelude::*; +use std::collections::HashMap; + +/// Endpoints for Meta +pub struct MetaGroup<'a> { + pub(crate) esi: &'a Esi, +} + +/// HTTP method of a route. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum HttpMethod { + /// `GET` + #[serde(rename = "GET")] + Get, + /// `POST` + #[serde(rename = "POST")] + Post, + /// `PUT` + #[serde(rename = "PUT")] + Put, + /// `DELETE` + #[serde(rename = "DELETE")] + Delete, + /// A method this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// The kind of change recorded in the changelog. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ChangeType { + /// A breaking change. + Breaking, + /// A non-breaking change. + Changed, + /// A new route. + New, + /// A removed route. + Removed, + /// A kind of change this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// Health of a route. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum RouteHealth { + /// The status is not known. + Unknown, + /// The route is working as expected. + #[serde(rename = "OK")] + Ok, + /// The route is working, but with degraded performance. + Degraded, + /// The route is not working. + Down, + /// The route is recovering. + Recovering, + /// A status this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// The list of compatibility dates supported by ESI. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct CompatibilityDates { + pub compatibility_dates: Vec, +} + +/// One change to a route, as listed in the ESI changelog. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct ChangelogEntry { + pub compatibility_date: String, + pub description: String, + pub method: HttpMethod, + pub path: String, + #[serde(rename = "type")] + pub change_type: ChangeType, +} + +/// The ESI changelog, keyed by compatibility date. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct Changelog { + pub changelog: HashMap>, +} + +/// A previous name of the API. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct ApiNameEntry { + pub date: String, + pub name: String, +} + +/// Current and past names of the API. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct ApiName { + pub current: String, + pub history: Vec, +} + +/// Health of a single route. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct RouteStatus { + pub method: HttpMethod, + pub path: String, + pub status: RouteHealth, +} + +/// Health of every ESI route. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct ApiStatus { + pub routes: Vec, +} + +impl MetaGroup<'_> { + api_get!( + /// Get the changelog of ESI routes. + get_changelog, + "GetMetaChangelog", + RequestType::Public, + Changelog, + ); + + api_get!( + /// Get the compatibility dates supported by ESI. + get_compatibility_dates, + "GetMetaCompatibilityDates", + RequestType::Public, + CompatibilityDates, + ); + + api_get!( + /// Get the current and past names of the API. + get_name, + "GetMetaName", + RequestType::Public, + ApiName, + ); + + api_get!( + /// Get the health of every ESI route. + get_status, + "GetMetaStatus", + RequestType::Public, + ApiStatus, + ); +} + +#[cfg(test)] +mod tests { + use super::{ + ApiName, ApiStatus, ChangeType, Changelog, CompatibilityDates, HttpMethod, RouteHealth, + }; + + #[test] + fn test_parse_compatibility_dates() { + let json = r#"{"compatibility_dates":["2026-08-18","2020-01-01"]}"#; + let dates: CompatibilityDates = serde_json::from_str(json).unwrap(); + assert_eq!(dates.compatibility_dates.len(), 2); + } + + #[test] + fn test_parse_changelog() { + let json = r#"{"changelog":{"2026-08-18":[{"method":"GET","path":"/status","compatibility_date":"2026-08-18","type":"changed","description":"Updated."}]}}"#; + let log: Changelog = serde_json::from_str(json).unwrap(); + assert_eq!( + log.changelog["2026-08-18"][0].change_type, + ChangeType::Changed + ); + assert_eq!(log.changelog["2026-08-18"][0].method, HttpMethod::Get); + } + + #[test] + fn test_parse_name_and_status() { + let name: ApiName = serde_json::from_str( + r#"{"current":"ESI","history":[{"date":"2026-01-01","name":"Old"}]}"#, + ) + .unwrap(); + assert_eq!(name.history[0].name, "Old"); + let status: ApiStatus = + serde_json::from_str(r#"{"routes":[{"method":"GET","path":"/status","status":"OK"}]}"#) + .unwrap(); + assert_eq!(status.routes[0].status, RouteHealth::Ok); + } +} diff --git a/src/groups/military_campaigns.rs b/src/groups/military_campaigns.rs new file mode 100644 index 0000000..dd84bfb --- /dev/null +++ b/src/groups/military_campaigns.rs @@ -0,0 +1,171 @@ +use crate::groups::Page; +use crate::prelude::*; +use uuid::Uuid; + +/// Endpoints for Military Campaigns +pub struct MilitaryCampaignsGroup<'a> { + pub(crate) esi: &'a Esi, +} + +/// The state of a campaign or objective. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum CampaignState { + /// The state is not specified. + Unspecified, + /// In progress. + Active, + /// Completed. + Completed, + /// Expired. + Expired, + /// A state this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A military campaign. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct Campaign { + pub finished: Option, + pub id: Uuid, + pub progress: i64, + pub started: Option, + pub state: CampaignState, +} + +/// Participation counts of an objective. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct ObjectiveParticipants { + pub committed: i64, + pub contributors: i64, + pub total: i64, +} + +/// An objective of a campaign. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct Objective { + pub finished: Option, + pub id: Uuid, + pub last_modified: String, + pub participants: ObjectiveParticipants, + pub progress: i64, + pub started: Option, + pub state: CampaignState, +} + +#[derive(Debug, Deserialize)] +struct CampaignList { + campaigns: Vec, +} + +impl MilitaryCampaignsGroup<'_> { + /// List the military campaigns. + pub async fn list(&self) -> EsiResult> { + let path = self + .esi + .get_endpoint_for_op_id("GetMilitaryCampaignsListing")?; + let wrapper: CampaignList = self + .esi + .query("GET", RequestType::Public, &path, None, None) + .await?; + Ok(wrapper.campaigns) + } + + api_get!( + /// Get a military campaign. + get_campaign, + "GetMilitaryCampaignsDetail", + RequestType::Public, + Campaign, + (campaign_id: Uuid) => "{campaign_id}" + ); + + api_get!( + /// List the objectives of a campaign. Use the cursor of the returned page + /// as `after` or `before` to walk through the list. + list_objectives, + "GetMilitaryCampaignsObjectivesListing", + RequestType::Public, + Page, + (campaign_id: Uuid) => "{campaign_id}"; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit" + ); + + api_get!( + /// Get an objective of a campaign. + get_objective, + "GetMilitaryCampaignsObjectivesDetail", + RequestType::Public, + Objective, + (campaign_id: Uuid) => "{campaign_id}", + (objective_id: Uuid) => "{objective_id}" + ); +} + +#[cfg(test)] +mod tests { + use super::{Campaign, CampaignState}; + use crate::groups::{Objective, Page}; + + #[test] + fn test_parse_campaign() { + let json = + r#"{"id": "3868eaed-8278-4cb7-9709-7d7de9c20dc7", "progress": 40, "state": "Active"}"#; + let campaign: Campaign = serde_json::from_str(json).unwrap(); + assert_eq!(campaign.state, CampaignState::Active); + assert!(campaign.started.is_none()); + } + + #[test] + fn test_parse_objectives_page_without_cursor() { + let json = r#"{"objectives": [{ + "id": "3868eaed-8278-4cb7-9709-7d7de9c20dc7", + "last_modified": "2026-10-01T00:00:00Z", + "participants": {"committed": 1, "contributors": 2, "total": 3}, + "progress": 5, "state": "Completed" + }]}"#; + let page: Page = serde_json::from_str(json).unwrap(); + assert!(page.cursor.is_none()); + assert_eq!(page.items[0].participants.total, 3); + } +} + +/// The participation of a character in a campaign objective. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct CharacterObjective { + pub campaign_id: Uuid, + pub contributed: i64, + pub id: Uuid, + pub is_committed: bool, + pub last_modified: String, +} + +impl MilitaryCampaignsGroup<'_> { + api_get!( + /// List the campaign objectives a character takes part in. + list_character_objectives, + "GetCharactersMilitaryCampaignsObjectivesListing", + RequestType::Authenticated, + Page, + (character_id: i64) => "{character_id}"; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit" + ); + + api_get!( + /// Get the participation of a character in one objective. + get_character_objective, + "GetCharactersMilitaryCampaignsObjectivesParticipation", + RequestType::Authenticated, + CharacterObjective, + (character_id: i64) => "{character_id}", + (objective_id: Uuid) => "{objective_id}" + ); +} diff --git a/src/groups/mod.rs b/src/groups/mod.rs index 618598e..63ef39a 100644 --- a/src/groups/mod.rs +++ b/src/groups/mod.rs @@ -1,5 +1,9 @@ //! Endpoint groups +mod access_list; +pub use access_list::*; +mod activities; +pub use activities::*; mod alliance; pub use alliance::*; mod assets; @@ -18,12 +22,18 @@ mod contracts; pub use contracts::*; mod corporation; pub use corporation::*; +mod corporation_projects; +pub use corporation_projects::*; +mod cosmetics; +pub use cosmetics::*; mod dogma; pub use dogma::*; mod faction_warfare; pub use faction_warfare::*; mod fittings; pub use fittings::*; +mod freelance_jobs; +pub use freelance_jobs::*; mod fleets; pub use fleets::*; mod incursions; @@ -42,18 +52,26 @@ mod mail; pub use mail::*; mod market; pub use market::*; +mod military_campaigns; +pub use military_campaigns::*; mod opportunities; pub use opportunities::*; +mod paragon_hub; +pub use paragon_hub::*; mod planetary_interaction; pub use planetary_interaction::*; mod routes; pub use routes::*; +mod meta; +pub use meta::*; mod search; pub use search::*; mod skills; pub use skills::*; mod sovereignty; pub use sovereignty::*; +mod structures; +pub use structures::*; mod status; pub use status::*; mod universe; @@ -64,3 +82,78 @@ mod wallet; pub use wallet::*; mod wars; pub use wars::*; + +/// Cursor for walking through paginated listings. +/// +/// Pass `after` or `before` from a page as the matching parameter of the next request. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct Cursor { + /// Cursor to continue walking forwards in time. + pub after: Option, + /// Cursor to continue walking backwards in time. + pub before: Option, +} + +/// One page of a cursor-paginated listing, with the cursors to continue walking it. +/// +/// ESI names the array of records after what the endpoint lists (`projects`, +/// `listings`, `objectives`, ...); here it is always [`Page::items`]. A response +/// without the array reads as a page without records. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct Page { + /// Cursors to continue walking the list. + pub cursor: Option, + /// The records in the page. + #[serde( + default = "Vec::new", + alias = "projects", + alias = "contributors", + alias = "freelance_jobs", + alias = "participants", + alias = "objectives", + alias = "listings" + )] + pub items: Vec, +} + +#[cfg(test)] +mod page_tests { + use super::Page; + + #[test] + fn test_page_reads_every_key_esi_uses() { + for key in [ + "projects", + "contributors", + "freelance_jobs", + "participants", + "objectives", + "listings", + ] { + let json = + format!(r#"{{"cursor": {{"after": "a", "before": null}}, "{key}": [1, 2]}}"#); + let page: Page = serde_json::from_str(&json).unwrap(); + assert_eq!(page.items, [1, 2], "{key}"); + assert_eq!(page.cursor.unwrap().after.as_deref(), Some("a")); + } + } + + #[test] + fn test_page_without_records_or_cursor_is_empty() { + let page: Page = serde_json::from_str("{}").unwrap(); + assert!(page.items.is_empty() && page.cursor.is_none()); + let page: Page = + serde_json::from_str(r#"{"cursor": {"after": null, "before": null}}"#).unwrap(); + assert!(page.items.is_empty()); + } + + #[test] + fn test_page_round_trips() { + let page = Page { + cursor: None, + items: vec![1_i64, 2], + }; + let json = serde_json::to_string(&page).unwrap(); + assert_eq!(serde_json::from_str::>(&json).unwrap(), page); + } +} diff --git a/src/groups/paragon_hub.rs b/src/groups/paragon_hub.rs new file mode 100644 index 0000000..1d5b477 --- /dev/null +++ b/src/groups/paragon_hub.rs @@ -0,0 +1,189 @@ +use crate::groups::Page; +use crate::prelude::*; +use uuid::Uuid; + +/// Endpoints for Paragon Hub +pub struct ParagonHubGroup<'a> { + pub(crate) esi: &'a Esi, +} + +/// The state of a SKINR listing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ListingState { + /// For sale. + Listed, + /// Sold out. + SoldOut, + /// Expired. + Expired, + /// Removed by the seller. + Removed, + /// A state this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// The price of a listing, in ISK or in PLEX. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[serde(untagged)] +#[allow(missing_docs)] +pub enum ListingPrice { + Isk { isk: f64 }, + Plex { plex: i64 }, +} + +/// A SKINR listing. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct SkinrListing { + pub created: String, + pub expires: String, + pub id: Uuid, + pub last_modified: String, + pub price: ListingPrice, + pub quantity: i64, + /// Character ID of the seller. + pub seller_id: i64, + pub skinr_id: String, + pub state: ListingState, +} + +impl ParagonHubGroup<'_> { + api_get!( + /// List SKINR listings. Use the cursor of the returned page as `after` or + /// `before` to walk through the list. + get_skinr_listings, + "GetParagonHubSkinr", + RequestType::Public, + Page, + ; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit" + ); +} + +#[cfg(test)] +mod tests { + use super::{ListingPrice, ListingState}; + use crate::groups::{Page, SkinrListing}; + + #[test] + fn test_parse_listings_with_both_price_kinds() { + let json = r#"{"listings": [ + {"created": "2026-10-01T00:00:00Z", "expires": "2026-11-01T00:00:00Z", + "id": "3868eaed-8278-4cb7-9709-7d7de9c20dc7", "last_modified": "2026-10-02T00:00:00Z", + "price": {"isk": 1500000.5}, "quantity": 1, "seller_id": 2112000000, + "skinr_id": "abc", "state": "sold_out"}, + {"created": "2026-10-01T00:00:00Z", "expires": "2026-11-01T00:00:00Z", + "id": "3868eaed-8278-4cb7-9709-7d7de9c20dc8", "last_modified": "2026-10-02T00:00:00Z", + "price": {"plex": 250}, "quantity": 3, "seller_id": 2112000001, + "skinr_id": "def", "state": "listed"} + ]}"#; + let page: Page = serde_json::from_str(json).unwrap(); + assert!(page.cursor.is_none()); + assert_eq!(page.items[0].state, ListingState::SoldOut); + assert!(matches!(page.items[0].price, ListingPrice::Isk { .. })); + assert!(matches!( + page.items[1].price, + ListingPrice::Plex { plex: 250 } + )); + } +} + +/// Who a SKINR listing is offered to: only one field is set. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct ListingTarget { + /// Offered to one character. + pub character_id: Option, + /// Offered to one corporation. + pub corporation_id: Option, + /// Offered to one alliance. + pub alliance_id: Option, + /// Offered to everyone. + pub public: Option, +} + +/// A SKINR listing of a character, including who it is offered to. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct CharacterSkinrListing { + pub created: String, + pub expires: String, + pub id: Uuid, + pub last_modified: String, + pub price: ListingPrice, + pub quantity: i64, + pub seller_id: i64, + pub skinr_id: String, + pub state: ListingState, + pub target: ListingTarget, +} + +impl ParagonHubGroup<'_> { + api_get!( + /// List the SKINR listings of a character. + get_character_skinr_listings, + "GetCharactersParagonHubSkinr", + RequestType::Authenticated, + Page, + (character_id: i64) => "{character_id}"; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit" + ); + + api_get!( + /// List the SKINR listings offered to an alliance. + get_alliance_skinr_listings, + "GetParagonHubSkinrAlliances", + RequestType::Authenticated, + Page, + (alliance_id: i64) => "{alliance_id}"; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit" + ); + + api_get!( + /// List the SKINR listings offered to a character. + get_skinr_listings_for_character, + "GetParagonHubSkinrCharacters", + RequestType::Authenticated, + Page, + (character_id: i64) => "{character_id}"; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit" + ); + + api_get!( + /// List the SKINR listings offered to a corporation. + get_corporation_skinr_listings, + "GetParagonHubSkinrCorporations", + RequestType::Authenticated, + Page, + (corporation_id: i64) => "{corporation_id}"; + Optional(after: String) => "after", + Optional(before: String) => "before", + Optional(limit: i64) => "limit" + ); +} + +#[cfg(test)] +mod target_tests { + use super::*; + + #[test] + fn test_parse_character_listing_target() { + let page: Page = serde_json::from_str( + r#"{"listings":[{"created":"a","expires":"b","id":"67e6b7a0-6f58-4b5b-a2c4-4f3a1f0f2b11", + "last_modified":"c","price":{"plex":5},"quantity":1,"seller_id":2,"skinr_id":"s", + "state":"listed","target":{"corporation_id":9}}]}"#, + ) + .unwrap(); + assert_eq!(page.items[0].target.corporation_id, Some(9)); + assert!(page.cursor.is_none()); + } +} diff --git a/src/groups/planetary_interaction.rs b/src/groups/planetary_interaction.rs index 8707ea4..092ec32 100644 --- a/src/groups/planetary_interaction.rs +++ b/src/groups/planetary_interaction.rs @@ -1,8 +1,306 @@ -#![allow(unused)] - use crate::prelude::*; /// Endpoints for PlanetaryInteraction pub struct PlanetaryInteractionGroup<'a> { pub(crate) esi: &'a Esi, } + +/// Information about a planetary production schematic. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct Schematic { + /// Time in seconds to process one cycle. + pub cycle_time: i64, + pub schematic_name: String, +} + +/// The type of a planet that supports planetary interaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PlanetType { + /// Temperate. + Temperate, + /// Barren. + Barren, + /// Oceanic. + Oceanic, + /// Ice. + Ice, + /// Gas. + Gas, + /// Lava. + Lava, + /// Storm. + Storm, + /// Plasma. + Plasma, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A planet where a character has a colony. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct CharacterPlanet { + /// Date of the last update of the colony. + pub last_update: String, + /// Number of pins in the colony. + pub num_pins: i64, + /// Character ID of the owner. + pub owner_id: i64, + /// ID of the planet. + pub planet_id: i64, + /// Type of the planet. + pub planet_type: PlanetType, + /// Solar system of the planet. + pub solar_system_id: i64, + /// Command center upgrade level. + pub upgrade_level: i64, +} + +/// A link between two pins of a colony. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ColonyLink { + /// Pin the link ends at. + pub destination_pin_id: i64, + /// Level of the link. + pub link_level: i64, + /// Pin the link starts at. + pub source_pin_id: i64, +} + +/// An amount of a commodity stored in a pin. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct PinContent { + /// Amount stored. + pub amount: i64, + /// Type ID of the commodity. + pub type_id: i64, +} + +/// A head of an extractor control unit. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ExtractorHead { + /// ID of the head. + pub head_id: i64, + /// Latitude of the head. + pub latitude: f64, + /// Longitude of the head. + pub longitude: f64, +} + +/// Details of an extractor control unit. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ExtractorDetails { + /// Cycle time in seconds. + pub cycle_time: Option, + /// Radius of the heads. + pub head_radius: Option, + /// Heads of the control unit. + #[serde(default)] + pub heads: Vec, + /// Type ID of the extracted product. + pub product_type_id: Option, + /// Quantity extracted per cycle. + pub qty_per_cycle: Option, +} + +/// Details of a factory. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct FactoryDetails { + /// ID of the schematic. + pub schematic_id: i64, +} + +/// A pin (structure) of a colony. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ColonyPin { + /// Contents of the pin. + #[serde(default)] + pub contents: Vec, + /// Expiry time of the pin. + pub expiry_time: Option, + /// Extractor details, for extractor control units. + pub extractor_details: Option, + /// Factory details, for factories. + pub factory_details: Option, + /// Installation time. + pub install_time: Option, + /// Start of the last cycle. + pub last_cycle_start: Option, + /// Latitude of the pin. + pub latitude: f64, + /// Longitude of the pin. + pub longitude: f64, + /// ID of the pin. + pub pin_id: i64, + /// ID of the schematic, for factories. + pub schematic_id: Option, + /// Type ID of the pin. + pub type_id: i64, +} + +/// A route of commodities between two pins. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ColonyRoute { + /// Type ID of the commodity moved by the route. + pub content_type_id: i64, + /// Pin the route ends at. + pub destination_pin_id: i64, + /// Quantity moved. + pub quantity: f64, + /// ID of the route. + pub route_id: i64, + /// Pin the route starts at. + pub source_pin_id: i64, + /// Pins the route passes through. + #[serde(default)] + pub waypoints: Vec, +} + +/// The layout of a colony. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct Colony { + /// Links between pins. + pub links: Vec, + /// Pins of the colony. + pub pins: Vec, + /// Routes of the colony. + pub routes: Vec, +} + +/// The standing level that decides a customs office tax rate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StandingLevel { + /// Bad standing. + Bad, + /// Excellent standing. + Excellent, + /// Good standing. + Good, + /// Neutral standing. + Neutral, + /// Terrible standing. + Terrible, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A customs office of a corporation. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct CustomsOffice { + /// Tax rate for alliance members. + pub alliance_tax_rate: Option, + /// Whether access is allowed based on standings. + pub allow_access_with_standings: bool, + /// Whether alliance members have access. + pub allow_alliance_access: bool, + /// Tax rate for bad standings. + pub bad_standing_tax_rate: Option, + /// Tax rate for corporation members. + pub corporation_tax_rate: Option, + /// Tax rate for excellent standings. + pub excellent_standing_tax_rate: Option, + /// Tax rate for good standings. + pub good_standing_tax_rate: Option, + /// Tax rate for neutral standings. + pub neutral_standing_tax_rate: Option, + /// ID of the office. + pub office_id: i64, + /// Hour the reinforcement exit window ends. + pub reinforce_exit_end: i64, + /// Hour the reinforcement exit window starts. + pub reinforce_exit_start: i64, + /// Minimum standing level allowed. + pub standing_level: Option, + /// Solar system of the office. + pub system_id: i64, + /// Tax rate for terrible standings. + pub terrible_standing_tax_rate: Option, + /// Type ID of the office. + pub type_id: Option, +} + +impl PlanetaryInteractionGroup<'_> { + api_get!( + /// Get information about a planetary production schematic. + get_schematic, + "GetUniverseSchematicsSchematicId", + RequestType::Public, + Schematic, + (schematic_id: i64) => "{schematic_id}" + ); + api_get!( + /// List the planets where a character has colonies. + get_character_planets, + "GetCharactersCharacterIdPlanets", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// Get the layout of a character's colony on a planet. + get_character_planet, + "GetCharactersCharacterIdPlanetsPlanetId", + RequestType::Authenticated, + Colony, + (character_id: i64) => "{character_id}", + (planet_id: i64) => "{planet_id}" + ); + + api_get!( + /// List the customs offices of a corporation. + /// + /// Requires the Director role. + get_corporation_customs_offices, + "GetCorporationsCorporationIdCustomsOffices", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}"; + Optional(page: i32) => "page" + ); +} + +#[cfg(test)] +mod colony_tests { + use super::*; + + #[test] + fn test_parse_colony() { + let json = r#"{"links": [{"destination_pin_id": 1000000000001, "link_level": 0, "source_pin_id": 2}], + "pins": [{"latitude": 1.0, "longitude": 2.0, "pin_id": 1000000000001, "type_id": 2524, + "extractor_details": {"cycle_time": 3600, "heads": [{"head_id": 0, "latitude": 1.0, "longitude": 1.0}]}, + "contents": [{"amount": 3000000000, "type_id": 2268}]}], + "routes": [{"content_type_id": 2268, "destination_pin_id": 2, "quantity": 20.0, + "route_id": 1, "source_pin_id": 1}]}"#; + let c: Colony = serde_json::from_str(json).unwrap(); + assert_eq!(c.pins[0].pin_id, 1_000_000_000_001); + assert_eq!(c.pins[0].contents[0].amount, 3_000_000_000); + assert!(c.routes[0].waypoints.is_empty()); + assert_eq!(c.pins[0].extractor_details.as_ref().unwrap().heads.len(), 1); + } + + #[test] + fn test_parse_planets_and_offices() { + let p: Vec = serde_json::from_str( + r#"[{"last_update": "u", "num_pins": 5, "owner_id": 2112000000, "planet_id": 40000001, + "planet_type": "lava", "solar_system_id": 30000142, "upgrade_level": 4}, + {"last_update": "u", "num_pins": 1, "owner_id": 1, "planet_id": 1, + "planet_type": "shattered", "solar_system_id": 1, "upgrade_level": 0}]"#, + ) + .unwrap(); + assert_eq!(p[0].planet_type, PlanetType::Lava); + assert_eq!(p[1].planet_type, PlanetType::Unrecognized); + let o: Vec = serde_json::from_str( + r#"[{"allow_access_with_standings": true, "allow_alliance_access": false, + "office_id": 1040000000000, "reinforce_exit_end": 21, "reinforce_exit_start": 20, + "system_id": 30000142, "standing_level": "good", "good_standing_tax_rate": 0.05}]"#, + ) + .unwrap(); + assert_eq!(o[0].standing_level, Some(StandingLevel::Good)); + assert_eq!(o[0].office_id, 1_040_000_000_000); + } +} diff --git a/src/groups/routes.rs b/src/groups/routes.rs index 77dacc9..b48f967 100644 --- a/src/groups/routes.rs +++ b/src/groups/routes.rs @@ -1,8 +1,88 @@ -#![allow(unused)] - use crate::prelude::*; /// Endpoints for Routes pub struct RoutesGroup<'a> { pub(crate) esi: &'a Esi, } + +/// Which route to prefer when several are possible. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum RoutePreference { + /// The shortest route. + Shorter, + /// The route that avoids low and null security systems. + Safer, + /// The route that prefers low and null security systems. + LessSecure, +} + +/// An extra connection between two systems (such as a wormhole or a jump bridge). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct RouteConnection { + /// Solar system ID the connection starts at. + pub from: i64, + /// Solar system ID the connection ends at. + pub to: i64, +} + +/// Options for a route calculation. All fields are optional. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct RouteRequest { + /// Solar system IDs to avoid. + #[serde(skip_serializing_if = "Option::is_none")] + pub avoid_systems: Option>, + /// Extra connections to consider. + #[serde(skip_serializing_if = "Option::is_none")] + pub connections: Option>, + /// Route preference. + #[serde(skip_serializing_if = "Option::is_none")] + pub preference: Option, + /// Extra penalty applied to low and null security systems. + #[serde(skip_serializing_if = "Option::is_none")] + pub security_penalty: Option, +} + +/// A calculated route. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Route { + /// Solar system IDs along the route, origin first. + pub route: Vec, +} + +impl RoutesGroup<'_> { + api_post!( + /// Calculate the route between two solar systems. + calculate_route, + "PostRoute", + RequestType::Public, + Route, + (origin_system_id: i64) => "{origin_system_id}", + (destination_system_id: i64) => "{destination_system_id}", + options: &RouteRequest, + ); +} + +#[cfg(test)] +mod tests { + use super::{RouteConnection, RoutePreference, RouteRequest}; + + #[test] + fn test_default_request_serializes_to_empty_object() { + let json = serde_json::to_string(&RouteRequest::default()).unwrap(); + assert_eq!(json, "{}"); + } + + #[test] + fn test_request_serialization() { + let request = RouteRequest { + avoid_systems: Some(vec![30000142]), + connections: Some(vec![RouteConnection { from: 1, to: 2 }]), + preference: Some(RoutePreference::LessSecure), + security_penalty: Some(50), + }; + let value = serde_json::to_value(&request).unwrap(); + assert_eq!(value["preference"], "LessSecure"); + assert_eq!(value["connections"][0]["from"], 1); + assert_eq!(value["security_penalty"], 50); + } +} diff --git a/src/groups/search.rs b/src/groups/search.rs index 8db4567..5396a38 100644 --- a/src/groups/search.rs +++ b/src/groups/search.rs @@ -14,7 +14,7 @@ impl SearchGroup<'_> { "GetCharactersCharacterIdSearch", RequestType::Authenticated, SearchResult, - (character_id: i32) => "{character_id}"; + (character_id: i64) => "{character_id}"; (categories: String) => "categories", (search: String) => "search"; Optional(strict: bool) => "strict" @@ -24,15 +24,15 @@ impl SearchGroup<'_> { #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct SearchResult { - pub agent: Option>, - pub alliance: Option>, - pub character: Option>, - pub constellation: Option>, - pub corporation: Option>, - pub faction: Option>, - pub inventory_type: Option>, - pub region: Option>, - pub solar_system: Option>, - pub station: Option>, - pub structure: Option>, + pub agent: Option>, + pub alliance: Option>, + pub character: Option>, + pub constellation: Option>, + pub corporation: Option>, + pub faction: Option>, + pub inventory_type: Option>, + pub region: Option>, + pub solar_system: Option>, + pub station: Option>, + pub structure: Option>, } diff --git a/src/groups/skills.rs b/src/groups/skills.rs index 48c16f5..d4f2ecc 100644 --- a/src/groups/skills.rs +++ b/src/groups/skills.rs @@ -8,10 +8,10 @@ pub struct SkillsGroup<'a> { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct Skill { - pub skill_id: i32, - pub active_skill_level: i32, + pub skill_id: i64, + pub active_skill_level: i64, pub skillpoints_in_skill: i64, - pub trained_skill_level: i32, + pub trained_skill_level: i64, } #[derive(Debug, Deserialize)] @@ -19,7 +19,7 @@ pub struct Skill { pub struct Skills { pub skills: Vec, pub total_sp: i64, - pub unallocated_sp: Option, + pub unallocated_sp: Option, } impl SkillsGroup<'_> { @@ -29,6 +29,67 @@ impl SkillsGroup<'_> { "GetCharactersCharacterIdSkills", RequestType::Authenticated, Skills, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" ); } + +/// A character's attributes and remap information. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct CharacterAttributes { + pub accrued_remap_cooldown_date: Option, + pub bonus_remaps: Option, + pub charisma: i64, + pub intelligence: i64, + pub last_remap_date: Option, + pub memory: i64, + pub perception: i64, + pub willpower: i64, +} + +/// An entry of a character's skill queue. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct SkillQueueItem { + pub finish_date: Option, + pub finished_level: i64, + pub level_end_sp: Option, + pub level_start_sp: Option, + pub queue_position: i64, + pub skill_id: i64, + pub start_date: Option, + pub training_start_sp: Option, +} + +impl SkillsGroup<'_> { + api_get!( + /// Get a character's attributes. + get_attributes, + "GetCharactersCharacterIdAttributes", + RequestType::Authenticated, + CharacterAttributes, + (character_id: i64) => "{character_id}" + ); + + api_get!( + /// Get a character's skill queue. + get_skillqueue, + "GetCharactersCharacterIdSkillqueue", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}" + ); +} + +#[cfg(test)] +mod character_basic_tests { + use super::SkillQueueItem; + + #[test] + fn test_parse_paused_queue_entry() { + let json = r#"[{"finished_level": 5, "queue_position": 0, "skill_id": 3300}]"#; + let queue: Vec = serde_json::from_str(json).unwrap(); + assert!(queue[0].finish_date.is_none()); + assert_eq!(queue[0].skill_id, 3300); + } +} diff --git a/src/groups/sovereignty.rs b/src/groups/sovereignty.rs index 1842dc2..1b2abcb 100644 --- a/src/groups/sovereignty.rs +++ b/src/groups/sovereignty.rs @@ -1,8 +1,166 @@ -#![allow(unused)] - use crate::prelude::*; /// Endpoints for Sovereignty pub struct SovereigntyGroup<'a> { pub(crate) esi: &'a Esi, } + +/// A participant in a sovereignty campaign. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct CampaignParticipant { + pub alliance_id: i64, + pub score: f64, +} + +/// The kind of structure a sovereignty campaign is about. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CampaignEventType { + /// Defense of a territorial claim unit. + TcuDefense, + /// Defense of an infrastructure hub. + IhubDefense, + /// Defense of a station. + StationDefense, + /// Freeport of a station. + StationFreeport, + /// A type this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// An active sovereignty campaign. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct SovereigntyCampaign { + pub attackers_score: Option, + pub campaign_id: i64, + pub constellation_id: i64, + pub defender_id: Option, + pub defender_score: Option, + pub event_type: CampaignEventType, + pub participants: Option>, + pub solar_system_id: i64, + pub start_time: String, + pub structure_id: i64, +} + +/// The window in which a sovereignty hub can be attacked. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct VulnerabilityWindow { + pub end: String, + pub start: String, +} + +/// The sovereignty hub of a claimed system. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct SovereigntyHub { + pub id: i64, + pub vulnerability_window: Option, +} + +/// Development levels of a claimed system. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct SystemDevelopment { + pub activity_defense_multiplier: f64, + pub industrial_level: i64, + pub military_level: i64, + pub strategic_level: i64, +} + +/// A claim held by an alliance. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct AllianceClaim { + pub alliance_id: i64, + pub claimed_since: String, + pub corporation_id: i64, + pub development: SystemDevelopment, + pub is_capital_system: bool, + pub sovereignty_hub: SovereigntyHub, +} + +/// A claim held by a faction. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct FactionClaim { + pub faction_id: i64, +} + +/// Who holds a solar system. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(untagged)] +#[allow(missing_docs)] +pub enum SovereigntyClaim { + Faction { faction: FactionClaim }, + Alliance { alliance: AllianceClaim }, + Unclaimed { unclaimed: bool }, +} + +/// Sovereignty state of one solar system. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct SovereigntySystem { + pub claim: SovereigntyClaim, + pub solar_system_id: i64, +} + +#[derive(Debug, Deserialize)] +struct SovereigntySystems { + solar_systems: Vec, +} + +impl SovereigntyGroup<'_> { + api_get!( + /// List active sovereignty campaigns. + get_campaigns, + "GetSovereigntyCampaigns", + RequestType::Public, + Vec, + ); + + /// List the sovereignty state of every solar system. + pub async fn get_systems(&self) -> EsiResult> { + let path = self.esi.get_endpoint_for_op_id("GetSovereigntySystems")?; + let wrapper: SovereigntySystems = self + .esi + .query("GET", RequestType::Public, &path, None, None) + .await?; + Ok(wrapper.solar_systems) + } +} + +#[cfg(test)] +mod tests { + use super::{SovereigntyClaim, SovereigntySystem}; + + #[test] + fn test_parse_each_claim_kind() { + let faction: SovereigntySystem = serde_json::from_str( + r#"{"solar_system_id":1,"claim":{"faction":{"faction_id":500001}}}"#, + ) + .unwrap(); + assert!(matches!(faction.claim, SovereigntyClaim::Faction { .. })); + + let none: SovereigntySystem = + serde_json::from_str(r#"{"solar_system_id":2,"claim":{"unclaimed":true}}"#).unwrap(); + assert!(matches!( + none.claim, + SovereigntyClaim::Unclaimed { unclaimed: true } + )); + + let alliance: SovereigntySystem = serde_json::from_str( + r#"{"solar_system_id":3,"claim":{"alliance":{ + "alliance_id":1,"corporation_id":2,"claimed_since":"2026-01-01T00:00:00Z", + "is_capital_system":false, + "development":{"activity_defense_multiplier":1.0,"industrial_level":1,"military_level":2,"strategic_level":3}, + "sovereignty_hub":{"id":9}}}}"#, + ) + .unwrap(); + assert!(matches!(alliance.claim, SovereigntyClaim::Alliance { .. })); + } +} diff --git a/src/groups/status.rs b/src/groups/status.rs index b5a4e73..5e969e1 100644 --- a/src/groups/status.rs +++ b/src/groups/status.rs @@ -1,8 +1,39 @@ -#![allow(unused)] - use crate::prelude::*; /// Endpoints for Status pub struct StatusGroup<'a> { pub(crate) esi: &'a Esi, } + +/// Current state of the game server. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct ServerStatus { + pub players: i64, + pub server_version: String, + pub start_time: String, + pub vip: bool, +} + +impl StatusGroup<'_> { + api_get!( + /// Retrieve the uptime and player counts of the game server. + get_status, + "GetStatus", + RequestType::Public, + ServerStatus, + ); +} + +#[cfg(test)] +mod tests { + use super::ServerStatus; + + #[test] + fn test_parse_server_status() { + let json = r#"{"players":12345,"server_version":"2951234","start_time":"2026-10-07T11:00:00Z","vip":false}"#; + let status: ServerStatus = serde_json::from_str(json).unwrap(); + assert_eq!(status.players, 12345); + assert!(!status.vip); + } +} diff --git a/src/groups/structures.rs b/src/groups/structures.rs new file mode 100644 index 0000000..fc3368d --- /dev/null +++ b/src/groups/structures.rs @@ -0,0 +1,472 @@ +use crate::prelude::*; + +/// Endpoints for Structures +pub struct StructuresGroup<'a> { + pub(crate) esi: &'a Esi, +} + +/// A mercenary den owned by a character. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct MercenaryDenSummary { + /// ID of the mercenary den. + pub id: i64, + /// Planet the mercenary den is on. + pub planet_id: i64, +} + +#[derive(Debug, Deserialize)] +struct MercenaryDens { + mercenary_dens: Vec, +} + +/// The level of a mercenary den evolution track. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum EvolutionLevel { + /// Not specified. + Unspecified, + /// Level 0. + Level0, + /// Level 1. + Level1, + /// Level 2. + Level2, + /// Level 3. + Level3, + /// Level 4. + Level4, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// Progress on one evolution track of a mercenary den. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct EvolutionTrack { + /// Amount accumulated. + pub amount: i64, + /// Current level. + pub level: EvolutionLevel, +} + +/// The evolution tracks of a mercenary den. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct MercenaryDenEvolution { + /// Anarchy track. + pub anarchy: EvolutionTrack, + /// Development track. + pub development: EvolutionTrack, +} + +/// Infomorphs stored in a mercenary den. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Infomorphs { + /// Amount stored. + pub amount: i64, +} + +/// A timer that has an end date. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct EndTimer { + /// When the timer ends. + pub end: String, +} + +/// The skyhook a mercenary den is linked to. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct DenSkyhook { + /// Corporation that owns the skyhook. + pub corporation_id: i64, + /// ID of the skyhook. + pub id: i64, + /// Planet the skyhook is on. + pub planet_id: i64, +} + +/// The state of a mercenary den. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum MercenaryDenState { + /// Not specified. + Unspecified, + /// Running. + Running, + /// Paused. + Paused, + /// Disabled. + Disabled, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// The details of a mercenary den. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct MercenaryDen { + /// Evolution tracks. + pub evolution: MercenaryDenEvolution, + /// ID of the mercenary den. + pub id: i64, + /// Infomorphs stored. + pub infomorphs: Infomorphs, + /// Reinforcement timer, if reinforced. + pub reinforcement_timer: Option, + /// Linked skyhook. + pub skyhook: DenSkyhook, + /// State of the mercenary den. + pub state: MercenaryDenState, + /// Type ID of the mercenary den. + pub type_id: i64, +} + +/// A skyhook owned by a corporation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct SkyhookSummary { + /// ID of the skyhook. + pub id: i64, + /// Planet the skyhook is on. + pub planet_id: i64, +} + +#[derive(Debug, Deserialize)] +struct Skyhooks { + skyhooks: Vec, +} + +/// The state of a skyhook. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum SkyhookState { + /// Not specified. + Unspecified, + /// Shield vulnerable. + ShieldVulnerable, + /// Armor reinforced. + ArmorReinforced, + /// Armor vulnerable. + ArmorVulnerable, + /// Hull reinforced. + HullReinforced, + /// Hull vulnerable. + HullVulnerable, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// Reagent stock in a skyhook. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct SkyhookReagent { + /// Last time the reagent was cycled. + pub last_cycle: String, + /// Secured stock. + pub secured_stock: i64, + /// Type ID of the reagent. + pub type_id: i64, + /// Unsecured stock. + pub unsecured_stock: i64, +} + +/// A time window. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TimeWindow { + /// Start of the window. + pub start: String, + /// End of the window. + pub end: String, +} + +/// The details of a skyhook. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Skyhook { + /// Effective workforce. + pub effective_workforce: Option, + /// ID of the skyhook. + pub id: i64, + /// Whether the skyhook is active. + pub is_active: bool, + /// Planet the skyhook is on. + pub planet_id: i64, + /// Reagent stock. + #[serde(default)] + pub reagents: Vec, + /// Reinforcement timer, if reinforced. + pub reinforcement_timer: Option, + /// State of the skyhook. + pub state: SkyhookState, + /// Window in which the contents can be stolen. + pub theft_vulnerability: Option, +} + +/// A sovereignty hub owned by a corporation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct SovereigntyHubSummary { + /// ID of the hub. + pub id: i64, + /// Solar system of the hub. + pub solar_system_id: i64, +} + +#[derive(Debug, Deserialize)] +struct SovereigntyHubs { + sovereignty_hubs: Vec, +} + +/// Reagent burned by a sovereignty hub. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct HubReagent { + /// Amount in the bay. + pub amount: i64, + /// Amount burned per hour. + pub burning_per_hour: i64, + /// Type ID of the reagent. + pub type_id: i64, +} + +/// The reagent bay of a sovereignty hub. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ReagentBay { + /// When the bay was last updated. + pub last_updated: String, + /// Reagents in the bay. + pub reagents: Vec, +} + +/// Allocated and available amount of a resource. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ResourceUsage { + /// Amount allocated. + pub allocated: i64, + /// Amount available. + pub available: i64, +} + +/// Power and workforce of a sovereignty hub. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct HubResources { + /// Power usage. + pub power: ResourceUsage, + /// Workforce usage. + pub workforce: ResourceUsage, +} + +/// The power state of a hub upgrade. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum UpgradePowerState { + /// Not specified. + Unspecified, + /// Online. + Online, + /// Offline. + Offline, + /// Low power. + Low, + /// Pending. + Pending, + /// A value this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// An upgrade installed in a sovereignty hub. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct HubUpgrade { + /// Power state of the upgrade. + pub power_state: UpgradePowerState, + /// Type ID of the upgrade. + pub type_id: i64, +} + +/// A solar system and an amount of workforce. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct WorkforceFlow { + /// Amount of workforce. + pub amount: Option, + /// Solar system the workforce goes to or comes from. + pub solar_system_id: Option, +} + +/// The workforce transport of a sovereignty hub, as configured or as running. +/// +/// Exactly one of the fields is set. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct WorkforceTransportMode { + /// Workforce is exported. + pub export: Option, + /// Workforce is imported from the listed systems. + pub import: Option, + /// Workforce is in transit. + pub transit: Option, +} + +/// Workforce import sources. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct WorkforceImport { + /// Systems workforce is imported from. + pub sources: Vec, +} + +/// The workforce transport of a sovereignty hub. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct WorkforceTransport { + /// Configured mode. + pub configuration: WorkforceTransportMode, + /// Mode currently running. + pub state: WorkforceTransportMode, +} + +/// The details of a sovereignty hub. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct CorporationSovereigntyHub { + /// Access list that controls fuel access. + pub fuel_access_list_id: Option, + /// ID of the hub. + pub id: i64, + /// Reagent bay. + pub reagent_bay: ReagentBay, + /// Power and workforce. + pub resources: HubResources, + /// Solar system of the hub. + pub solar_system_id: i64, + /// Installed upgrades. + pub upgrades: Vec, + /// Vulnerability window. + pub vulnerability_window: Option, + /// Workforce transport. + pub workforce_transport: WorkforceTransport, +} + +impl StructuresGroup<'_> { + /// List the mercenary dens of a character. + pub async fn get_character_mercenary_dens( + &self, + character_id: i64, + ) -> EsiResult> { + let path = self + .esi + .get_endpoint_for_op_id("GetCharactersStructuresMercenaryDensListing")? + .replace("{character_id}", &character_id.to_string()); + let wrapper: MercenaryDens = self + .esi + .query("GET", RequestType::Authenticated, &path, None, None) + .await?; + Ok(wrapper.mercenary_dens) + } + + api_get!( + /// Get the details of a mercenary den of a character. + get_character_mercenary_den, + "GetCharactersStructuresMercenaryDensDetail", + RequestType::Authenticated, + MercenaryDen, + (character_id: i64) => "{character_id}", + (mercenary_den_id: i64) => "{mercenary_den_id}" + ); + + /// List the skyhooks of a corporation. + /// + /// Requires the Station Manager role. + pub async fn get_corporation_skyhooks( + &self, + corporation_id: i64, + ) -> EsiResult> { + let path = self + .esi + .get_endpoint_for_op_id("GetCorporationsStructuresSkyhooksListing")? + .replace("{corporation_id}", &corporation_id.to_string()); + let wrapper: Skyhooks = self + .esi + .query("GET", RequestType::Authenticated, &path, None, None) + .await?; + Ok(wrapper.skyhooks) + } + + api_get!( + /// Get the details of a skyhook of a corporation. + /// + /// Requires the Station Manager role. + get_corporation_skyhook, + "GetCorporationsStructuresSkyhooksDetail", + RequestType::Authenticated, + Skyhook, + (corporation_id: i64) => "{corporation_id}", + (skyhook_id: i64) => "{skyhook_id}" + ); + + /// List the sovereignty hubs of a corporation. + pub async fn get_corporation_sovereignty_hubs( + &self, + corporation_id: i64, + ) -> EsiResult> { + let path = self + .esi + .get_endpoint_for_op_id("GetCorporationsStructuresSovereigntyHubsListing")? + .replace("{corporation_id}", &corporation_id.to_string()); + let wrapper: SovereigntyHubs = self + .esi + .query("GET", RequestType::Authenticated, &path, None, None) + .await?; + Ok(wrapper.sovereignty_hubs) + } + + api_get!( + /// Get the details of a sovereignty hub of a corporation. + /// + /// Requires the Station Manager role. + get_corporation_sovereignty_hub, + "GetCorporationsStructuresSovereigntyHubsDetail", + RequestType::Authenticated, + CorporationSovereigntyHub, + (corporation_id: i64) => "{corporation_id}", + (sovereignty_hub_id: i64) => "{sovereignty_hub_id}" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_mercenary_den() { + let json = r#"{"evolution": {"anarchy": {"amount": 5, "level": "Level2"}, + "development": {"amount": 1, "level": "Level9"}}, + "id": 1040000000000, "infomorphs": {"amount": 10}, + "skyhook": {"corporation_id": 98000001, "id": 2, "planet_id": 40000001}, + "state": "Running", "type_id": 85230}"#; + let den: MercenaryDen = serde_json::from_str(json).unwrap(); + assert_eq!(den.id, 1_040_000_000_000); + assert_eq!(den.evolution.anarchy.level, EvolutionLevel::Level2); + assert_eq!( + den.evolution.development.level, + EvolutionLevel::Unrecognized + ); + assert!(den.reinforcement_timer.is_none()); + } + + #[test] + fn test_parse_skyhook() { + let json = r#"{"id": 1, "is_active": true, "planet_id": 2, "state": "ArmorReinforced", + "reagents": [{"last_cycle": "t", "secured_stock": 1, "type_id": 81143, "unsecured_stock": 2}], + "theft_vulnerability": {"start": "a", "end": "b"}}"#; + let hook: Skyhook = serde_json::from_str(json).unwrap(); + assert_eq!(hook.state, SkyhookState::ArmorReinforced); + assert_eq!(hook.reagents.len(), 1); + assert!(hook.effective_workforce.is_none()); + } + + #[test] + fn test_parse_sovereignty_hub() { + let json = r#"{"id": 1040000000001, "solar_system_id": 30000142, + "reagent_bay": {"last_updated": "t", "reagents": [{"amount": 1, "burning_per_hour": 2, "type_id": 81144}]}, + "resources": {"power": {"allocated": 1, "available": 2}, "workforce": {"allocated": 3, "available": 4}}, + "upgrades": [{"power_state": "Low", "type_id": 81615}], + "workforce_transport": { + "configuration": {"import": {"sources": [{"solar_system_id": 30000143}]}}, + "state": {"transit": true}}}"#; + let hub: CorporationSovereigntyHub = serde_json::from_str(json).unwrap(); + assert_eq!(hub.upgrades[0].power_state, UpgradePowerState::Low); + let cfg = &hub.workforce_transport.configuration; + assert_eq!( + cfg.import.as_ref().unwrap().sources[0].solar_system_id, + Some(30000143) + ); + assert_eq!(hub.workforce_transport.state.transit, Some(true)); + } +} diff --git a/src/groups/universe.rs b/src/groups/universe.rs index 8d19036..9b41afe 100644 --- a/src/groups/universe.rs +++ b/src/groups/universe.rs @@ -7,7 +7,8 @@ pub struct UniverseGroup<'a> { pub(crate) esi: &'a Esi, } -#[derive(Debug, Deserialize, Serialize, Clone)] +/// A position in space, in meters. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] #[allow(missing_docs)] pub struct Position { pub x: f64, @@ -18,43 +19,43 @@ pub struct Position { #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct Constellation { - pub constellation_id: i32, + pub constellation_id: i64, pub name: String, pub position: Position, - pub region_id: i32, - pub systems: Vec, + pub region_id: i64, + pub systems: Vec, } #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct Region { - pub constellations: Vec, + pub constellations: Vec, pub description: Option, pub name: String, - pub region_id: i32, + pub region_id: i64, } #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct SystemPlanet { - pub asteroid_belts: Option>, - pub moons: Option>, - pub planet_id: i32, + pub asteroid_belts: Option>, + pub moons: Option>, + pub planet_id: i64, } #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct System { - pub constellation_id: i32, + pub constellation_id: i64, pub name: String, pub planets: Option>, pub position: Position, pub security_class: Option, pub security_status: f64, - pub star_id: Option, - pub stargates: Option>, - pub stations: Option>, - pub system_id: i32, + pub star_id: Option, + pub stargates: Option>, + pub stations: Option>, + pub system_id: i64, } #[derive(Debug, Deserialize)] @@ -72,21 +73,21 @@ pub struct Ids { #[derive(Debug, Deserialize)] #[allow(missing_docs)] pub struct Category { - pub id: i32, + pub id: i64, pub name: String, } #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct TypeDogmaAttribute { - pub attribute_id: i32, + pub attribute_id: i64, pub value: f64, } #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct TypeDogmaEffect { - pub effect_id: i32, + pub effect_id: i64, pub is_default: bool, } @@ -97,17 +98,17 @@ pub struct Type { pub description: String, pub dogma_attributes: Option>, pub dogma_effects: Option>, - pub graphic_id: Option, - pub group_id: i32, - pub icon_id: Option, - pub market_group_id: Option, + pub graphic_id: Option, + pub group_id: i64, + pub icon_id: Option, + pub market_group_id: Option, pub mass: Option, pub name: String, pub packaged_volume: Option, - pub portion_size: Option, + pub portion_size: Option, pub published: bool, pub radius: Option, - pub type_id: i32, + pub type_id: i64, pub volume: Option, } @@ -117,32 +118,32 @@ pub struct Station { pub max_dockable_ship_volume: f64, pub name: String, pub office_rental_cost: f64, - pub owner: Option, + pub owner: Option, pub position: Position, - pub race_id: Option, + pub race_id: Option, pub reprocessing_efficiency: f64, pub reprocessing_stations_take: f64, pub services: Vec, - pub station_id: i32, - pub system_id: i32, - pub type_id: i32, + pub station_id: i64, + pub system_id: i64, + pub type_id: i64, } #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct Structure { pub name: String, - pub owner_id: i32, + pub owner_id: i64, pub position: Option, - pub solar_system_id: i32, - pub type_id: Option, + pub solar_system_id: i64, + pub type_id: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct CategoriesCategory { - pub category_id: i32, - pub groups: Vec, + pub category_id: i64, + pub groups: Vec, pub name: String, pub published: bool, } @@ -150,11 +151,11 @@ pub struct CategoriesCategory { #[derive(Debug, Deserialize, Serialize, Clone)] #[allow(missing_docs)] pub struct Group { - pub category_id: i32, - pub group_id: i32, + pub category_id: i64, + pub group_id: i64, pub name: String, pub published: bool, - pub types: Vec, + pub types: Vec, } impl UniverseGroup<'_> { @@ -164,7 +165,7 @@ impl UniverseGroup<'_> { "GetUniverseCategoriesCategoryId", RequestType::Public, CategoriesCategory, - (category_id: i32) => "{category_id}" + (category_id: i64) => "{category_id}" ); api_get!( @@ -173,7 +174,7 @@ impl UniverseGroup<'_> { "GetUniverseGroupsGroupId", RequestType::Public, Group, - (group_id: i32) => "{group_id}" + (group_id: i64) => "{group_id}" ); api_get!( @@ -182,7 +183,7 @@ impl UniverseGroup<'_> { "GetUniverseTypesTypeId", RequestType::Public, Type, - (type_id: i32) => "{type_id}" + (type_id: i64) => "{type_id}" ); api_get!( @@ -190,7 +191,7 @@ impl UniverseGroup<'_> { get_constellation_ids, "GetUniverseConstellations", RequestType::Public, - Vec, + Vec, ); api_get!( @@ -199,7 +200,7 @@ impl UniverseGroup<'_> { "GetUniverseConstellationsConstellationId", RequestType::Public, Constellation, - (constellation_id: i32) => "{constellation_id}" + (constellation_id: i64) => "{constellation_id}" ); api_get!( @@ -207,7 +208,7 @@ impl UniverseGroup<'_> { get_region_ids, "GetUniverseRegions", RequestType::Public, - Vec, + Vec, ); api_get!( @@ -216,7 +217,7 @@ impl UniverseGroup<'_> { "GetUniverseRegionsRegionId", RequestType::Public, Region, - (region_id: i32) => "{region_id}" + (region_id: i64) => "{region_id}" ); api_get!( @@ -224,7 +225,7 @@ impl UniverseGroup<'_> { get_system_ids, "GetUniverseSystems", RequestType::Public, - Vec, + Vec, ); api_get!( @@ -233,7 +234,7 @@ impl UniverseGroup<'_> { "GetUniverseSystemsSystemId", RequestType::Public, System, - (system_id: i32) => "{system_id}" + (system_id: i64) => "{system_id}" ); api_get!( @@ -241,7 +242,7 @@ impl UniverseGroup<'_> { get_type_ids, "GetUniverseTypes", RequestType::Public, - Vec, + Vec, ); api_get!( @@ -250,7 +251,7 @@ impl UniverseGroup<'_> { "GetUniverseTypesTypeId", RequestType::Public, Type, - (type_id: i32) => "{type_id}" + (type_id: i64) => "{type_id}" ); api_get!( @@ -259,7 +260,7 @@ impl UniverseGroup<'_> { "GetUniverseStationsStationId", RequestType::Public, Station, - (station_id: i32) => "{station_id}" + (station_id: i64) => "{station_id}" ); api_get!( @@ -281,3 +282,392 @@ impl UniverseGroup<'_> { names: &[&str], ); } + +/// A character ancestry. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct Ancestry { + pub bloodline_id: i64, + pub description: String, + pub icon_id: Option, + pub id: i64, + pub name: String, + pub short_description: Option, +} + +/// An asteroid belt. +#[derive(Debug, Clone, Deserialize)] +#[allow(missing_docs)] +pub struct AsteroidBelt { + pub name: String, + pub position: Position, + pub system_id: i64, +} + +/// A character bloodline. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct Bloodline { + pub bloodline_id: i64, + pub charisma: i64, + pub corporation_id: i64, + pub description: String, + pub intelligence: i64, + pub memory: i64, + pub name: String, + pub perception: i64, + pub race_id: i64, + pub ship_type_id: i64, + pub willpower: i64, +} + +/// An NPC faction. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct UniverseFaction { + pub corporation_id: Option, + pub description: String, + pub faction_id: i64, + pub is_unique: bool, + pub militia_corporation_id: Option, + pub name: String, + pub size_factor: f64, + pub solar_system_id: Option, + pub station_count: i64, + pub station_system_count: i64, +} + +/// Rendering information of a graphic. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct Graphic { + pub collision_file: Option, + pub graphic_file: Option, + pub graphic_id: i64, + pub icon_folder: Option, + pub sof_dna: Option, + /// Named `sof_fation_name` (sic) in the ESI spec. + pub sof_fation_name: Option, + pub sof_hull_name: Option, + pub sof_race_name: Option, +} + +/// A moon. +#[derive(Debug, Clone, Deserialize)] +#[allow(missing_docs)] +pub struct Moon { + pub moon_id: i64, + pub name: String, + pub position: Position, + pub system_id: i64, +} + +/// A planet. +#[derive(Debug, Clone, Deserialize)] +#[allow(missing_docs)] +pub struct PlanetInfo { + pub name: String, + pub planet_id: i64, + pub position: Position, + pub system_id: i64, + pub type_id: i64, +} + +/// A playable race. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct Race { + pub alliance_id: i64, + pub description: String, + pub name: String, + pub race_id: i64, +} + +/// The other end of a stargate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct StargateDestination { + pub stargate_id: i64, + pub system_id: i64, +} + +/// A stargate. +#[derive(Debug, Clone, Deserialize)] +#[allow(missing_docs)] +pub struct Stargate { + pub destination: StargateDestination, + pub name: String, + pub position: Position, + pub stargate_id: i64, + pub system_id: i64, + pub type_id: i64, +} + +/// A star. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct Star { + pub age: i64, + pub luminosity: f64, + pub name: String, + pub radius: i64, + pub solar_system_id: i64, + /// Spectral class such as `K2 V`, as published by ESI. + pub spectral_class: String, + pub temperature: i64, + pub type_id: i64, +} + +/// Jumps through a system in the last hour. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct SystemJumps { + pub ship_jumps: i64, + pub system_id: i64, +} + +/// Kills in a system in the last hour. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct SystemKills { + pub npc_kills: i64, + pub pod_kills: i64, + pub ship_kills: i64, + pub system_id: i64, +} + +/// Which public structures to list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StructureFilter { + /// Structures with a market. + Market, + /// Structures with basic manufacturing. + ManufacturingBasic, +} + +impl std::fmt::Display for StructureFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + StructureFilter::Market => "market", + StructureFilter::ManufacturingBasic => "manufacturing_basic", + }) + } +} + +/// The kind of entity a resolved name belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NameCategory { + /// An alliance. + Alliance, + /// A character. + Character, + /// A constellation. + Constellation, + /// A corporation. + Corporation, + /// An inventory type. + InventoryType, + /// A region. + Region, + /// A solar system. + SolarSystem, + /// A station. + Station, + /// A faction. + Faction, + /// A category this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// A name resolved from an ID. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[allow(missing_docs)] +pub struct ResolvedName { + pub category: NameCategory, + pub id: i64, + pub name: String, +} + +impl UniverseGroup<'_> { + api_get!( + /// List the character ancestries. + get_ancestries, + "GetUniverseAncestries", + RequestType::Public, + Vec, + ); + + api_get!( + /// Get information about an asteroid belt. + get_asteroid_belt, + "GetUniverseAsteroidBeltsAsteroidBeltId", + RequestType::Public, + AsteroidBelt, + (asteroid_belt_id: i64) => "{asteroid_belt_id}" + ); + + api_get!( + /// List the character bloodlines. + get_bloodlines, + "GetUniverseBloodlines", + RequestType::Public, + Vec, + ); + + api_get!( + /// List the IDs of all item categories. + get_category_ids, + "GetUniverseCategories", + RequestType::Public, + Vec, + ); + + api_get!( + /// List the NPC factions. + get_factions, + "GetUniverseFactions", + RequestType::Public, + Vec, + ); + + api_get!( + /// List the IDs of all graphics. + get_graphic_ids, + "GetUniverseGraphics", + RequestType::Public, + Vec, + ); + + api_get!( + /// Get information about a graphic. + get_graphic, + "GetUniverseGraphicsGraphicId", + RequestType::Public, + Graphic, + (graphic_id: i64) => "{graphic_id}" + ); + + api_get!( + /// List the IDs of all item groups. + get_group_ids, + "GetUniverseGroups", + RequestType::Public, + Vec, + ; + Optional(page: i32) => "page" + ); + + api_get!( + /// Get information about a moon. + get_moon, + "GetUniverseMoonsMoonId", + RequestType::Public, + Moon, + (moon_id: i64) => "{moon_id}" + ); + + api_get!( + /// Get information about a planet. + get_planet, + "GetUniversePlanetsPlanetId", + RequestType::Public, + PlanetInfo, + (planet_id: i64) => "{planet_id}" + ); + + api_get!( + /// List the playable races. + get_races, + "GetUniverseRaces", + RequestType::Public, + Vec, + ); + + api_get!( + /// Get information about a stargate. + get_stargate, + "GetUniverseStargatesStargateId", + RequestType::Public, + Stargate, + (stargate_id: i64) => "{stargate_id}" + ); + + api_get!( + /// Get information about a star. + get_star, + "GetUniverseStarsStarId", + RequestType::Public, + Star, + (star_id: i64) => "{star_id}" + ); + + api_get!( + /// List the IDs of public structures, optionally filtered. + get_structure_ids, + "GetUniverseStructures", + RequestType::Public, + Vec, + ; + Optional(filter: StructureFilter) => "filter" + ); + + api_get!( + /// List the number of ship jumps per system in the last hour. + get_system_jumps, + "GetUniverseSystemJumps", + RequestType::Public, + Vec, + ); + + api_get!( + /// List the number of kills per system in the last hour. + get_system_kills, + "GetUniverseSystemKills", + RequestType::Public, + Vec, + ); + + api_post!( + /// Resolve a list of IDs to names and categories. + get_names, + "PostUniverseNames", + RequestType::Public, + Vec, + ; Chunked(ids: &[i64], 1000) + ); +} + +#[cfg(test)] +mod new_endpoint_tests { + use super::{NameCategory, ResolvedName, Star, StructureFilter}; + + #[test] + fn test_parse_star_with_large_values() { + let json = r#"{"age": 4500999969, "luminosity": 0.5, "name": "Jita - Star", + "radius": 2284000000, "solar_system_id": 30000142, + "spectral_class": "K2 V", "temperature": 4567, "type_id": 45041}"#; + let star: Star = serde_json::from_str(json).unwrap(); + assert_eq!(star.radius, 2_284_000_000); + assert_eq!(star.spectral_class, "K2 V"); + } + + #[test] + fn test_parse_resolved_names() { + let json = r#"[{"category": "inventory_type", "id": 34, "name": "Tritanium"}, + {"category": "something_new", "id": 1, "name": "?"}]"#; + let names: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(names[0].category, NameCategory::InventoryType); + assert_eq!(names[1].category, NameCategory::Unrecognized); + } + + #[test] + fn test_structure_filter_query_value() { + assert_eq!(StructureFilter::Market.to_string(), "market"); + assert_eq!( + StructureFilter::ManufacturingBasic.to_string(), + "manufacturing_basic" + ); + } +} diff --git a/src/groups/user_interface.rs b/src/groups/user_interface.rs index 727b1b3..51336f7 100644 --- a/src/groups/user_interface.rs +++ b/src/groups/user_interface.rs @@ -7,7 +7,7 @@ pub struct UserInterfaceGroup<'a> { impl UserInterfaceGroup<'_> { /// Open the market details window. - pub async fn open_market_details_window(&self, type_id: i32) -> EsiResult<()> { + pub async fn open_market_details_window(&self, type_id: i64) -> EsiResult<()> { // not using the macro since it doesn't like no body; // `type_id` is a query parameter, not part of the path let path = self @@ -25,3 +25,110 @@ impl UserInterfaceGroup<'_> { .await } } + +/// A new mail to pre-fill in the in-game mail window. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct NewMailWindow { + /// Body of the mail. + pub body: String, + /// Character IDs of the recipients. + pub recipients: Vec, + /// Subject of the mail. + pub subject: String, + /// Corporation or alliance ID to send the mail to. + #[serde(skip_serializing_if = "Option::is_none")] + pub to_corp_or_alliance_id: Option, + /// Mailing list ID to send the mail to. + #[serde(skip_serializing_if = "Option::is_none")] + pub to_mailing_list_id: Option, +} + +impl UserInterfaceGroup<'_> { + /// Set a solar system, station or structure as an autopilot waypoint in the client. + pub async fn set_autopilot_waypoint( + &self, + destination_id: i64, + add_to_beginning: bool, + clear_other_waypoints: bool, + ) -> EsiResult<()> { + let path = self.esi.get_endpoint_for_op_id("PostUiAutopilotWaypoint")?; + let destination_id = destination_id.to_string(); + let add_to_beginning = add_to_beginning.to_string(); + let clear_other_waypoints = clear_other_waypoints.to_string(); + self.esi + .query( + "POST", + RequestType::Authenticated, + &path, + Some(&[ + ("add_to_beginning", add_to_beginning.as_str()), + ("clear_other_waypoints", clear_other_waypoints.as_str()), + ("destination_id", destination_id.as_str()), + ]), + None, + ) + .await + } + + /// Open the contract window in the client. + pub async fn open_contract_window(&self, contract_id: i64) -> EsiResult<()> { + let path = self + .esi + .get_endpoint_for_op_id("PostUiOpenwindowContract")?; + let contract_id = contract_id.to_string(); + self.esi + .query( + "POST", + RequestType::Authenticated, + &path, + Some(&[("contract_id", contract_id.as_str())]), + None, + ) + .await + } + + /// Open the information window of a character, corporation or alliance in the client. + pub async fn open_information_window(&self, target_id: i64) -> EsiResult<()> { + let path = self + .esi + .get_endpoint_for_op_id("PostUiOpenwindowInformation")?; + let target_id = target_id.to_string(); + self.esi + .query( + "POST", + RequestType::Authenticated, + &path, + Some(&[("target_id", target_id.as_str())]), + None, + ) + .await + } + + /// Open the new mail window in the client with the given content. + pub async fn open_new_mail_window(&self, mail: &NewMailWindow) -> EsiResult<()> { + let path = self.esi.get_endpoint_for_op_id("PostUiOpenwindowNewmail")?; + let body = serde_json::to_string(mail)?; + self.esi + .query("POST", RequestType::Authenticated, &path, None, Some(&body)) + .await + } +} + +#[cfg(test)] +mod new_mail_tests { + use super::*; + + #[test] + fn test_serialize_new_mail_window() { + let mail = NewMailWindow { + body: "b".into(), + recipients: vec![1, 2], + subject: "s".into(), + to_corp_or_alliance_id: None, + to_mailing_list_id: Some(5), + }; + let json = serde_json::to_string(&mail).unwrap(); + assert!(json.contains("\"to_mailing_list_id\":5")); + assert!(!json.contains("to_corp_or_alliance_id")); + } +} diff --git a/src/groups/wallet.rs b/src/groups/wallet.rs index a54b084..58906cb 100644 --- a/src/groups/wallet.rs +++ b/src/groups/wallet.rs @@ -14,6 +14,156 @@ impl WalletGroup<'_> { "GetCharactersCharacterIdWallet", RequestType::Authenticated, f64, - (character_id: i32) => "{character_id}" + (character_id: i64) => "{character_id}" + ); +} + +/// What the context ID of a wallet journal entry refers to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JournalContextType { + /// A structure ID. + StructureId, + /// A station ID. + StationId, + /// A market transaction ID. + MarketTransactionId, + /// A character ID. + CharacterId, + /// A corporation ID. + CorporationId, + /// An alliance ID. + AllianceId, + /// An EVE system. + EveSystem, + /// An industry job ID. + IndustryJobId, + /// A contract ID. + ContractId, + /// A planet ID. + PlanetId, + /// A solar system ID. + SystemId, + /// A type ID. + TypeId, + /// A kind this version of the crate does not know about. + #[serde(other)] + Unrecognized, +} + +/// An entry of a wallet journal. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct JournalEntry { + pub amount: Option, + pub balance: Option, + pub context_id: Option, + pub context_id_type: Option, + pub date: String, + pub description: String, + pub first_party_id: Option, + pub id: i64, + pub reason: Option, + /// The kind of transaction, such as `bounty_prizes` or `player_trading`. + /// ESI defines more than 160 values, so it is kept as a string. + pub ref_type: String, + pub second_party_id: Option, + pub tax: Option, + pub tax_receiver_id: Option, +} + +impl WalletGroup<'_> { + api_get!( + /// Get a character's wallet journal for the last 30 days. + get_journal, + "GetCharactersCharacterIdWalletJournal", + RequestType::Authenticated, + Vec, + (character_id: i64) => "{character_id}"; + Optional(page: i32) => "page" + ); +} + +#[cfg(test)] +mod character_basic_tests { + use super::{JournalContextType, JournalEntry}; + + #[test] + fn test_parse_journal_entries() { + let json = r#"[ + {"id": 9000000000, "date": "2026-10-01T00:00:00Z", "description": "Bounty", + "ref_type": "bounty_prizes", "amount": 1500.5, "balance": 100.0, + "context_id": 30000142, "context_id_type": "system_id", "first_party_id": 2112625428}, + {"id": 2, "date": "2026-10-01T00:00:00Z", "description": "?", "ref_type": "new_kind", + "context_id_type": "something_new"} + ]"#; + let entries: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(entries[0].id, 9_000_000_000); + assert_eq!( + entries[0].context_id_type, + Some(JournalContextType::SystemId) + ); + assert_eq!( + entries[1].context_id_type, + Some(JournalContextType::Unrecognized) + ); + assert!(entries[1].amount.is_none()); + } +} + +/// The balance of one corporation wallet division. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct DivisionBalance { + pub balance: f64, + pub division: i64, +} + +/// A market transaction of a corporation wallet division. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[allow(missing_docs)] +pub struct CorporationWalletTransaction { + pub client_id: i64, + pub date: String, + pub is_buy: bool, + pub journal_ref_id: i64, + pub location_id: i64, + pub quantity: i64, + pub transaction_id: i64, + pub type_id: i64, + pub unit_price: f64, +} + +impl WalletGroup<'_> { + api_get!( + /// Get the balance of every wallet division of a corporation. + get_corporation_wallets, + "GetCorporationsCorporationIdWallets", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}" + ); + + api_get!( + /// Get the journal of a corporation wallet division for the last 30 days. + get_corporation_journal, + "GetCorporationsCorporationIdWalletsDivisionJournal", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}", + (division: i64) => "{division}"; + Optional(page: i32) => "page" + ); + + api_get!( + /// Get the market transactions of a corporation wallet division. + /// Pass the lowest `transaction_id` of the previous page as `from_id` to continue. + get_corporation_transactions, + "GetCorporationsCorporationIdWalletsDivisionTransactions", + RequestType::Authenticated, + Vec, + (corporation_id: i64) => "{corporation_id}", + (division: i64) => "{division}"; + Optional(from_id: i64) => "from_id" ); } diff --git a/src/groups/wars.rs b/src/groups/wars.rs index 443e9a4..f7e485e 100644 --- a/src/groups/wars.rs +++ b/src/groups/wars.rs @@ -1,8 +1,100 @@ -#![allow(unused)] - use crate::prelude::*; /// Endpoints for Wars pub struct WarsGroup<'a> { pub(crate) esi: &'a Esi, } + +/// One side of a war. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct WarParty { + pub alliance_id: Option, + pub corporation_id: Option, + pub isk_destroyed: f64, + pub ships_killed: i64, +} + +/// An ally of one side of a war. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct WarAlly { + pub alliance_id: Option, + pub corporation_id: Option, +} + +/// Information about a war. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct War { + pub aggressor: WarParty, + pub allies: Option>, + pub declared: String, + pub defender: WarParty, + pub finished: Option, + pub id: i64, + pub mutual: bool, + pub open_for_allies: bool, + pub retracted: Option, + pub started: Option, +} + +/// A killmail reference belonging to a war. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[allow(missing_docs)] +pub struct WarKillmail { + pub killmail_hash: String, + pub killmail_id: i64, +} + +impl WarsGroup<'_> { + api_get!( + /// List war IDs, newest first. Pass `max_war_id` to start the list below that ID. + list, + "GetWars", + RequestType::Public, + Vec, + ; + Optional(max_war_id: i64) => "max_war_id" + ); + + api_get!( + /// Get information about a war. + get_war, + "GetWarsWarId", + RequestType::Public, + War, + (war_id: i64) => "{war_id}" + ); + + api_get!( + /// List the killmails of a war, newest first. + get_war_killmails, + "GetWarsWarIdKillmails", + RequestType::Public, + Vec, + (war_id: i64) => "{war_id}"; + Optional(page: i32) => "page" + ); +} + +#[cfg(test)] +mod tests { + use super::War; + + #[test] + fn test_parse_war_with_optional_fields_missing() { + let json = r#"{ + "id": 1, + "declared": "2026-01-01T00:00:00Z", + "mutual": false, + "open_for_allies": true, + "aggressor": {"corporation_id": 7, "isk_destroyed": 1.5, "ships_killed": 2}, + "defender": {"alliance_id": 9, "isk_destroyed": 0.0, "ships_killed": 0} + }"#; + let war: War = serde_json::from_str(json).unwrap(); + assert_eq!(war.aggressor.corporation_id, Some(7)); + assert!(war.finished.is_none()); + assert!(war.allies.is_none()); + } +} diff --git a/src/legacy.rs b/src/legacy.rs index 64dd1ac..c8e1bd5 100644 --- a/src/legacy.rs +++ b/src/legacy.rs @@ -199,6 +199,24 @@ mod tests { assert!(LEGACY_OP_IDS.windows(2).all(|w| w[0].0 < w[1].0)); } + #[test] + fn test_legacy_targets_exist_in_the_spec() { + let spec: crate::spec::Spec = + serde_json::from_str(include_str!("../resources/test/openapi.json")).unwrap(); + let index = spec.operation_index(); + let missing: Vec<&str> = LEGACY_OP_IDS + .iter() + .map(|(_, id)| *id) + .filter(|id| !index.contains_key(*id)) + .collect(); + assert!( + missing.is_empty(), + "legacy aliases to unknown operations: {missing:?}" + ); + // The table only covers operations rfesi had; the spec has many more. + assert!(LEGACY_OP_IDS.len() < index.len()); + } + #[test] fn test_legacy_lookup() { assert_eq!( diff --git a/src/lib.rs b/src/lib.rs index 802757a..c2dbf92 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -102,7 +102,9 @@ mod macros; mod builders; +mod cache; mod client; +mod cursor_page; mod errors; pub mod groups; #[cfg(feature = "validate_jwt")] @@ -110,4 +112,5 @@ mod jwt_util; mod legacy; mod pkce; pub mod prelude; +mod rate_limiter; pub mod spec; diff --git a/src/macros.rs b/src/macros.rs index 4f24bbf..e0400bb 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -55,7 +55,7 @@ /// "some_operation_id", /// RequestType::Public, /// Vec, -/// (alliance_id: u64) => "{alliance_id}" +/// (alliance_id: i64) => "{alliance_id}" /// ); /// /// } @@ -65,7 +65,7 @@ /// /// ```rust,ignore /// /// Docs for the generated function -/// pub async fn function_name(&self, alliance_id: u64) -> EsiResult> { +/// pub async fn function_name(&self, alliance_id: i64) -> EsiResult> { /// let path = self.esi.get_endpoint_for_op_id("some_operation_id")? /// .replace("{alliance_id}", &alliance_id.to_string()); /// self.esi @@ -125,6 +125,56 @@ /// .await /// } /// ``` +/// +/// # Extended query parameters +/// +/// `api_get!`, `api_post!`, `api_put!` and `api_delete!` also accept query +/// parameters tagged with a kind, after a `;` that follows the path parameters: +/// +/// - `Required(name: T)`: a required value, sent as `name=value` +/// - `Optional(name: T)`: the function takes an `Option` +/// - `Many(name: &[T])`: a required list, sent as repeated keys (`name=1&name=2`) +/// - `OptionalMany(name: &[T])`: the function takes an `Option<&[T]>` +/// +/// `api_post!` and `api_put!` take the body after a second `;`. +/// +/// # Example +/// +/// ```rust,no_run +/// # use esi_openapi::prelude::*; +/// # use esi_openapi::{api_get, api_post}; +/// pub struct SomeGroup<'a> { +/// pub(crate) esi: &'a Esi, +/// } +/// +/// impl SomeGroup<'_> { +/// +/// api_get!( +/// /// Docs for the generated function +/// list_things, +/// "some_operation_id", +/// RequestType::Authenticated, +/// Vec, +/// (character_id: i64) => "{character_id}"; +/// OptionalMany(labels: &[i64]) => "labels", +/// Optional(last_id: i64) => "last_id" +/// ); +/// +/// api_post!( +/// /// Docs for the generated function +/// add_things, +/// "some_other_operation_id", +/// RequestType::Authenticated, +/// Vec, +/// (character_id: i64) => "{character_id}"; +/// Required(standing: f64) => "standing", +/// Optional(watched: bool) => "watched"; +/// ids: &[i64] +/// ); +/// +/// } +/// # fn main() {} +/// ``` #[macro_export] macro_rules! api_get { ( @@ -190,6 +240,114 @@ macro_rules! api_get { .await } }; + ( + $(#[$m:meta])* + $fn_name:ident, + $op_id:literal, + $visibility:expr, + $ret_type:ty, + $( ($param:ident: $param_t:ty) => $replace:literal ),* + ; $( $qkind:ident($qparam:ident: $qparam_t:ty) => $qkey:literal ),+ $(,)? + ) => { + $crate::__esi_endpoint!( + $(#[$m])* + $fn_name, $op_id, $visibility, $ret_type, "GET", + [ $( ($param: $param_t) => $replace ),* ] + [ $( $qkind($qparam: $qparam_t) => $qkey ),+ ] + [ ] + ); + }; +} + +/// Internal: the type of a query parameter, given its kind. +#[doc(hidden)] +#[macro_export] +macro_rules! __esi_query_type { + (Required, $t:ty) => { $t }; + (Optional, $t:ty) => { Option<$t> }; + (Many, $t:ty) => { $t }; + (OptionalMany, $t:ty) => { Option<$t> }; +} + +/// Internal: add a query parameter to the list of `(key, value)` pairs. +/// List parameters are sent as repeated keys (`labels=1&labels=2`). +#[doc(hidden)] +#[macro_export] +macro_rules! __esi_push_query { + ($params:ident, Required, $name:ident, $key:literal) => { + $params.push(($key, $name.to_string())); + }; + ($params:ident, Optional, $name:ident, $key:literal) => { + if let Some(value) = $name { + $params.push(($key, value.to_string())); + } + }; + ($params:ident, Many, $name:ident, $key:literal) => { + for value in $name.iter() { + $params.push(($key, value.to_string())); + } + }; + ($params:ident, OptionalMany, $name:ident, $key:literal) => { + if let Some(list) = $name { + for value in list.iter() { + $params.push(($key, value.to_string())); + } + } + }; +} + +/// Internal: serialize the request body, if the endpoint has one. +#[doc(hidden)] +#[macro_export] +macro_rules! __esi_body { + () => { + None:: + }; + ($body:ident) => { + Some(serde_json::to_string($body)?) + }; +} + +/// Internal: builds an endpoint function with path parameters, typed query +/// parameters (`Required`, `Optional`, `Many` or `OptionalMany`) and an +/// optional body. Used by the extended forms of `api_get!`, `api_post!`, +/// `api_put!` and `api_delete!`. +#[doc(hidden)] +#[macro_export] +macro_rules! __esi_endpoint { + ( + $(#[$m:meta])* + $fn_name:ident, $op_id:literal, $visibility:expr, $ret_type:ty, $method:literal, + [ $( ($param:ident: $param_t:ty) => $replace:literal ),* ] + [ $( $qkind:ident($qparam:ident: $qparam_t:ty) => $qkey:literal ),* ] + [ $( $body_param:ident: $body_t:ty )? ] + ) => { + $(#[$m])* + #[allow(clippy::vec_init_then_push)] + pub async fn $fn_name( + &self, + $( $param: $param_t, )* + $( $qparam: $crate::__esi_query_type!($qkind, $qparam_t), )* + $( $body_param: $body_t, )? + ) -> EsiResult<$ret_type> { + let path = self + .esi + .get_endpoint_for_op_id($op_id)? + $( + .replace($replace, &$param.to_string()) + )*; + #[allow(unused_mut)] + let mut params: Vec<(&str, String)> = Vec::new(); + $( + $crate::__esi_push_query!(params, $qkind, $qparam, $qkey); + )* + let params: Vec<(&str, &str)> = params.iter().map(|(a, b)| (*a, &**b)).collect(); + let body: Option = $crate::__esi_body!($($body_param)?); + self.esi + .query($method, $visibility, &path, Some(¶ms), body.as_deref()) + .await + } + }; } /// Create a function for calling a single endpoint @@ -218,7 +376,7 @@ macro_rules! api_get { /// "some_operation_id", /// RequestType::Public, /// Vec, -/// (alliance_id: u64) => "{alliance_id}", +/// (alliance_id: i64) => "{alliance_id}", /// ids: &[u64], /// ); /// @@ -229,7 +387,7 @@ macro_rules! api_get { /// /// ```rust,ignore /// /// Docs for the generated function -/// pub async fn function_name(&self, alliance_id: u64, ids: &[u64]) -> EsiResult> { +/// pub async fn function_name(&self, alliance_id: i64, ids: &[u64]) -> EsiResult> { /// let path = self.esi.get_endpoint_for_op_id("some_operation_id")? /// .replace("{alliance_id}", &alliance_id.to_string()); /// let body = serde_json::to_string(ids); @@ -262,5 +420,251 @@ macro_rules! api_post { query("POST", $visibility, &path, None, Some(&body)) .await } - } + }; + // A POST whose body is an array with a maximum length: the slice is split into + // requests of at most `$max` items and the answers are joined in order. + // `path params ; Chunked(body: &[T], max)`. + ( + $(#[$m:meta])* + $fn_name:ident, + $op_id:literal, + $visibility:expr, + $ret_type:ty, + $( ($param:ident: $param_t:ty) => $replace:literal ),* + ; Chunked($body_param:ident: $body_t:ty, $max:literal) $(,)? + ) => { + $(#[$m])* + /// + /// The spec allows at most + #[doc = concat!(stringify!($max), " items per request; longer lists are split into several requests,")] + /// sent concurrently, and the results are joined in order. An empty list sends nothing. + pub async fn $fn_name(&self, $( $param: $param_t, )* $body_param: $body_t) -> EsiResult<$ret_type> { + let path = self + .esi + .get_endpoint_for_op_id($op_id)? + $( + .replace($replace, &$param.to_string()) + )*; + self.esi + .post_chunked($visibility, &path, $body_param, $max) + .await + } + }; + ( + $(#[$m:meta])* + $fn_name:ident, + $op_id:literal, + $visibility:expr, + $ret_type:ty, + $( ($param:ident: $param_t:ty) => $replace:literal ),* + ; $( $qkind:ident($qparam:ident: $qparam_t:ty) => $qkey:literal ),* + ; $body_param:ident: $body_t:ty $(,)? + ) => { + $crate::__esi_endpoint!( + $(#[$m])* + $fn_name, $op_id, $visibility, $ret_type, "POST", + [ $( ($param: $param_t) => $replace ),* ] + [ $( $qkind($qparam: $qparam_t) => $qkey ),* ] + [ $body_param: $body_t ] + ); + }; + // A POST request without a body: `path params ; NoBody`. + ( + $(#[$m:meta])* + $fn_name:ident, + $op_id:literal, + $visibility:expr, + $ret_type:ty, + $( ($param:ident: $param_t:ty) => $replace:literal ),* + ; NoBody $(,)? + ) => { + $crate::__esi_endpoint!( + $(#[$m])* + $fn_name, $op_id, $visibility, $ret_type, "POST", + [ $( ($param: $param_t) => $replace ),* ] + [ ] + [ ] + ); + }; +} + +/// Create a function for calling a single endpoint +/// with a PUT request. +/// +/// Follows the structure of the `api_post!` macro: path parameters, +/// followed by the data that will be passed to `serde_json::to_string` +/// to set the request's body. +/// +/// # Example +/// +/// ```rust,no_run +/// # use esi_openapi::prelude::*; +/// # use esi_openapi::api_put; +/// pub struct SomeGroup<'a> { +/// pub(crate) esi: &'a Esi, +/// } +/// +/// impl SomeGroup<'_> { +/// +/// api_put!( +/// /// Docs for the generated function +/// function_name, +/// "some_operation_id", +/// RequestType::Authenticated, +/// (), +/// (fleet_id: i32) => "{fleet_id}", +/// settings: &serde_json::Value, +/// ); +/// +/// } +/// # fn main() {} +/// ``` +#[macro_export] +macro_rules! api_put { + ( + $(#[$m:meta])* + $fn_name:ident, + $op_id:literal, + $visibility:expr, + $ret_type:ty, + $( ($param:ident: $param_t:ty) => $replace:literal ),*, + $body_param:ident: $param_type:ty, + ) => { + $(#[$m])* + pub async fn $fn_name(&self, $( $param: $param_t, )* $body_param: $param_type) -> EsiResult<$ret_type> { + let path = self + .esi + .get_endpoint_for_op_id($op_id)? + $( + .replace($replace, &$param.to_string()) + )*; + let body = serde_json::to_string($body_param)?; + self.esi. + query("PUT", $visibility, &path, None, Some(&body)) + .await + } + }; + ( + $(#[$m:meta])* + $fn_name:ident, + $op_id:literal, + $visibility:expr, + $ret_type:ty, + $( ($param:ident: $param_t:ty) => $replace:literal ),* + ; $( $qkind:ident($qparam:ident: $qparam_t:ty) => $qkey:literal ),* + ; $body_param:ident: $body_t:ty $(,)? + ) => { + $crate::__esi_endpoint!( + $(#[$m])* + $fn_name, $op_id, $visibility, $ret_type, "PUT", + [ $( ($param: $param_t) => $replace ),* ] + [ $( $qkind($qparam: $qparam_t) => $qkey ),* ] + [ $body_param: $body_t ] + ); + }; +} + +/// Create a function for calling a single endpoint +/// with a DELETE request. +/// +/// Follows the structure of the `api_get!` macro: path parameters, +/// then required and optional query parameters. DELETE requests +/// have no body. +/// +/// # Example +/// +/// ```rust,no_run +/// # use esi_openapi::prelude::*; +/// # use esi_openapi::api_delete; +/// pub struct SomeGroup<'a> { +/// pub(crate) esi: &'a Esi, +/// } +/// +/// impl SomeGroup<'_> { +/// +/// api_delete!( +/// /// Docs for the generated function +/// function_name, +/// "some_operation_id", +/// RequestType::Authenticated, +/// (), +/// (character_id: i64) => "{character_id}", +/// (fitting_id: i32) => "{fitting_id}" +/// ); +/// +/// } +/// # fn main() {} +/// ``` +#[macro_export] +macro_rules! api_delete { + ( + $(#[$m:meta])* + $fn_name:ident, + $op_id:literal, + $visibility:expr, + $ret_type:ty, + $( ($param:ident: $param_t:ty) => $replace:literal ),* + ) => { + $(#[$m])* + pub async fn $fn_name(&self, $( $param: $param_t, )*) -> EsiResult<$ret_type> { + let path = self + .esi + .get_endpoint_for_op_id($op_id)? + $( + .replace($replace, &$param.to_string()) + )*; + self.esi. + query("DELETE", $visibility, &path, None, None) + .await + } + }; + ( + $(#[$m:meta])* + $fn_name:ident, + $op_id:literal, + $visibility:expr, + $ret_type:ty, + $( ($param:ident: $param_t:ty) => $replace:literal ),* + $( ; $( ($qparam:ident: $qparam_t:ty) => $qreplace:literal ),+ )? + ) => { + $(#[$m])* + pub async fn $fn_name( + &self, + $( $param: $param_t, )* + $($( $qparam: $qparam_t, )*)? + ) -> EsiResult<$ret_type> { + let path = self + .esi + .get_endpoint_for_op_id($op_id)? + $( + .replace($replace, &$param.to_string()) + )*; + let params = vec![ + $($( + ($qreplace, $qparam.to_string()), + )+)? + ]; + let params: Vec<(&str, &str)> = params.iter().map(|(a, b)| (*a, &**b)).collect(); + self.esi. + query("DELETE", $visibility, &path, Some(¶ms), None) + .await + } + }; + ( + $(#[$m:meta])* + $fn_name:ident, + $op_id:literal, + $visibility:expr, + $ret_type:ty, + $( ($param:ident: $param_t:ty) => $replace:literal ),* + ; $( $qkind:ident($qparam:ident: $qparam_t:ty) => $qkey:literal ),+ $(,)? + ) => { + $crate::__esi_endpoint!( + $(#[$m])* + $fn_name, $op_id, $visibility, $ret_type, "DELETE", + [ $( ($param: $param_t) => $replace ),* ] + [ $( $qkind($qparam: $qparam_t) => $qkey ),+ ] + [ ] + ); + }; } diff --git a/src/prelude.rs b/src/prelude.rs index 8d4cf91..0d461dd 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -1,12 +1,13 @@ //! Module for easy imports. -pub use crate::builders::EsiBuilder; +pub use crate::builders::{EsiBuilder, Language}; pub use crate::client::{ AuthenticationInformation, ErrorLimitStatus, Esi, RateLimitStatus, RequestType, COMPATIBILITY_DATE_DEFAULT, }; pub use crate::errors::{EsiError, EsiResult}; pub use crate::pkce::PkceVerifier; +pub use crate::rate_limiter::RateLimitPolicy; pub use crate::spec::Spec; pub(crate) use serde::{Deserialize, Serialize}; diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs new file mode 100644 index 0000000..ec67eb1 --- /dev/null +++ b/src/rate_limiter.rs @@ -0,0 +1,353 @@ +//! Optional proactive throttling against ESI's rate limits. +//! +//! ESI gives each application/character pair a token bucket per route group +//! over a floating window: a request spends tokens (`X-Ratelimit-Used`), and +//! those tokens come back one window later. Every response reports the tokens +//! left (`X-Ratelimit-Remaining`). With a [`RateLimitPolicy`] other than +//! [`RateLimitPolicy::Off`], the client keeps, for each group and access token, +//! the last reported balance plus a ledger of what its own responses spent, and +//! works out when the next request fits instead of provoking a `429`. +//! +//! Only operations that declare an `x-rate-limit` group in the spec are +//! throttled; the others are covered by the error limit. Until a response of a +//! group has been seen, nothing is known about its balance and requests go out. + +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex, MutexGuard, PoisonError}, + time::Duration, +}; + +/// What the client does when a route group has no tokens left for a request. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitPolicy { + /// Do not track budgets; send every request (the default). A `429` from ESI + /// is reported as [`EsiError::RateLimited`](crate::prelude::EsiError::RateLimited). + #[default] + Off, + /// Sleep until the request fits. If that takes longer than `max_wait`, the + /// request is not sent and [`EsiError::RateLimited`](crate::prelude::EsiError::RateLimited) + /// says how many seconds to wait. + Wait { + /// The longest the client sleeps for a single request. + max_wait: Duration, + }, + /// Do not wait: return [`EsiError::RateLimited`](crate::prelude::EsiError::RateLimited) + /// without calling ESI when the request does not fit. + Fail, +} + +/// Window length when ESI did not say one. +const DEFAULT_WINDOW_MS: i64 = 900_000; +/// Tokens a successful request costs until a response says otherwise. +const DEFAULT_COST: i64 = 2; +/// Added to a computed wait so the tokens are surely back by then. +const WAIT_MARGIN_MS: i64 = 50; + +/// The balance of one route group for one access token. +#[derive(Debug)] +struct Bucket { + window_ms: i64, + /// Tokens left, as of the last response. + remaining: i64, + /// Tokens a request is expected to spend. + cost: i64, + last_response_ms: i64, + /// When each response arrived and what it spent, oldest first. + spent: VecDeque<(i64, i64)>, + /// Requests sent whose response has not been seen yet. + in_flight: i64, + /// No request fits before this time (`Retry-After` of a `429`). + blocked_until_ms: i64, + /// Whether any response has been seen. + seen: bool, +} + +impl Default for Bucket { + fn default() -> Self { + Bucket { + window_ms: DEFAULT_WINDOW_MS, + remaining: 0, + cost: DEFAULT_COST, + last_response_ms: 0, + spent: VecDeque::new(), + in_flight: 0, + blocked_until_ms: 0, + seen: false, + } + } +} + +impl Bucket { + /// Milliseconds to wait from `now` until one more request fits (0 if it fits). + fn wait_ms(&self, now: i64) -> i64 { + if !self.seen { + return 0; + } + let needed = (self.in_flight + 1) * self.cost; + let mut available = self.remaining; + let mut ready_at = now; + if available < needed { + // Tokens spent before the last response return as their window ends. + ready_at = self.last_response_ms + self.window_ms; + for (at, cost) in &self.spent { + let returns_at = at + self.window_ms; + if returns_at <= self.last_response_ms { + continue; + } + available += cost; + if available >= needed { + ready_at = returns_at; + break; + } + } + } + let wait = (ready_at - now).max(self.blocked_until_ms - now).max(0); + if wait > 0 { + wait + WAIT_MARGIN_MS + } else { + 0 + } + } +} + +/// The answer to a request for budget. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Acquire { + /// The request fits; it is counted as in flight until its permit is dropped. + Granted, + /// It does not fit; try again in this many milliseconds. + Wait(i64), +} + +/// Budgets by `group|access token`. +#[derive(Debug, Default)] +pub(crate) struct RateLimiter { + buckets: Mutex>, +} + +impl RateLimiter { + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.buckets.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The key of a bucket. + pub(crate) fn key(group: &str, token: Option<&str>) -> String { + format!("{group}|{}", token.unwrap_or("")) + } + + /// The group a key was made for. + pub(crate) fn group_of(key: &str) -> &str { + key.split('|').next().unwrap_or(key) + } + + /// Reserve budget for one request, if it fits at `now`. + pub(crate) fn try_acquire(&self, key: &str, now: i64) -> Acquire { + let mut buckets = self.lock(); + let bucket = buckets.entry(key.to_owned()).or_default(); + match bucket.wait_ms(now) { + 0 => { + bucket.in_flight += 1; + Acquire::Granted + } + wait => Acquire::Wait(wait), + } + } + + /// Release the reservation of a request that is no longer in flight. + pub(crate) fn release(&self, key: &str) { + if let Some(bucket) = self.lock().get_mut(key) { + bucket.in_flight = (bucket.in_flight - 1).max(0); + } + } + + /// Take in the balance a response reported: `remaining` tokens left after + /// it spent `used`, with the group's `window_ms` (0 if unknown). + pub(crate) fn record( + &self, + key: &str, + remaining: i64, + used: i64, + window_ms: i64, + success: bool, + now: i64, + ) { + let mut buckets = self.lock(); + let bucket = buckets.entry(key.to_owned()).or_default(); + if window_ms > 0 { + bucket.window_ms = window_ms; + } + bucket.remaining = remaining; + bucket.last_response_ms = now; + bucket.seen = true; + if success && used > 0 { + bucket.cost = used; + } + if used > 0 { + bucket.spent.push_back((now, used)); + } + let window = bucket.window_ms; + while bucket + .spent + .front() + .is_some_and(|(at, _)| at + window <= now) + { + bucket.spent.pop_front(); + } + buckets.retain(|_, b| b.in_flight > 0 || b.last_response_ms + b.window_ms > now); + } + + /// No request fits for this key before `until_ms` (a `Retry-After`). + pub(crate) fn block_until(&self, key: &str, until_ms: i64) { + let mut buckets = self.lock(); + let bucket = buckets.entry(key.to_owned()).or_default(); + bucket.blocked_until_ms = bucket.blocked_until_ms.max(until_ms); + bucket.seen = true; + } +} + +/// A reservation of budget for a request in flight; released when dropped. +pub(crate) struct Permit { + limiter: Arc, + key: String, +} + +impl Permit { + pub(crate) fn new(limiter: Arc, key: &str) -> Self { + Permit { + limiter, + key: key.to_owned(), + } + } +} + +impl Drop for Permit { + fn drop(&mut self) { + self.limiter.release(&self.key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &str = "g|t"; + + #[test] + fn test_unseen_groups_are_not_throttled() { + let limiter = RateLimiter::default(); + for _ in 0..10 { + assert_eq!(limiter.try_acquire(KEY, 0), Acquire::Granted); + } + } + + #[test] + fn test_requests_that_fit_are_granted_and_in_flight_ones_count() { + let limiter = RateLimiter::default(); + // 5 tokens left, a request costs 2: two fit, the third does not. + limiter.record(KEY, 5, 2, 10_000, true, 0); + assert_eq!(limiter.try_acquire(KEY, 1), Acquire::Granted); + assert_eq!(limiter.try_acquire(KEY, 1), Acquire::Granted); + assert!(matches!(limiter.try_acquire(KEY, 1), Acquire::Wait(_))); + // A released reservation frees its share. + limiter.release(KEY); + assert_eq!(limiter.try_acquire(KEY, 1), Acquire::Granted); + } + + #[test] + fn test_tokens_return_when_their_window_ends() { + let limiter = RateLimiter::default(); + limiter.record(KEY, 0, 2, 1_000, true, 0); + // The 2 tokens spent at 0 are back at 1000. + let Acquire::Wait(wait) = limiter.try_acquire(KEY, 100) else { + panic!("should wait"); + }; + assert_eq!(wait, 900 + WAIT_MARGIN_MS); + assert_eq!(limiter.try_acquire(KEY, 1_000), Acquire::Granted); + } + + #[test] + fn test_the_wait_ends_at_the_first_return_that_is_enough() { + let limiter = RateLimiter::default(); + // Spent 2 at 0, 2 at 400, 2 at 800; none left. One request (2) fits once + // the first return arrives, at 1000. + for at in [0, 400, 800] { + limiter.record(KEY, 0, 2, 1_000, true, at); + } + assert_eq!( + limiter.try_acquire(KEY, 800), + Acquire::Wait(200 + WAIT_MARGIN_MS) + ); + // With a request in flight, two returns are needed: 1000 and 1400. + let limiter = RateLimiter::default(); + for at in [0, 400, 800] { + limiter.record(KEY, 0, 2, 1_000, true, at); + } + limiter.record(KEY, 0, 0, 1_000, true, 800); + assert_eq!(limiter.try_acquire(KEY, 1_000), Acquire::Granted); + assert_eq!( + limiter.try_acquire(KEY, 1_000), + Acquire::Wait(400 + WAIT_MARGIN_MS) + ); + } + + #[test] + fn test_retry_after_blocks_the_group() { + let limiter = RateLimiter::default(); + limiter.record(KEY, 100, 2, 10_000, true, 0); + limiter.block_until(KEY, 5_000); + assert_eq!( + limiter.try_acquire(KEY, 1_000), + Acquire::Wait(4_000 + WAIT_MARGIN_MS) + ); + assert_eq!(limiter.try_acquire(KEY, 5_000), Acquire::Granted); + } + + #[test] + fn test_buckets_are_independent_per_group_and_token() { + let limiter = RateLimiter::default(); + limiter.record("a|x", 0, 2, 10_000, true, 0); + assert!(matches!(limiter.try_acquire("a|x", 1), Acquire::Wait(_))); + assert_eq!(limiter.try_acquire("a|y", 1), Acquire::Granted); + assert_eq!(limiter.try_acquire("b|x", 1), Acquire::Granted); + assert_eq!(RateLimiter::key("a", Some("x")), "a|x"); + assert_eq!(RateLimiter::group_of("a|x"), "a"); + } + + #[test] + fn test_idle_buckets_are_dropped() { + let limiter = RateLimiter::default(); + limiter.record("old|x", 10, 2, 1_000, true, 0); + limiter.record("new|x", 10, 2, 1_000, true, 5_000); + assert_eq!(limiter.lock().len(), 1); + } + + #[test] + fn test_a_permit_releases_on_drop() { + let limiter = Arc::new(RateLimiter::default()); + limiter.record(KEY, 2, 2, 10_000, true, 0); + assert_eq!(limiter.try_acquire(KEY, 1), Acquire::Granted); + let permit = Permit::new(Arc::clone(&limiter), KEY); + assert!(matches!(limiter.try_acquire(KEY, 1), Acquire::Wait(_))); + drop(permit); + assert_eq!(limiter.try_acquire(KEY, 1), Acquire::Granted); + } + + #[test] + fn test_the_policy_serializes_in_snake_case() { + assert_eq!( + serde_json::to_string(&RateLimitPolicy::Off).unwrap(), + "\"off\"" + ); + let wait = RateLimitPolicy::Wait { + max_wait: Duration::from_secs(3), + }; + let json = serde_json::to_string(&wait).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + wait + ); + } +} diff --git a/src/spec.rs b/src/spec.rs index d867378..6e253d5 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -12,6 +12,49 @@ use std::collections::HashMap; pub struct Spec { /// Map of URL path (e.g. `/markets/{region_id}/orders`) to its path item. pub paths: HashMap, + /// Reusable parts of the spec; only the security schemes are read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub components: Option, +} + +/// The `components` section of the spec. +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct SpecComponents { + /// Security schemes by name, such as `OAuth2`. + #[serde( + rename = "securitySchemes", + default, + skip_serializing_if = "HashMap::is_empty" + )] + pub security_schemes: HashMap, +} + +/// A security scheme; only the OAuth2 authorization code flow is read. +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct SpecSecurityScheme { + /// OAuth2 flows of the scheme. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flows: Option, +} + +/// The OAuth2 flows of a security scheme. +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct SpecOAuthFlows { + /// The authorization code flow. + #[serde( + rename = "authorizationCode", + default, + skip_serializing_if = "Option::is_none" + )] + pub authorization_code: Option, +} + +/// One OAuth2 flow. +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct SpecOAuthFlow { + /// Scope name to description. + #[serde(default)] + pub scopes: HashMap, } /// An OpenAPI path item: the operations available on a single URL path. @@ -56,9 +99,161 @@ pub struct SpecPathMethod { skip_serializing_if = "Option::is_none" )] pub operation_id: Option, + /// How long, in seconds, a client may reuse the response (`x-client-cache-ttl`). + #[serde( + rename = "x-client-cache-ttl", + default, + skip_serializing_if = "Option::is_none" + )] + pub client_cache_ttl: Option, + /// Security requirements: each entry maps a scheme (`OAuth2`) to the scopes it needs. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub security: Vec>>, + /// Rate limit group and budget of the operation (`x-rate-limit`). + #[serde( + rename = "x-rate-limit", + default, + skip_serializing_if = "Option::is_none" + )] + pub rate_limit: Option, + /// Corporation roles of which the character needs at least one (`x-required-roles`). + #[serde( + rename = "x-required-roles", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub required_roles: Vec, + /// Pagination style of the operation (`x-pagination`), such as `cursor`. + #[serde( + rename = "x-pagination", + default, + skip_serializing_if = "Option::is_none" + )] + pub pagination: Option, + /// Seconds a deleted resource keeps answering as gone (`x-tombstone-ttl`). + #[serde( + rename = "x-tombstone-ttl", + default, + skip_serializing_if = "Option::is_none" + )] + pub tombstone_ttl: Option, +} + +/// The rate limit an operation declares with `x-rate-limit`. +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct SpecRateLimit { + /// Route group the operation spends tokens from. + pub group: String, + /// Tokens available per window. + #[serde(rename = "max-tokens")] + pub max_tokens: i64, + /// Window length as ESI writes it, such as `15m`. + #[serde(rename = "window-size")] + pub window_size: String, +} + +impl SpecPathMethod { + /// The OAuth2 scopes the operation needs, without duplicates. + pub fn scopes(&self) -> Vec { + let mut scopes: Vec = Vec::new(); + for requirement in &self.security { + for scope in requirement.values().flatten() { + if !scopes.contains(scope) { + scopes.push(scope.clone()); + } + } + } + scopes + } } impl Spec { + /// Every OAuth2 scope the spec defines, sorted. + pub fn oauth_scopes(&self) -> Vec { + let mut scopes: Vec = self + .components + .iter() + .flat_map(|c| c.security_schemes.values()) + .filter_map(|scheme| scheme.flows.as_ref()?.authorization_code.as_ref()) + .flat_map(|flow| flow.scopes.keys().cloned()) + .collect(); + scopes.sort(); + scopes.dedup(); + scopes + } + + /// The scopes needed to call every operation in `op_ids`, sorted and without + /// duplicates, as a space-separated list ready for `EsiBuilder::scope`. + pub fn scope_string_for(&self, op_ids: &[&str]) -> String { + let mut scopes: Vec = op_ids + .iter() + .flat_map(|id| self.required_scopes(id)) + .collect(); + scopes.sort(); + scopes.dedup(); + scopes.join(" ") + } + + /// Find an operation by its `operationId`. + pub fn operation(&self, op_id: &str) -> Option<&SpecPathMethod> { + self.paths + .values() + .flat_map(SpecPathItem::methods) + .find(|m| m.operation_id.as_deref() == Some(op_id)) + } + + /// The scopes an operation needs (empty for public operations). + pub fn required_scopes(&self, op_id: &str) -> Vec { + self.operation(op_id) + .map(SpecPathMethod::scopes) + .unwrap_or_default() + } + + /// The rate limit every operation declares, keyed by route group. + pub fn rate_limit_groups(&self) -> HashMap { + self.paths + .values() + .flat_map(SpecPathItem::methods) + .filter_map(|m| m.rate_limit.clone()) + .map(|limit| (limit.group.clone(), limit)) + .collect() + } + + /// The `x-client-cache-ttl` of the `GET` operation whose path template matches + /// `path` (with or without the leading slash, e.g. `characters/95465499`). + pub fn client_cache_ttl(&self, path: &str) -> Option { + self.get_operation_for_path(path)?.client_cache_ttl + } + + /// The `x-tombstone-ttl` of the `GET` operation whose path template matches + /// `path`: how long ESI keeps answering for a deleted resource. + pub fn tombstone_ttl(&self, path: &str) -> Option { + self.get_operation_for_path(path)?.tombstone_ttl + } + + /// The `GET` operation whose path template matches a concrete path such as + /// `characters/95465499`. When several templates match, the one with the + /// most literal segments wins (`characters/affiliation` over + /// `characters/{character_id}`). + pub fn get_operation_for_path(&self, path: &str) -> Option<&SpecPathMethod> { + let wanted: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + self.paths + .iter() + .filter_map(|(template, item)| { + let parts: Vec<&str> = template.trim_start_matches('/').split('/').collect(); + let is_param = |t: &&str| t.starts_with('{') && t.ends_with('}'); + let matches = parts.len() == wanted.len() + && parts + .iter() + .zip(&wanted) + .all(|(t, w)| is_param(t) || t == w); + let operation = item.get.as_ref()?; + matches.then(|| (parts.iter().filter(|t| !is_param(t)).count(), operation)) + }) + .max_by_key(|(literals, _)| *literals) + .map(|(_, operation)| operation) + } + /// Build a map of `operationId` to URL path, with the path's leading /// slash removed so it can be appended to the base API URL. pub fn operation_index(&self) -> HashMap { @@ -73,9 +268,137 @@ impl Spec { } } +/// A path template split into segments, with the metadata looked up per request. +#[derive(Debug, Clone, PartialEq, Eq)] +struct PathTemplate { + /// One entry per segment: the literal text, or `None` for a `{parameter}`. + segments: Vec>, + /// How many segments are literal; more literals win when several templates match. + literals: usize, + client_cache_ttl: Option, + tombstone_ttl: Option, + rate_limit_group: Option, +} + +impl PathTemplate { + fn matches(&self, wanted: &[&str]) -> bool { + self.segments + .iter() + .zip(wanted) + .all(|(segment, w)| segment.as_deref().is_none_or(|literal| literal == *w)) + } +} + +/// Lookup tables built once from a [`Spec`], so that resolving an operation or +/// matching a concrete path does not scan every path in the spec on each call. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct SpecIndex { + /// `operationId` -> URL path without the leading slash. + paths: HashMap, + /// `operationId` -> (path template, position among that path's methods). + locations: HashMap, + /// Templates by HTTP method and segment count, the most literal first. + templates: HashMap<&'static str, HashMap>>, +} + +impl SpecIndex { + /// The HTTP methods a path item can define, as written in requests. + const VERBS: [&'static str; 5] = ["GET", "POST", "PUT", "DELETE", "PATCH"]; + + /// Index every operation and every path template of `spec`. + pub(crate) fn new(spec: &Spec) -> Self { + let mut index = SpecIndex::default(); + for (template, item) in &spec.paths { + let path = template.strip_prefix('/').unwrap_or(template); + for (position, method) in item.methods().enumerate() { + if let Some(op_id) = &method.operation_id { + index.paths.insert(op_id.clone(), path.to_owned()); + index + .locations + .insert(op_id.clone(), (template.clone(), position)); + } + } + let segments: Vec> = template + .trim_start_matches('/') + .split('/') + .map(|part| { + (!(part.starts_with('{') && part.ends_with('}'))).then(|| part.to_owned()) + }) + .collect(); + let literals = segments.iter().flatten().count(); + let methods = [&item.get, &item.post, &item.put, &item.delete, &item.patch]; + for (verb, method) in Self::VERBS.into_iter().zip(methods) { + let Some(method) = method else { continue }; + index + .templates + .entry(verb) + .or_default() + .entry(segments.len()) + .or_default() + .push(PathTemplate { + segments: segments.clone(), + literals, + client_cache_ttl: method.client_cache_ttl, + tombstone_ttl: method.tombstone_ttl, + rate_limit_group: method.rate_limit.as_ref().map(|r| r.group.clone()), + }); + } + } + for by_length in index.templates.values_mut() { + for templates in by_length.values_mut() { + templates.sort_by(|a, b| { + b.literals + .cmp(&a.literals) + .then_with(|| a.segments.cmp(&b.segments)) + }); + } + } + index + } + + /// The URL path (without the leading slash) of an operation. + pub(crate) fn path(&self, op_id: &str) -> Option<&str> { + self.paths.get(op_id).map(String::as_str) + } + + /// The operation's metadata, read from the `spec` this index was built from. + pub(crate) fn operation<'a>(&self, spec: &'a Spec, op_id: &str) -> Option<&'a SpecPathMethod> { + let (template, position) = self.locations.get(op_id)?; + spec.paths.get(template)?.methods().nth(*position) + } + + /// The template of the given HTTP method that best matches a concrete path. + fn template_for(&self, method: &str, path: &str) -> Option<&PathTemplate> { + let verb = Self::VERBS + .into_iter() + .find(|verb| verb.eq_ignore_ascii_case(method))?; + let wanted: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + self.templates + .get(verb)? + .get(&wanted.len())? + .iter() + .find(|template| template.matches(&wanted)) + } + + /// Same as [`Spec::client_cache_ttl`], without scanning the spec. + pub(crate) fn client_cache_ttl(&self, path: &str) -> Option { + self.template_for("GET", path)?.client_cache_ttl + } + + /// Same as [`Spec::tombstone_ttl`], without scanning the spec. + pub(crate) fn tombstone_ttl(&self, path: &str) -> Option { + self.template_for("GET", path)?.tombstone_ttl + } + + /// The rate-limit group the operation with this HTTP method and path spends from. + pub(crate) fn rate_limit_group(&self, method: &str, path: &str) -> Option<&str> { + self.template_for(method, path)?.rate_limit_group.as_deref() + } +} + #[cfg(test)] mod tests { - use super::Spec; + use super::{Spec, SpecIndex}; const FIXTURE: &str = include_str!("../resources/test/openapi.json"); @@ -94,6 +417,143 @@ mod tests { ); } + #[test] + fn test_client_cache_ttl_matches_templates() { + let spec: Spec = serde_json::from_str(FIXTURE).unwrap(); + assert_eq!(spec.client_cache_ttl("/alliances"), Some(3600)); + assert!(spec.client_cache_ttl("characters/95465499").is_some()); + assert_eq!(spec.client_cache_ttl("no/such/path"), None); + } + + #[test] + fn test_operation_metadata() { + let spec: Spec = serde_json::from_str(FIXTURE).unwrap(); + let op = spec + .operation("GetCorporationsCorporationIdBlueprints") + .unwrap(); + assert_eq!(op.scopes(), ["esi-corporations.read_blueprints.v1"]); + assert_eq!(op.required_roles, ["Director"]); + let limit = op.rate_limit.as_ref().unwrap(); + assert_eq!(limit.group, "corp-industry"); + assert_eq!(limit.max_tokens, 600); + assert_eq!(limit.window_size, "15m"); + assert!(spec.required_scopes("GetStatus").is_empty()); + assert_eq!( + spec.operation("GetFreelanceJobsListing") + .unwrap() + .pagination + .as_deref(), + Some("cursor") + ); + assert_eq!( + spec.operation("GetParagonHubSkinr").unwrap().tombstone_ttl, + Some(604_800) + ); + assert!(spec.rate_limit_groups().contains_key("char-wallet")); + } + + #[test] + fn test_oauth_scopes_and_scope_string() { + let spec: Spec = serde_json::from_str(FIXTURE).unwrap(); + let scopes = spec.oauth_scopes(); + assert!(scopes.len() > 30, "only {} scopes", scopes.len()); + assert!(scopes.contains(&"esi-wallet.read_character_wallet.v1".to_owned())); + assert_eq!( + spec.scope_string_for(&[ + "GetCharactersCharacterIdWallet", + "GetCharactersCharacterIdWalletJournal", + "GetStatus" + ]), + "esi-wallet.read_character_wallet.v1" + ); + } + + #[test] + fn test_tombstone_ttl_and_path_matching() { + let spec: Spec = serde_json::from_str(FIXTURE).unwrap(); + assert_eq!(spec.tombstone_ttl("paragon-hub/skinr"), Some(604_800)); + assert_eq!( + spec.tombstone_ttl("paragon-hub/skinr/alliances/99000001"), + Some(604_800) + ); + assert_eq!(spec.tombstone_ttl("characters/95465499"), None); + assert_eq!( + spec.get_operation_for_path("characters/95465499") + .and_then(|m| m.operation_id.as_deref()), + Some("GetCharactersDetail") + ); + } + + #[test] + fn test_index_agrees_with_spec() { + let spec: Spec = serde_json::from_str(FIXTURE).unwrap(); + let index = SpecIndex::new(&spec); + for (op_id, path) in spec.operation_index() { + assert_eq!(index.path(&op_id), Some(path.as_str())); + assert_eq!(index.operation(&spec, &op_id), spec.operation(&op_id)); + } + assert_eq!(index.path("NoSuchOperation"), None); + assert!(index.operation(&spec, "NoSuchOperation").is_none()); + } + + #[test] + fn test_index_path_matching_agrees_with_spec() { + let spec: Spec = serde_json::from_str(FIXTURE).unwrap(); + let index = SpecIndex::new(&spec); + for path in [ + "/alliances", + "alliances/99000001", + "characters/95465499", + "characters/affiliation", + "paragon-hub/skinr", + "paragon-hub/skinr/alliances/99000001", + "markets/10000002/orders", + "no/such/path", + "", + ] { + assert_eq!( + index.client_cache_ttl(path), + spec.client_cache_ttl(path), + "{path}" + ); + assert_eq!( + index.tombstone_ttl(path), + spec.tombstone_ttl(path), + "{path}" + ); + } + assert_eq!(index.tombstone_ttl("paragon-hub/skinr"), Some(604_800)); + } + + #[test] + fn test_index_rate_limit_group_by_method() { + let spec: Spec = serde_json::from_str(FIXTURE).unwrap(); + let index = SpecIndex::new(&spec); + let group_of = |op_id: &str| { + spec.operation(op_id) + .and_then(|m| m.rate_limit.as_ref()) + .map(|r| r.group.clone()) + }; + let post = group_of("PostCharactersCharacterIdAssetsNames").expect("declares a group"); + assert_eq!( + index.rate_limit_group("post", "/characters/95465499/assets/names"), + Some(post.as_str()) + ); + // The same path template has other methods; each keeps its own metadata. + let get = group_of("GetCharactersCharacterIdAssets").expect("declares a group"); + assert_eq!( + index.rate_limit_group("GET", "characters/95465499/assets"), + Some(get.as_str()) + ); + // An operation that declares no group, and an unknown method or path. + assert_eq!( + index.rate_limit_group("POST", "characters/affiliation"), + None + ); + assert_eq!(index.rate_limit_group("TRACE", "alliances"), None); + assert_eq!(index.rate_limit_group("GET", "no/such/path"), None); + } + #[test] fn test_parse_ignores_unknown_keys() { let source = r#"{ diff --git a/tests/authenticated.rs b/tests/authenticated.rs index f5900b8..d767679 100644 --- a/tests/authenticated.rs +++ b/tests/authenticated.rs @@ -67,6 +67,44 @@ async fn authenticated_endpoints() { } check!("skills", esi.group_skills().get_skills(character_id)); + check!( + "attributes", + esi.group_skills().get_attributes(character_id) + ); + check!( + "skill queue", + esi.group_skills().get_skillqueue(character_id) + ); + check!( + "agents research", + esi.group_character().get_agents_research(character_id) + ); + check!("fatigue", esi.group_character().get_fatigue(character_id)); + check!("medals", esi.group_character().get_medals(character_id)); + check!( + "contact notifications", + esi.group_character() + .get_contact_notifications(character_id) + ); + check!("roles", esi.group_character().get_roles(character_id)); + check!( + "standings", + esi.group_character().get_standings(character_id) + ); + check!("titles", esi.group_character().get_titles(character_id)); + check!( + "wallet journal", + esi.group_wallet().get_journal(character_id, Some(1)) + ); + check!( + "loyalty points", + esi.group_loyalty().get_character_points(character_id) + ); + check!( + "faction warfare stats", + esi.group_faction_warfare() + .get_character_stats(character_id) + ); check!("wallet", esi.group_wallet().get_wallet(character_id)); check!( "wallet transactions", @@ -92,6 +130,16 @@ async fn authenticated_endpoints() { "mail labels", esi.group_mail().get_character_mail_labels(character_id) ); + check!( + "contracts", + esi.group_contracts() + .get_character_contracts(character_id, Some(1)) + ); + check!( + "planets", + esi.group_planetary_interaction() + .get_character_planets(character_id) + ); check!( "market orders", esi.group_market().get_character_orders(character_id) @@ -101,6 +149,167 @@ async fn authenticated_endpoints() { esi.group_industry() .get_character_industry_jobs(character_id, Some(true)) ); + check!( + "character mining", + esi.group_industry() + .get_character_mining(character_id, Some(1)) + ); + check!( + "character order history", + esi.group_market() + .get_character_orders_history(character_id, Some(1)) + ); + check!( + "access lists", + esi.group_access_list() + .get_character_access_lists(character_id) + ); + check!( + "tactical operations", + esi.group_activities() + .get_character_tactical_operations(character_id) + ); + check!( + "skinr licenses", + esi.group_cosmetics().get_character_skinr(character_id) + ); + check!( + "skinr components", + esi.group_cosmetics() + .get_character_skinr_components(character_id) + ); + check!( + "character freelance jobs", + esi.group_freelance_jobs().get_character_jobs(character_id) + ); + check!( + "mercenary dens", + esi.group_structures() + .get_character_mercenary_dens(character_id) + ); + check!( + "character campaign objectives", + esi.group_military_campaigns().list_character_objectives( + character_id, + None, + None, + Some(10) + ) + ); + check!( + "character skinr listings", + esi.group_paragon_hub() + .get_character_skinr_listings(character_id, None, None, Some(10)) + ); + // The raw body, to see which keys ESI really sends for this endpoint. + let skinr_path = esi + .get_endpoint_for_op_id("GetCharactersParagonHubSkinr") + .unwrap() + .replace("{character_id}", &character_id.to_string()); + let raw: esi_openapi::prelude::EsiResult = esi + .query( + "GET", + esi_openapi::prelude::RequestType::Authenticated, + &skinr_path, + Some(&[("limit", "10")]), + None, + ) + .await; + println!("RAW character skinr listings: {raw:?}"); + check!( + "raidable skyhooks", + esi.group_activities().get_raidable_skyhooks() + ); + match esi.group_character().get_public_info(character_id).await { + Ok(info) => { + let corp = info.corporation_id; + let group = esi.group_corporation(); + check!("corporation icons", group.get_icons(corp)); + check!("corporation roles", group.get_roles(corp)); + check!("corporation divisions", group.get_divisions(corp)); + check!("corporation titles", group.get_titles(corp)); + check!("corporation medals", group.get_medals(corp, Some(1))); + check!("corporation standings", group.get_standings(corp, Some(1))); + check!( + "corporation member tracking", + group.get_member_tracking(corp) + ); + check!( + "corporation structures", + group.get_structures(corp, Some(1)) + ); + check!("corporation starbases", group.get_starbases(corp, Some(1))); + check!( + "corporation blueprints", + group.get_blueprints(corp, Some(1)) + ); + check!( + "corporation wallets", + esi.group_wallet().get_corporation_wallets(corp) + ); + check!( + "corporation wallet journal", + esi.group_wallet().get_corporation_journal(corp, 1, Some(1)) + ); + check!( + "corporation wallet transactions", + esi.group_wallet() + .get_corporation_transactions(corp, 1, None) + ); + check!( + "corporation orders", + esi.group_market().get_corporation_orders(corp, Some(1)) + ); + check!( + "corporation order history", + esi.group_market() + .get_corporation_orders_history(corp, Some(1)) + ); + check!( + "corporation killmails", + esi.group_killmails().get_corporation_recent(corp, Some(1)) + ); + check!( + "corporation industry jobs", + esi.group_industry() + .get_corporation_industry_jobs(corp, Some(true), Some(1)) + ); + check!( + "corporation mining extractions", + esi.group_industry() + .get_corporation_mining_extractions(corp, Some(1)) + ); + check!( + "corporation mining observers", + esi.group_industry() + .get_corporation_mining_observers(corp, Some(1)) + ); + check!( + "corporation skyhooks", + esi.group_structures().get_corporation_skyhooks(corp) + ); + check!( + "corporation sovereignty hubs", + esi.group_structures() + .get_corporation_sovereignty_hubs(corp) + ); + check!( + "corporation projects", + esi.group_corporation_projects() + .list(corp, None, None, Some(10), None) + ); + check!( + "corporation freelance jobs", + esi.group_freelance_jobs() + .list_corporation_jobs(corp, None, None, Some(10)) + ); + check!( + "corporation faction warfare stats", + esi.group_faction_warfare().get_corporation_stats(corp) + ); + } + Err(e) => println!("SKIP corporation checks: could not read the character: {e}"), + } check!( "recent killmails", esi.group_killmails().get_character_recent(character_id) @@ -132,7 +341,6 @@ async fn authenticated_endpoints() { if singleton_ids.is_empty() { println!("SKIP asset locations/names: no singleton assets"); } else { - let unsigned: Vec = singleton_ids.iter().map(|&id| id as u64).collect(); check!( "asset locations", esi.group_assets() @@ -141,7 +349,7 @@ async fn authenticated_endpoints() { check!( "asset names", esi.group_assets() - .get_character_assets_names(character_id, &unsigned) + .get_character_assets_names(character_id, &singleton_ids) ); } diff --git a/tests/cache.rs b/tests/cache.rs new file mode 100644 index 0000000..bdd0cb9 --- /dev/null +++ b/tests/cache.rs @@ -0,0 +1,607 @@ +//! Exercises the response cache against a tiny local HTTP server. + +use esi_openapi::prelude::*; +use esi_openapi::spec::Spec; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// A local server that records the requests it receives. +struct LocalServer { + url: String, + requests: Arc>>, +} + +impl LocalServer { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&requests); + tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let seen = Arc::clone(&seen); + tokio::spawn(async move { + let mut buffer = vec![0u8; 8192]; + let n = socket.read(&mut buffer).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buffer[..n]).to_lowercase(); + seen.lock().unwrap().push(request.clone()); + let first_line = request.lines().next().unwrap_or_default().to_owned(); + let response = if first_line.contains("/spec") { + // `spec` stays fresh for a minute; `spec0` never does. + let max_age = if first_line.contains("/spec0") { 0 } else { 60 }; + if request.contains("if-none-match: \"s1\"") { + format!("HTTP/1.1 304 Not Modified\r\nETag: \"s1\"\r\nCache-Control: max-age={max_age}\r\nConnection: close\r\n\r\n") + } else { + let body = + r#"{"paths":{"/limited":{"get":{"operationId":"GetLimited"}}}}"#; + format!( + "HTTP/1.1 200 OK\r\nETag: \"s1\"\r\nCache-Control: max-age={max_age}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + } else if first_line.contains("/limited") { + // A group whose window of 1 second has no tokens left. + let body = "[1]"; + format!( + "HTTP/1.1 200 OK\r\nX-Ratelimit-Group: test\r\nX-Ratelimit-Limit: 10/1s\r\nX-Ratelimit-Remaining: 0\r\nX-Ratelimit-Used: 2\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } else if first_line.contains("/blocked") { + "HTTP/1.1 429 Too Many Requests\r\nX-Ratelimit-Group: blocked\r\nX-Ratelimit-Limit: 10/15m\r\nX-Ratelimit-Remaining: 0\r\nX-Ratelimit-Used: 5\r\nRetry-After: 30\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_owned() + } else if first_line.contains("/echo") { + // Answers with the number of items in the posted array. + let posted = request.split("\r\n\r\n").nth(1).unwrap_or_default(); + let count = posted.matches(',').count() + 1; + let body = format!("[{count}]"); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } else if first_line.contains("/cursor") { + let body = if first_line.contains("after=2") { + r#"{"items":[3,4]}"# + } else { + r#"{"cursor":{"after":"2"},"items":[1,2]}"# + }; + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } else if first_line.contains("/gone") { + "HTTP/1.1 410 Gone\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_owned() + } else if request.contains("if-none-match: \"v1\"") { + "HTTP/1.1 304 Not Modified\r\nETag: \"v1\"\r\nConnection: close\r\n\r\n" + .to_owned() + } else { + let max_age = if first_line.contains("/fresh") { 60 } else { 0 }; + let body = "[1,2,3]"; + format!( + "HTTP/1.1 200 OK\r\nETag: \"v1\"\r\nCache-Control: max-age={max_age}\r\nX-Pages: 4\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + }; + let _ = socket.write_all(response.as_bytes()).await; + }); + } + }); + LocalServer { url, requests } + } + + fn count(&self) -> usize { + self.requests.lock().unwrap().len() + } +} + +struct Client; + +impl Client { + fn build(url: &str, cache: bool) -> Esi { + EsiBuilder::new() + .user_agent("cache test") + .base_api_url(url) + .enable_cache(cache) + .build() + .unwrap() + } +} + +#[tokio::test] +async fn revalidates_with_etag_and_reuses_the_body_on_304() { + let server = LocalServer::start().await; + let esi = Client::build(&server.url, true); + let first: Vec = esi + .query("GET", RequestType::Public, "stale", None, None) + .await + .unwrap(); + let second: Vec = esi + .query("GET", RequestType::Public, "stale", None, None) + .await + .unwrap(); + assert_eq!(first, [1, 2, 3]); + assert_eq!(second, [1, 2, 3]); + assert_eq!(server.count(), 2, "max-age=0 must be revalidated"); + assert!(server.requests.lock().unwrap()[1].contains("if-none-match: \"v1\"")); + assert_eq!(esi.cache_len().await, 1); +} + +#[tokio::test] +async fn serves_fresh_entries_without_calling_the_server() { + let server = LocalServer::start().await; + let esi = Client::build(&server.url, true); + for _ in 0..3 { + let (data, pages): (Vec, Option) = esi + .query_with_pages("GET", RequestType::Public, "fresh", None) + .await + .unwrap(); + assert_eq!(data, [1, 2, 3]); + assert_eq!(pages, Some(4)); + } + assert_eq!(server.count(), 1); +} + +#[tokio::test] +async fn the_cache_is_off_by_default() { + let server = LocalServer::start().await; + let esi = Client::build(&server.url, false); + for _ in 0..2 { + let _: Vec = esi + .query("GET", RequestType::Public, "fresh", None, None) + .await + .unwrap(); + } + assert_eq!(server.count(), 2); + assert_eq!(esi.cache_len().await, 0); +} + +#[tokio::test] +async fn fetch_all_pages_follows_x_pages() { + let server = LocalServer::start().await; + let esi = Client::build(&server.url, false); + let items: Vec = esi + .fetch_all_pages(RequestType::Public, "fresh", None, None) + .await + .unwrap(); + assert_eq!(items.len(), 12); + assert_eq!(server.count(), 4); + let limited: Vec = esi + .fetch_all_pages(RequestType::Public, "fresh", None, Some(2)) + .await + .unwrap(); + assert_eq!(limited.len(), 6); +} + +#[tokio::test] +async fn sends_language_and_tenant_headers() { + let server = LocalServer::start().await; + let esi = EsiBuilder::new() + .user_agent("header test") + .base_api_url(&server.url) + .language(Language::De) + .tenant("singularity") + .build() + .unwrap(); + let _: Vec = esi + .query("GET", RequestType::Public, "fresh", None, None) + .await + .unwrap(); + let request = server.requests.lock().unwrap()[0].clone(); + assert!(request.contains("accept-language: de"), "{request}"); + assert!(request.contains("x-tenant: singularity"), "{request}"); +} + +#[tokio::test] +async fn reports_tombstones_as_gone() { + let server = LocalServer::start().await; + let spec: Spec = serde_json::from_str(include_str!("../resources/test/openapi.json")).unwrap(); + let esi = EsiBuilder::new() + .user_agent("tombstone test") + .base_api_url(&server.url) + .spec(Some(spec)) + .build() + .unwrap(); + // The alliances listing declares `x-tombstone-ttl`, so a 410 there is `Gone`. + let result: EsiResult = esi + .query( + "GET", + RequestType::Public, + "paragon-hub/skinr/alliances/gone", + None, + None, + ) + .await; + assert!( + matches!( + result, + Err(EsiError::Gone { + status: 410, + tombstone_ttl_secs: 604_800 + }) + ), + "{result:?}" + ); + // An operation without a tombstone TTL keeps the plain status error. + let result: EsiResult = esi + .query("GET", RequestType::Public, "characters/gone", None, None) + .await; + assert!( + matches!(result, Err(EsiError::InvalidStatusCode(410))), + "{result:?}" + ); +} + +#[tokio::test] +async fn cursor_walk_asks_for_the_largest_page_size() { + let server = LocalServer::start().await; + let esi = Client::build(&server.url, false); + let items: Vec = esi + .fetch_all_cursor(RequestType::Public, "cursor", None, "items", None) + .await + .unwrap(); + assert_eq!(items, [1, 2, 3, 4]); + let requests = server.requests.lock().unwrap().clone(); + assert_eq!(requests.len(), 2); + assert!( + requests.iter().all(|r| r.contains("limit=100")), + "{requests:?}" + ); + drop(requests); + // An explicit `limit` is kept. + let items: Vec = esi + .fetch_all_cursor( + RequestType::Public, + "cursor", + Some(&[("limit", "10")]), + "items", + Some(1), + ) + .await + .unwrap(); + assert_eq!(items, [1, 2]); + let last = server.requests.lock().unwrap().last().unwrap().clone(); + assert!( + last.contains("limit=10 ") || last.contains("limit=10&"), + "{last}" + ); +} + +#[tokio::test] +async fn pages_can_be_fetched_sequentially_or_concurrently() { + for concurrency in [1, 3] { + let server = LocalServer::start().await; + let esi = EsiBuilder::new() + .user_agent("concurrency test") + .base_api_url(&server.url) + .page_concurrency(concurrency) + .build() + .unwrap(); + let items: Vec = esi + .fetch_all_pages(RequestType::Public, "fresh", None, None) + .await + .unwrap(); + assert_eq!(items.len(), 12, "concurrency {concurrency}"); + assert_eq!(server.count(), 4); + } +} + +#[tokio::test] +async fn the_cache_respects_its_size_limit() { + let server = LocalServer::start().await; + let esi = EsiBuilder::new() + .user_agent("limit test") + .base_api_url(&server.url) + .enable_cache(true) + .cache_max_entries(2) + .build() + .unwrap(); + for path in ["fresh/a", "fresh/b", "fresh/c"] { + let _: Vec = esi + .query("GET", RequestType::Public, path, None, None) + .await + .unwrap(); + } + assert_eq!(esi.cache_len().await, 2); +} + +#[tokio::test] +async fn the_cache_evicts_the_least_recently_used_entry() { + let server = LocalServer::start().await; + let esi = EsiBuilder::new() + .user_agent("lru test") + .base_api_url(&server.url) + .enable_cache(true) + .cache_max_entries(2) + .build() + .unwrap(); + // "fresh/..." answers with max-age=60, so these entries stay live. + for path in ["fresh/a", "fresh/b", "fresh/a", "fresh/c"] { + let _: Vec = esi + .query("GET", RequestType::Public, path, None, None) + .await + .unwrap(); + } + // a (hit), b, c: three requests so far. "a" was used after "b", so "b" went. + assert_eq!(server.count(), 3); + let _: Vec = esi + .query("GET", RequestType::Public, "fresh/a", None, None) + .await + .unwrap(); + assert_eq!(server.count(), 3, "a is still cached"); + let _: Vec = esi + .query("GET", RequestType::Public, "fresh/b", None, None) + .await + .unwrap(); + assert_eq!(server.count(), 4, "b had been evicted"); +} + +#[tokio::test] +async fn post_chunked_splits_long_lists_and_keeps_the_order() { + for concurrency in [1, 3] { + let server = LocalServer::start().await; + let esi = EsiBuilder::new() + .user_agent("chunk test") + .base_api_url(&server.url) + .page_concurrency(concurrency) + .build() + .unwrap(); + let ids: Vec = (1..=2500).collect(); + let counts: Vec = esi + .post_chunked(RequestType::Public, "echo", &ids, 1000) + .await + .unwrap(); + assert_eq!(counts, [1000, 1000, 500]); + assert_eq!(server.count(), 3); + assert!(server + .requests + .lock() + .unwrap() + .iter() + .all(|r| r.starts_with("post "))); + } +} + +#[tokio::test] +async fn post_chunked_sends_nothing_for_an_empty_list() { + let server = LocalServer::start().await; + let esi = Client::build(&server.url, false); + let ids: Vec = Vec::new(); + let counts: Vec = esi + .post_chunked(RequestType::Public, "echo", &ids, 1000) + .await + .unwrap(); + assert!(counts.is_empty()); + assert_eq!(server.count(), 0); +} + +#[tokio::test] +async fn the_cache_respects_its_byte_limit() { + let server = LocalServer::start().await; + let unbounded = Client::build(&server.url, true); + let _: Vec = unbounded + .query("GET", RequestType::Public, "fresh/a", None, None) + .await + .unwrap(); + let one = unbounded.cache_bytes().await; + assert!(one > 0); + + // Room for two entries, not three. + let esi = EsiBuilder::new() + .user_agent("cache test") + .base_api_url(&server.url) + .enable_cache(true) + .cache_max_bytes(one * 5 / 2) + .build() + .unwrap(); + for path in ["fresh/a", "fresh/b", "fresh/c"] { + let _: Vec = esi + .query("GET", RequestType::Public, path, None, None) + .await + .unwrap(); + } + assert_eq!(esi.cache_len().await, 2); + assert!(esi.cache_bytes().await <= one * 5 / 2); + + // Nothing fits in a tiny limit, but requests still work. + let tiny = EsiBuilder::new() + .user_agent("cache test") + .base_api_url(&server.url) + .enable_cache(true) + .cache_max_bytes(10) + .build() + .unwrap(); + let data: Vec = tiny + .query("GET", RequestType::Public, "fresh/a", None, None) + .await + .unwrap(); + assert_eq!(data, [1, 2, 3]); + assert_eq!(tiny.cache_len().await, 0); +} + +/// A spec with two rate-limited paths, `limited` and `blocked`, and one without a group. +fn throttle_spec() -> Spec { + serde_json::from_str( + r#"{"paths": { + "/limited": {"get": {"operationId": "GetLimited", + "x-rate-limit": {"group": "test", "max-tokens": 10, "window-size": "1s"}}}, + "/blocked": {"get": {"operationId": "GetBlocked", + "x-rate-limit": {"group": "blocked", "max-tokens": 10, "window-size": "15m"}}}, + "/free": {"get": {"operationId": "GetFree"}} + }}"#, + ) + .unwrap() +} + +fn throttled(url: &str, policy: RateLimitPolicy) -> Esi { + EsiBuilder::new() + .user_agent("throttle test") + .base_api_url(url) + .spec(Some(throttle_spec())) + .rate_limit_policy(policy) + .build() + .unwrap() +} + +async fn get_ok(esi: &Esi, path: &str) -> EsiResult> { + esi.query("GET", RequestType::Public, path, None, None) + .await +} + +#[tokio::test] +async fn the_throttle_is_off_by_default() { + let server = LocalServer::start().await; + let esi = throttled(&server.url, RateLimitPolicy::Off); + for _ in 0..3 { + get_ok(&esi, "limited").await.unwrap(); + } + assert_eq!(server.count(), 3); +} + +#[tokio::test] +async fn the_fail_policy_refuses_without_calling_esi() { + let server = LocalServer::start().await; + let esi = throttled(&server.url, RateLimitPolicy::Fail); + assert_eq!(get_ok(&esi, "limited").await.unwrap(), [1]); + let error = get_ok(&esi, "limited").await.unwrap_err(); + assert!( + matches!(&error, EsiError::RateLimited { group: Some(g), .. } if g == "test"), + "{error:?}" + ); + assert_eq!(server.count(), 1); + // A path the spec gives no group is never throttled. + get_ok(&esi, "free").await.unwrap(); + get_ok(&esi, "free").await.unwrap(); + assert_eq!(server.count(), 3); +} + +#[tokio::test] +async fn the_wait_policy_sleeps_until_the_tokens_return() { + let server = LocalServer::start().await; + let esi = throttled( + &server.url, + RateLimitPolicy::Wait { + max_wait: std::time::Duration::from_secs(5), + }, + ); + get_ok(&esi, "limited").await.unwrap(); + let started = std::time::Instant::now(); + get_ok(&esi, "limited").await.unwrap(); + assert!( + started.elapsed() >= std::time::Duration::from_millis(900), + "{:?}", + started.elapsed() + ); + assert_eq!(server.count(), 2); +} + +#[tokio::test] +async fn the_wait_policy_gives_up_after_max_wait() { + let server = LocalServer::start().await; + let esi = throttled( + &server.url, + RateLimitPolicy::Wait { + max_wait: std::time::Duration::from_millis(100), + }, + ); + get_ok(&esi, "limited").await.unwrap(); + let started = std::time::Instant::now(); + let error = get_ok(&esi, "limited").await.unwrap_err(); + // About a second to go, rounded up. + assert!( + matches!( + error, + EsiError::RateLimited { + retry_after_secs: Some(1..=2), + .. + } + ), + "{error:?}" + ); + assert!(started.elapsed() < std::time::Duration::from_millis(500)); + assert_eq!(server.count(), 1); +} + +#[tokio::test] +async fn a_429_blocks_the_group_for_its_retry_after() { + let server = LocalServer::start().await; + let esi = throttled(&server.url, RateLimitPolicy::Fail); + let first = get_ok(&esi, "blocked").await.unwrap_err(); + assert!(matches!( + first, + EsiError::RateLimited { + retry_after_secs: Some(30), + .. + } + )); + let second = get_ok(&esi, "blocked").await.unwrap_err(); + assert!( + matches!(second, EsiError::RateLimited { retry_after_secs: Some(s), .. } if s >= 29), + "{second:?}" + ); + assert_eq!(server.count(), 1, "the second call must not reach ESI"); +} + +fn with_spec_url(url: &str, path: &str) -> Esi { + EsiBuilder::new() + .user_agent("spec test") + .base_api_url(url) + .spec_url(&format!("{url}{path}")) + .build() + .unwrap() +} + +#[tokio::test] +async fn ensure_spec_fresh_skips_the_request_while_the_spec_is_fresh() { + let server = LocalServer::start().await; + let mut esi = with_spec_url(&server.url, "spec"); + assert!(esi.get_spec().is_none()); + esi.ensure_spec_fresh().await.unwrap(); + assert!(esi.get_spec().is_some()); + assert_eq!(esi.get_endpoint_for_op_id("GetLimited").unwrap(), "limited"); + assert_eq!(server.count(), 1); + for _ in 0..3 { + esi.ensure_spec_fresh().await.unwrap(); + } + assert_eq!(server.count(), 1, "the spec is fresh for a minute"); +} + +#[tokio::test] +async fn ensure_spec_fresh_downloads_again_once_stale() { + let server = LocalServer::start().await; + let mut esi = with_spec_url(&server.url, "spec0"); + esi.ensure_spec_fresh().await.unwrap(); + esi.ensure_spec_fresh().await.unwrap(); + assert_eq!(server.count(), 2, "max-age=0 is never fresh"); + // The second request was conditional and its 304 kept the spec. + assert!(server.requests.lock().unwrap()[1].contains("if-none-match: \"s1\"")); + assert!(esi.get_endpoint_for_op_id("GetLimited").is_ok()); +} + +#[tokio::test] +async fn update_spec_is_conditional_once_the_spec_is_known() { + let server = LocalServer::start().await; + let mut esi = with_spec_url(&server.url, "spec"); + esi.update_spec().await.unwrap(); + assert!(!server.requests.lock().unwrap()[0].contains("if-none-match")); + esi.update_spec().await.unwrap(); + assert_eq!(server.count(), 2, "update_spec always asks"); + assert!(server.requests.lock().unwrap()[1].contains("if-none-match: \"s1\"")); + assert!(esi.get_endpoint_for_op_id("GetLimited").is_ok()); +} + +#[tokio::test] +async fn a_spec_given_to_the_builder_has_no_known_age() { + let server = LocalServer::start().await; + let mut esi = EsiBuilder::new() + .user_agent("spec test") + .spec_url(&format!("{}spec", server.url)) + .spec(Some(throttle_spec())) + .build() + .unwrap(); + esi.ensure_spec_fresh().await.unwrap(); + assert_eq!(server.count(), 1); + assert!(esi.get_endpoint_for_op_id("GetLimited").is_ok()); + assert!(esi.get_endpoint_for_op_id("GetFree").is_err()); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1872672..9980e88 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -120,7 +120,7 @@ pub fn save_refresh_token(token: &str) -> std::io::Result<()> { /// Read the character ID from an access token's `sub` claim /// (`CHARACTER:EVE:`), without validating the token. -pub fn character_id_from_token(access_token: &str) -> Option { +pub fn character_id_from_token(access_token: &str) -> Option { let payload = access_token.split('.').nth(1)?; let bytes = URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?; let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?; @@ -130,7 +130,7 @@ pub fn character_id_from_token(access_token: &str) -> Option { /// Exchange the refresh token from `.env` for an access token, saving /// the rotated refresh token back to `.env` when EVE SSO issues a new one. /// Returns the client and the character ID. -pub async fn authenticated_esi(cfg: &EnvConfig) -> Result<(Esi, i32), String> { +pub async fn authenticated_esi(cfg: &EnvConfig) -> Result<(Esi, i64), String> { let refresh_token = cfg .refresh_token .as_deref() diff --git a/tests/conformance.rs b/tests/conformance.rs new file mode 100644 index 0000000..44e902a --- /dev/null +++ b/tests/conformance.rs @@ -0,0 +1,696 @@ +//! Checks the implemented endpoints against the OpenAPI fixture in +//! `resources/test/openapi.json`: every operation is implemented, and the HTTP +//! method, path placeholders and query keys used by each implementation match the spec. + +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +/// Operations deliberately left unimplemented. Keep it empty unless there is a reason. +const NOT_IMPLEMENTED: &[&str] = &[]; + +const HTTP_METHODS: [&str; 5] = ["get", "post", "put", "delete", "patch"]; + +/// What the spec says about one operation. +struct SpecOperation { + method: String, + path: String, + query_params: BTreeSet, + /// Properties of the (array item or object) response schema, with whether each is required. + response_fields: Option>, + /// For a cursor page (`cursor` plus one array of records): the fields of a record. + page_item_fields: Option>, +} + +/// One use of an operation ID in the sources. +struct Usage { + file: String, + op_id: String, + /// HTTP method, when the source makes it explicit. + method: Option, + /// Placeholders (`{x}`), when the source is a macro call. + placeholders: Option>, + /// Query keys, when the source is a macro call. + query_keys: Option>, + /// Name of the Rust type the macro call returns, without `Vec<>`. + return_type: Option, + /// The `T` of a returned `Page`. + page_item: Option, +} + +/// A struct declared in the sources: its serialized field names. +struct RustStruct { + fields: BTreeSet, +} + +struct Conformance { + operations: BTreeMap, + usages: Vec, + structs: BTreeMap, +} + +impl Conformance { + fn load() -> Self { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let spec: Value = serde_json::from_str( + &fs::read_to_string(root.join("resources/test/openapi.json")).unwrap(), + ) + .unwrap(); + let mut conformance = Conformance { + operations: BTreeMap::new(), + usages: Vec::new(), + structs: BTreeMap::new(), + }; + conformance.read_spec(&spec); + let mut files: Vec<_> = fs::read_dir(root.join("src/groups")) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e == "rs")) + .collect(); + files.sort(); + for file in files { + let source = fs::read_to_string(&file).unwrap(); + let name = file.file_name().unwrap().to_string_lossy().into_owned(); + conformance.read_structs(&source); + conformance.read_source(&name, &source); + } + conformance + } + + fn resolve<'a>(spec: &'a Value, node: &'a Value) -> &'a Value { + match node.get("$ref").and_then(Value::as_str) { + Some(reference) => { + let mut current = spec; + for key in reference.trim_start_matches("#/").split('/') { + current = ¤t[key]; + } + Self::resolve(spec, current) + } + None => node, + } + } + + fn read_spec(&mut self, spec: &Value) { + for (path, item) in spec["paths"].as_object().unwrap() { + for method in HTTP_METHODS { + let Some(operation) = item.get(method) else { + continue; + }; + let op_id = operation["operationId"].as_str().unwrap().to_owned(); + let mut query_params = BTreeSet::new(); + let shared = item.get("parameters").and_then(Value::as_array); + let own = operation.get("parameters").and_then(Value::as_array); + for param in shared + .into_iter() + .flatten() + .chain(own.into_iter().flatten()) + { + let param = Self::resolve(spec, param); + if param["in"] == "query" { + query_params.insert(param["name"].as_str().unwrap().to_owned()); + } + } + let response_fields = Self::response_fields(spec, operation); + let page_item_fields = Self::page_item_fields(spec, operation); + self.operations.insert( + op_id, + SpecOperation { + method: method.to_uppercase(), + path: path.clone(), + query_params, + response_fields, + page_item_fields, + }, + ); + } + } + } + + fn response_fields(spec: &Value, operation: &Value) -> Option> { + let ok = operation["responses"] + .as_object()? + .iter() + .find(|(code, _)| code.starts_with('2'))? + .1; + let ok = Self::resolve(spec, ok); + let schema = ok.get("content")?.get("application/json")?.get("schema")?; + let mut schema = Self::resolve(spec, schema); + if schema["type"] == "array" { + schema = Self::resolve(spec, schema.get("items")?); + } + let properties = schema.get("properties")?.as_object()?; + let required: BTreeSet<&str> = schema + .get("required") + .and_then(Value::as_array) + .map(|r| r.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + Some( + properties + .keys() + .map(|k| (k.clone(), required.contains(k.as_str()))) + .collect(), + ) + } + + /// Fields of a record in a cursor page: the response has a `cursor` property and + /// one array property that holds the records. + fn page_item_fields(spec: &Value, operation: &Value) -> Option> { + let ok = operation["responses"] + .as_object()? + .iter() + .find(|(code, _)| code.starts_with('2'))? + .1; + let ok = Self::resolve(spec, ok); + let schema = ok.get("content")?.get("application/json")?.get("schema")?; + let schema = Self::resolve(spec, schema); + let properties = schema.get("properties")?.as_object()?; + properties.get("cursor")?; + let mut arrays = properties + .values() + .map(|p| Self::resolve(spec, p)) + .filter(|p| p["type"] == "array"); + let records = Self::resolve(spec, arrays.next()?.get("items")?); + if arrays.next().is_some() { + return None; + } + let required: BTreeSet<&str> = records + .get("required") + .and_then(Value::as_array) + .map(|r| r.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + Some( + records + .get("properties")? + .as_object()? + .keys() + .map(|k| (k.clone(), required.contains(k.as_str()))) + .collect(), + ) + } + + /// Collect the structs declared in a source file. + fn read_structs(&mut self, source: &str) { + let mut rest = source; + while let Some(start) = rest.find("pub struct ") { + let after = &rest[start + 11..]; + rest = after; + let name: String = after + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + let Some(body_start) = after.find('{') else { + continue; + }; + // Only structs with named fields: the `{` must come before any `;` or `(`. + let header = &after[..body_start]; + if header.contains(';') || header.contains('(') { + continue; + } + let Some(body_end) = after.find("\n}") else { + continue; + }; + let body = &after[body_start..body_end]; + let mut fields = BTreeSet::new(); + let mut rename: Option = None; + for line in body.lines().map(str::trim) { + if let Some(attr) = line.strip_prefix("#[serde(rename = \"") { + rename = attr.split('"').next().map(str::to_owned); + } else if let Some(field) = line.strip_prefix("pub ") { + if let Some(name) = field.split(':').next() { + fields.insert(rename.take().unwrap_or_else(|| name.trim().to_owned())); + } + } + } + self.structs.insert(name, RustStruct { fields }); + } + } + + /// Every double-quoted literal in `text`, in order. + fn literals(text: &str) -> Vec<&str> { + text.split('"').skip(1).step_by(2).collect() + } + + fn read_source(&mut self, file: &str, source: &str) { + // Macro calls: from `api_xxx!(` to the closing `);` at the start of a line. + let mut rest = source; + while let Some(start) = rest.find("api_") { + let after = &rest[start..]; + let Some(open) = after.find("!(") else { break }; + let name = &after[..open]; + let is_macro = matches!(name, "api_get" | "api_post" | "api_put" | "api_delete"); + let end = after.find("\n );").map_or(after.len(), |e| e + 6); + if is_macro { + self.read_macro(file, &name[4..], &after[open..end]); + } + rest = &after[(open + 2).min(after.len())..]; + } + // Hand-written methods: `get_endpoint_for_op_id("X")` and the next HTTP method literal. + let mut rest = source; + while let Some(start) = rest.find("get_endpoint_for_op_id(\"") { + let after = &rest[start + 24..]; + let op_id = after[..after.find('"').unwrap()].to_owned(); + let window = &after[..after.len().min(700)]; + let method = Self::literals(window) + .into_iter() + .find(|l| ["GET", "POST", "PUT", "DELETE"].contains(l)) + .map(str::to_owned); + self.usages.push(Usage { + file: file.to_owned(), + op_id, + method, + placeholders: None, + query_keys: None, + return_type: None, + page_item: None, + }); + rest = after; + } + } + + fn read_macro(&mut self, file: &str, method: &str, call: &str) { + let literals = Self::literals(call); + let Some(op_id) = literals + .iter() + .find(|l| self.operations.contains_key(**l)) + .map(|l| (*l).to_owned()) + else { + return; + }; + let mut placeholders = BTreeSet::new(); + let mut query_keys = BTreeSet::new(); + for literal in literals.iter().filter(|l| **l != op_id) { + if literal.starts_with('{') && literal.ends_with('}') { + placeholders.insert(literal[1..literal.len() - 1].to_owned()); + } else if literal + .chars() + .all(|c| c.is_ascii_lowercase() || c == '_' || c == '-') + && !literal.is_empty() + { + query_keys.insert((*literal).to_owned()); + } + } + self.usages.push(Usage { + file: file.to_owned(), + op_id, + method: Some(method.to_uppercase()), + placeholders: Some(placeholders), + query_keys: Some(query_keys), + return_type: Self::return_type(call), + page_item: Self::page_item(call), + }); + } + + /// The `T` of `Page` when that is what a macro call returns. + fn page_item(call: &str) -> Option { + let after = &call[call.find("RequestType::")?..]; + let after = &after[after.find(',')? + 1..]; + let inner = after.trim_start().strip_prefix("Page<")?; + let name: String = inner + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + (!name.is_empty()).then_some(name) + } + + /// The type after `RequestType::X,` in a macro call, without `Vec<>`. + fn return_type(call: &str) -> Option { + let after = &call[call.find("RequestType::")?..]; + let after = &after[after.find(',')? + 1..]; + let ty = after.trim_start(); + let ty = ty.strip_prefix("Vec<").unwrap_or(ty); + let name: String = ty + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + (!name.is_empty()).then_some(name) + } + + fn path_placeholders(path: &str) -> BTreeSet { + path.split('{') + .skip(1) + .filter_map(|s| s.split('}').next()) + .map(str::to_owned) + .collect() + } +} + +#[test] +fn every_operation_is_implemented() { + let conformance = Conformance::load(); + let used: BTreeSet<&str> = conformance + .usages + .iter() + .map(|u| u.op_id.as_str()) + .collect(); + let missing: Vec<&String> = conformance + .operations + .keys() + .filter(|id| !used.contains(id.as_str()) && !NOT_IMPLEMENTED.contains(&id.as_str())) + .collect(); + assert!( + missing.is_empty(), + "operations without implementation: {missing:?}" + ); +} + +#[test] +fn the_scan_finds_the_macro_calls() { + // Guards the other checks against silently scanning nothing. + let conformance = Conformance::load(); + let macros = conformance + .usages + .iter() + .filter(|u| u.placeholders.is_some()) + .count(); + assert!(macros > 150, "only {macros} macro calls were found"); +} + +#[test] +fn every_used_operation_exists_in_the_spec() { + let conformance = Conformance::load(); + let unknown: Vec = conformance + .usages + .iter() + .filter(|u| !conformance.operations.contains_key(&u.op_id)) + .map(|u| format!("{} ({})", u.op_id, u.file)) + .collect(); + assert!(unknown.is_empty(), "unknown operation IDs: {unknown:?}"); +} + +#[test] +fn http_methods_match_the_spec() { + let conformance = Conformance::load(); + let mut wrong = Vec::new(); + for usage in &conformance.usages { + let (Some(operation), Some(method)) = ( + conformance.operations.get(&usage.op_id), + usage.method.as_ref(), + ) else { + continue; + }; + if &operation.method != method { + wrong.push(format!( + "{} in {}: code uses {method}, spec says {}", + usage.op_id, usage.file, operation.method + )); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +#[test] +fn path_placeholders_match_the_spec() { + let conformance = Conformance::load(); + let mut wrong = Vec::new(); + for usage in &conformance.usages { + let (Some(operation), Some(placeholders)) = ( + conformance.operations.get(&usage.op_id), + usage.placeholders.as_ref(), + ) else { + continue; + }; + let expected = Conformance::path_placeholders(&operation.path); + if &expected != placeholders { + wrong.push(format!( + "{} in {}: code has {placeholders:?}, spec path {} needs {expected:?}", + usage.op_id, usage.file, operation.path + )); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +#[test] +fn query_keys_exist_in_the_spec() { + let conformance = Conformance::load(); + let mut wrong = Vec::new(); + for usage in &conformance.usages { + let (Some(operation), Some(keys)) = ( + conformance.operations.get(&usage.op_id), + usage.query_keys.as_ref(), + ) else { + continue; + }; + for key in keys.difference(&operation.query_params) { + wrong.push(format!( + "{} in {}: query key `{key}` is not a parameter in the spec", + usage.op_id, usage.file + )); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// `(struct, field)` pairs that are not in the response schema of every operation returning +/// the struct, on purpose: `Contact` is shared by character, corporation and alliance +/// contacts, and those schemas differ. +const FIELD_ALLOW_LIST: &[(&str, &str)] = &[("Contact", "is_blocked"), ("Contact", "is_watched")]; + +#[test] +fn struct_fields_match_the_response_schemas() { + let conformance = Conformance::load(); + let mut wrong = Vec::new(); + let mut compared = 0; + for usage in &conformance.usages { + let (Some(operation), Some(type_name)) = ( + conformance.operations.get(&usage.op_id), + usage.return_type.as_ref(), + ) else { + continue; + }; + if let Some(item) = &usage.page_item { + // `Page` stands for the cursor page; compare `T` with a record. + let (Some(spec_fields), Some(rust)) = ( + operation.page_item_fields.as_ref(), + conformance.structs.get(item), + ) else { + wrong.push(format!( + "{} in {}: `Page<{item}>` does not match a cursor page of the spec", + usage.op_id, usage.file + )); + continue; + }; + compared += 1; + for field in rust.fields.iter().filter(|f| { + !spec_fields.contains_key(*f) + && !FIELD_ALLOW_LIST.contains(&(item.as_str(), f.as_str())) + }) { + wrong.push(format!( + "{} in {}: `{item}.{field}` is not in the spec", + usage.op_id, usage.file + )); + } + for (field, _) in spec_fields + .iter() + .filter(|(f, required)| **required && !rust.fields.contains(*f)) + { + wrong.push(format!( + "{} in {}: required field `{field}` is missing from `{item}`", + usage.op_id, usage.file + )); + } + continue; + } + let (Some(spec_fields), Some(rust)) = ( + operation.response_fields.as_ref(), + conformance.structs.get(type_name), + ) else { + continue; + }; + compared += 1; + for field in rust.fields.iter().filter(|f| { + !spec_fields.contains_key(*f) + && !FIELD_ALLOW_LIST.contains(&(type_name.as_str(), f.as_str())) + }) { + wrong.push(format!( + "{} in {}: `{type_name}.{field}` is not in the spec", + usage.op_id, usage.file + )); + } + for (field, _) in spec_fields + .iter() + .filter(|(f, required)| **required && !rust.fields.contains(*f)) + { + wrong.push(format!( + "{} in {}: required field `{field}` is missing from `{type_name}`", + usage.op_id, usage.file + )); + } + } + assert!(compared > 100, "only {compared} responses were compared"); + wrong.sort(); + wrong.dedup(); + assert!( + wrong.is_empty(), + "{} differences:\n{}", + wrong.len(), + wrong.join("\n") + ); +} + +/// The scopes listed in `ESI_SCOPES` of `.env.example`, if the file exists. +struct ExampleScopes; + +impl ExampleScopes { + fn read() -> Option> { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".env.example"); + let text = fs::read_to_string(path).ok()?; + let line = text.lines().find(|l| l.starts_with("ESI_SCOPES="))?; + let value = line.trim_start_matches("ESI_SCOPES=").trim_matches('"'); + Some(value.split_whitespace().map(str::to_owned).collect()) + } + + fn spec_scopes() -> BTreeSet { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let spec: Value = serde_json::from_str( + &fs::read_to_string(root.join("resources/test/openapi.json")).unwrap(), + ) + .unwrap(); + spec["components"]["securitySchemes"]["OAuth2"]["flows"]["authorizationCode"]["scopes"] + .as_object() + .unwrap() + .keys() + .cloned() + .collect() + } +} + +#[test] +fn example_scopes_exist_in_the_spec() { + let Some(example) = ExampleScopes::read() else { + eprintln!("skipped: .env.example has no ESI_SCOPES"); + return; + }; + let known = ExampleScopes::spec_scopes(); + let unknown: Vec<&String> = example.difference(&known).collect(); + assert!(unknown.is_empty(), "scopes not in the spec: {unknown:?}"); +} + +#[test] +fn example_scopes_cover_every_scope_the_operations_need() { + let Some(example) = ExampleScopes::read() else { + eprintln!("skipped: .env.example has no ESI_SCOPES"); + return; + }; + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let spec: Value = serde_json::from_str( + &fs::read_to_string(root.join("resources/test/openapi.json")).unwrap(), + ) + .unwrap(); + let mut needed = BTreeSet::new(); + for item in spec["paths"].as_object().unwrap().values() { + for method in HTTP_METHODS { + let scopes = item[method]["security"][0]["OAuth2"].as_array(); + needed.extend( + scopes + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned), + ); + } + } + let missing: Vec<&String> = needed.difference(&example).collect(); + assert!( + missing.is_empty(), + "scopes missing from .env.example: {missing:?}" + ); +} + +#[test] +fn chunk_sizes_equal_the_spec_maximums() { + // `Chunked(body: &[T], max)` calls must use the `maxItems` of the request body. + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let spec: Value = serde_json::from_str( + &fs::read_to_string(root.join("resources/test/openapi.json")).unwrap(), + ) + .unwrap(); + let mut limits = BTreeMap::new(); + for item in spec["paths"].as_object().unwrap().values() { + let Some(operation) = item.get("post") else { + continue; + }; + let schema = &operation["requestBody"]["content"]["application/json"]["schema"]; + let schema = Conformance::resolve(&spec, schema); + if let (Some(id), Some(max)) = ( + operation["operationId"].as_str(), + schema.get("maxItems").and_then(Value::as_u64), + ) { + limits.insert(id.to_owned(), max); + } + } + let mut checked = 0; + for entry in fs::read_dir(root.join("src/groups")).unwrap() { + let source = fs::read_to_string(entry.unwrap().path()).unwrap(); + for call in source.split("api_post!(").skip(1) { + let call = &call[..call.find("\n );").unwrap_or(call.len())]; + let Some(start) = call.find("Chunked(") else { + continue; + }; + let op_id = Conformance::literals(call)[0]; + let max: u64 = call[start..] + .rsplit(',') + .next() + .unwrap() + .trim() + .trim_end_matches(')') + .trim() + .parse() + .unwrap(); + assert_eq!(limits.get(op_id), Some(&max), "{op_id}"); + checked += 1; + } + } + assert_eq!(checked, 6, "expected six chunked endpoints"); +} + +#[test] +fn cursor_pages_use_keys_that_page_accepts() { + // `Page::items` reads the records under these keys (see `serde(alias)`). + const KEYS: [&str; 6] = [ + "projects", + "contributors", + "freelance_jobs", + "participants", + "objectives", + "listings", + ]; + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let spec: Value = serde_json::from_str( + &fs::read_to_string(root.join("resources/test/openapi.json")).unwrap(), + ) + .unwrap(); + let mut checked = 0; + for item in spec["paths"].as_object().unwrap().values() { + let Some(operation) = item.get("get") else { + continue; + }; + if operation["x-pagination"] != "cursor" { + continue; + } + let ok = operation["responses"] + .as_object() + .unwrap() + .iter() + .find(|(code, _)| code.starts_with('2')) + .unwrap() + .1; + let ok = Conformance::resolve(&spec, ok); + let schema = &ok["content"]["application/json"]["schema"]; + let schema = Conformance::resolve(&spec, schema); + let key = schema["properties"] + .as_object() + .unwrap() + .keys() + .find(|k| *k != "cursor") + .unwrap(); + assert!( + KEYS.contains(&key.as_str()), + "{}: `{key}` is not a key of `Page`", + operation["operationId"] + ); + checked += 1; + } + assert!(checked >= 12, "only {checked} cursor operations were found"); +} diff --git a/tests/fleet_write.rs b/tests/fleet_write.rs new file mode 100644 index 0000000..43c408e --- /dev/null +++ b/tests/fleet_write.rs @@ -0,0 +1,113 @@ +//! Live test for the fleet endpoints that change a fleet. +//! +//! It is ignored by default because it modifies the fleet the authenticated +//! character is in. To run it, log in as the fleet boss, make sure the +//! refresh token has the `esi-fleets.read_fleet.v1` and +//! `esi-fleets.write_fleet.v1` scopes, and run: +//! +//! ```sh +//! cargo test --test fleet_write -- --ignored --nocapture +//! ``` +//! +//! The test creates a wing and a squad, renames both, and deletes them +//! again. The wing is deleted even when an earlier step fails. + +mod common; + +use esi_openapi::groups::{FleetNaming, FleetRole}; + +#[tokio::test] +#[ignore = "modifies the fleet the character is in; run it explicitly"] +async fn fleet_wing_and_squad_lifecycle() { + let cfg = match common::load() { + Ok(cfg) => cfg, + Err(reason) => { + eprintln!("skipping fleet write test: {reason}"); + return; + } + }; + let (esi, character_id) = match common::authenticated_esi(&cfg).await { + Ok(v) => v, + Err(reason) if reason.starts_with("missing") => { + eprintln!("skipping fleet write test: {reason}"); + return; + } + Err(reason) => panic!("{reason}"), + }; + + let fleet = match esi.group_fleets().get_character_fleet(character_id).await { + Ok(fleet) => fleet, + Err(e) => { + eprintln!( + "skipping fleet write test: character {character_id} is not in a fleet ({e})" + ); + return; + } + }; + if fleet.role != FleetRole::FleetCommander { + eprintln!( + "skipping fleet write test: character {character_id} is {:?}, not the fleet commander", + fleet.role + ); + return; + } + let fleet_id = fleet.fleet_id; + let groups = esi.group_fleets(); + + let wing = groups + .create_wing(fleet_id) + .await + .expect("create_wing failed"); + println!("created wing {}", wing.wing_id); + + let steps = async { + let name = FleetNaming { + name: "esi-openapi test wing".to_owned(), + }; + groups + .rename_wing(fleet_id, wing.wing_id, &name) + .await + .map_err(|e| format!("rename_wing: {e}"))?; + + let squad = groups + .create_squad(fleet_id, wing.wing_id) + .await + .map_err(|e| format!("create_squad: {e}"))?; + println!("created squad {}", squad.squad_id); + + let name = FleetNaming { + name: "esi-openapi test squad".to_owned(), + }; + groups + .rename_squad(fleet_id, squad.squad_id, &name) + .await + .map_err(|e| format!("rename_squad: {e}"))?; + + let wings = groups + .get_wings(fleet_id) + .await + .map_err(|e| format!("get_wings: {e}"))?; + let found = wings + .iter() + .find(|w| w.id == wing.wing_id) + .ok_or_else(|| "the new wing is not listed".to_owned())?; + if !found.squads.iter().any(|s| s.id == squad.squad_id) { + return Err("the new squad is not listed".to_owned()); + } + + groups + .delete_squad(fleet_id, squad.squad_id) + .await + .map_err(|e| format!("delete_squad: {e}"))?; + Ok::<(), String>(()) + } + .await; + + // Always try to remove the wing, which also removes any squad left in it. + let cleanup = groups.delete_wing(fleet_id, wing.wing_id).await; + if let Err(e) = &cleanup { + eprintln!("could not delete wing {}: {e}", wing.wing_id); + } + steps.expect("fleet write steps failed"); + cleanup.expect("delete_wing failed"); +}