diff --git a/.Rbuildignore b/.Rbuildignore index 1e85aba..cc723e3 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -11,3 +11,11 @@ ^_beekeeper\.yml$ ^_beekeeper_rapid\.rds$ ^exploration$ +^_slackapi_rapid\.rds$ +^data-raw$ +^\.positai$ +^\.claude$ +^[.]?air[.]toml$ +^\.vscode$ +^AGENTS\.md$ +^conversation-export\.json$ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..977de95 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +When reviewing pull requests, do not review `man/*.Rd`. diff --git a/.github/skills/create-issue/SKILL.md b/.github/skills/create-issue/SKILL.md new file mode 100644 index 0000000..d5a19b8 --- /dev/null +++ b/.github/skills/create-issue/SKILL.md @@ -0,0 +1,117 @@ +--- +name: create-issue +trigger: create GitHub issues +description: Creates GitHub issues for the package repository. Use when asked to create, file, or open a GitHub issue, or when planning new features or functions that need to be tracked. +compatibility: Requires the `gh` CLI and an authenticated GitHub session. +--- + +# Create a GitHub issue + +Use `gh api graphql` with the `createIssue` mutation to create issues. This sets the issue type in a single step. Write the body to a temp file first, then pass it via `$(cat ...)`. + +If `gh` is not authenticated, stop and ask the user to authenticate before continuing. + +## Looking up IDs + +The hardcoded IDs below are correct for this repo as of 2026-05-11 21:33:53 UTC. If they ever change, or if you're working in a fork, re-run these queries to get fresh values: + +```bash +# Repository node ID +gh api graphql -f query='{ repository(owner: "jonthegeek", name: "beekeeper") { id } }' + +# Available issue type IDs +gh api graphql -f query='{ repository(owner: "jonthegeek", name: "beekeeper") { issueTypes(first: 20) { nodes { id name description } } } }' +``` + +## Issue type + +Choose the type that best fits the issue: + +| Type | ID | Use for | +|---|---|---| + +## Issue title + +Titles use conventional commit prefixes: + +- `feat: my_function()` — new exported function or feature +- `fix: short description` — bug fix +- `docs: short description` — documentation +- `chore: short description` — maintenance or task + +## Issue body structure + +Which sections to include depends on the issue type: + +| Section | Feature | Bug | Documentation | Task | +|---|---|---|---|---| +| `## Summary` | ✓ | ✓ | ✓ | ✓ | +| `## Details` | optional | optional | optional | optional | +| `## Proposed signature` | ✓ | — | — | — | +| `## Behavior` | ✓ | ✓ | — | — | +| `## References` | optional | optional | optional | optional | + +### `## Summary` (all types) + +A single user story sentence (no other content in this section): + +```markdown +> As a [role], in order to [goal], I would like to [feature]. +``` + +Example: + +```markdown +## Summary + +> As a package developer, in order to set up agent skills quickly, I would like to generate a skill template from a single function call. +``` + +### `## Details` (optional, all types) + +For information that's important to capture but doesn't fit naturally into any other section, including implementation details such as packages to add to `Imports` in `DESCRIPTION` or files to add to `inst`. Use sparingly — if the content belongs in `## Behavior`, `## Proposed signature`, or `## References`, put it there instead. + +### `## Proposed signature` (Feature only) + +The proposed R function signature, arguments table, and return value description: + +````markdown +## Proposed signature + +```r +function_name(arg1, arg2) +``` + +**Arguments** + +- `arg1` (`TYPE`) — Description. +- `arg2` (`TYPE`) — Description. + +**Returns** a `TYPE` with description. +```` + +### `## Behavior` (Feature and Bug) + +- **Feature**: bullet points describing expected behavior, edge cases, and any internal helpers to implement as part of this issue. +- **Bug**: describe the current (broken) behavior, the expected behavior, and steps to reproduce if known. + +### `## References` (optional, all types) + +Only include when there are specific reference implementations, external URLs, or related code to link to. Omit it entirely when there are none. + +## Creating the issue + +Use the `repoId` and the `typeId` for the chosen issue type from the table above. + +```bash +gh api graphql \ + -f query='mutation($repoId:ID!, $title:String!, $body:String!, $typeId:ID!) { + createIssue(input:{repositoryId:$repoId, title:$title, body:$body, issueTypeId:$typeId}) { + issue { url } + } + }' \ + -f repoId="R_kgDOJRLJFQ" \ + -f title="feat: my_function()" \ + -f body="$(cat /tmp/issue_body.md)" \ + -f typeId="{typeId}" +``` diff --git a/.github/skills/document/SKILL.md b/.github/skills/document/SKILL.md new file mode 100644 index 0000000..9914cdd --- /dev/null +++ b/.github/skills/document/SKILL.md @@ -0,0 +1,172 @@ +--- +name: document +trigger: document functions +description: Document package functions. Use when asked to document functions. +--- + +# Document functions + +*All* R functions in `R/` should be documented in roxygen2 `#'` style, including internal/unexported functions. + +- Run `air format .` then `devtools::document()` after changing any roxygen2 docs. +- Use sentence case for all headings. +- Wrap roxygen comments at 80 characters. +- Files matching `R/import-standalone-*.R` are imported from other packages and have their own conventions. Do not modify their documentation. +- After documenting functions, run `devtools::document(roclets = c('rd', 'collate', 'namespace'))`. +- If `_pkgdown.yml` exists and contains a `reference` section: + - Whenever you add a new (non-internal) documentation topic, also add the topic to `_pkgdown.yml`. + - Use `pkgdown::check_pkgdown()` to check that all topics are included in the reference index. + +## Shared parameters + +**Parameters used in more than one function** go in `R/aaa-shared_params.R` under `@name .shared-params`. Functions inherit them with `@inheritParams .shared-params`. See `R/aaa-shared_params.R` for current definitions (if it exists). + +Shared params blocks: alphabetize parameters, use `@name .shared-params` (with leading dot), include `@keywords internal`, end with `NULL`. + +Multiple shared-params groups (e.g. `.shared-params-io`, `.shared-params-parsing`) are appropriate when parameters are only shared within a file and closely related files. + +## Parameter documentation format + +```r +#' @param param_name (`TYPE`) Brief description (usually 1-3 sentences. Can +#' include [cross_references()]. Additional details on continuation lines if +#' needed. +``` + +Function-specific `@param` definitions always appear *before* any `@inheritParams` lines. If all parameters are defined locally, omit `@inheritParams` entirely. + +### Type notation + +| Notation | Meaning | +|----------|---------| +| ``(`character`)`` | Character vector | +| ``(`character(1)`)`` | Single string | +| ``(`logical(1)`)`` | Single logical | +| ``(`integer`)`` | Integer vector | +| ``(`integer(1)`)`` | Single integer | +| ``(`double`)`` | Double vector | +| ``(`vector(0)`)`` | A prototype (zero-length vector) | +| ``(`vector`)`` | A vector of unspecified type | +| ``(`list`)`` | List | +| ``(`data.frame`)`` | Data frame or tibble | +| ``(`function` or `NULL`)`` | A function or NULL | +| ``(`my_class`)`` | A class-specific type (use the actual class name) | +| ``(`any`)`` | Any type | + +### Enumerated values + +When a parameter takes one of a fixed set of values, document them with a bullet list: + +```r +#' @param method (`character(1)`) The aggregation method. Can be one of: +#' * `"mean"`: Arithmetic mean. +#' * `"median"`: Median value. +#' * `"sum"`: Total sum. +``` + +## Returns + +Use `@returns` (not `@return`). Include a type when it's informative: + +```r +#' @returns A summary tibble. +#' @returns (`logical(1)`) `TRUE` if `x` is a valid record. +#' @returns Either a tibble or a list, depending on the input. +#' @returns `NULL` (invisibly). +``` + +**Structured returns with columns:** + +```r +#' @returns A [tibble::tibble()] with columns: +#' - `name`: Record name. +#' - `value`: Numeric value. +#' - `status`: Status (`"active"` or `"inactive"`). +``` + +## Exported functions + +```r +#' Title in sentence case +#' +#' Description paragraph providing context and details. +#' +#' @param param_name (`TYPE`) Description. +#' @inheritParams .shared-params +#' +#' @returns Description of return value. +#' @seealso [related_function()] +#' @export +#' +#' @examples +#' example_code() +``` + +- Blank `#'` lines separate: title/description, description/params, and `@export`/`@examples`. +- `@seealso` (optional) goes between `@returns` and `@export`. +- `@details` can supplement the description when needed. + +## Internal (unexported) functions + +Internal helpers (identified by a dot prefix, e.g. `.parse_response()`) use abbreviated documentation. Mark them with `@keywords internal` and omit `@export`: + +```r +#' Title in sentence case +#' +#' @param one_off_param (`TYPE`) Description. +#' @inheritParams .shared-params +#' @returns (`TYPE`) What it returns. +#' @keywords internal +``` + +Description paragraph is optional (only include when usage isn't obvious), fewer blank `#'` lines, and no `@examples`. + +## S3 methods and `@rdname` grouping + +Use `@rdname` to group related functions under one help page. This applies to: +- **S3 methods we own** (generic defined in this package): generic gets full docs, methods get `@rdname` + `@export`. +- **Related exported functions** (e.g. multiple variants of the same operation): primary function gets full docs, variants get `@rdname` + `@export`. + +```r +#' Format a summary object +#' +#' @param x (`any`) The object to format. +#' @param ... Additional arguments passed to methods. +#' @returns A formatted character string. +#' @keywords internal +.format_summary <- function(x, ...) { + UseMethod(".format_summary") +} + +#' @rdname .format_summary +#' @export +.format_summary.data_summary <- function(x, ...) { + # method implementation +} +``` + +**S3 methods we don't own** (generic from another package) need standalone documentation: + +```r +#' Title describing the method +#' +#' @param x (`TYPE`) Description. +#' @param ... Additional arguments (ignored). +#' @returns Description. +#' @exportS3Method pkg::generic +method.class <- function(x, ...) { ... } +``` + +## Style notes + +**Cross-references:** Use square brackets — `[fetch_records()]` (internal), `[tibble::tibble()]` (external), `[topic_name]` (topics). + +**Section comment headers** optionally organize code within a file, lowercase with dashes to column 80: + +```r +# helpers ---------------------------------------------------------------------- +``` + +Only use such headers in complex files. The need for section comment headers might indicate that the file should be split into multiple files. + +**Examples:** Exported functions include `@examples`. Use `@examplesIf interactive()` for network-dependent or slow functions. Use section-style comments (`# Section ---`) to organize longer example blocks. Internal functions do not get examples. diff --git a/.github/skills/github/SKILL.md b/.github/skills/github/SKILL.md new file mode 100644 index 0000000..8fa04d5 --- /dev/null +++ b/.github/skills/github/SKILL.md @@ -0,0 +1,22 @@ +--- +name: github +trigger: from github +description: GitHub workflows using the `gh` CLI, including viewing issues/PRs and commit message conventions. Use when interacting with GitHub in any way, such as viewing, creating, or editing issues and pull requests, making commits, or running any `gh` command. +compatibility: Requires the `gh` CLI and an authenticated GitHub session. +--- + +# GitHub + +Use `gh` CLI, not web URLs: `gh issue view 123`, `gh issue list`, `gh pr view 456`, `gh pr list`. + +## Commit messages + +Conventional commits; backtick-quote function names; close issues in body with `- Closes #N`. + +``` +feat: add `create_skill()` + +Generates a new skill directory with a SKILL.md template. + +- Closes #3 +``` diff --git a/.github/skills/implement-issue/SKILL.md b/.github/skills/implement-issue/SKILL.md new file mode 100644 index 0000000..f17891d --- /dev/null +++ b/.github/skills/implement-issue/SKILL.md @@ -0,0 +1,53 @@ +--- +name: implement-issue +trigger: implement issue / work on #NNN +description: Implements a GitHub issue end-to-end. Use when asked to implement, work on, or fix a specific issue number. +compatibility: Requires the `gh` CLI and an authenticated GitHub session. +--- + +# Implement a GitHub issue + +This skill wraps the Standard Workflow defined in `AGENTS.md`. Run the steps below before and after that workflow. + +## Before the standard workflow + +**A. Read the issue in full:** + +```bash +gh issue view {number} +``` + +If `gh` is not authenticated, stop and ask the user to authenticate before continuing. + +**B. Check/create the branch:** + +- If on `main`: `usethis::pr_init("fix-{number}-{description}")` +- Branch format: `fix-{number}-{description}` + - Parts separated by hyphens; `{description}` uses snake_case + - Example: `fix-42-validate_input` +- If a branch already exists for this issue, check it out instead + +## Run the Standard Workflow from AGENTS.md + +Steps 1–9 of the Standard Workflow are the core development loop. + +## After the standard workflow + +**C. Commit and push:** + +1. Review commits already on this branch (not on `main`) — these are all part of the same PR and should inform the PR description: + ```bash + git log main..HEAD --oneline + ``` +2. Stage and commit all changes: + ```bash + git add -A + git commit -m "{short imperative summary}" + ``` +3. Push and open the PR: + ```bash + gh pr create --fill + ``` + Use `--title` and `--body` explicitly if `--fill` produces an inadequate description. + +This step may be overridden — the user may ask you to stop before committing, handle the push themselves, or complete only part of the workflow. Always follow explicit user instructions over these defaults. diff --git a/.github/skills/r-code/SKILL.md b/.github/skills/r-code/SKILL.md new file mode 100644 index 0000000..70350d8 --- /dev/null +++ b/.github/skills/r-code/SKILL.md @@ -0,0 +1,228 @@ +--- +name: r-code +trigger: writing R functions / API design / error handling +description: Guide for writing R code. Use when writing new functions, designing APIs, or reviewing/modifying existing R code. +--- + +# R code + +This skill covers how to design and write R functions — including naming conventions, signatures, API conventions, input validation, error handling, and common pitfalls. For documenting functions, use the `document` skill. For tests, use the `tdd-workflow` skill. + +## Naming conventions + +### Functions + +Functions use `snake_case` and should be **verbs or verb phrases** that describe what the function does: + +```r +fetch_records() +build_summary() +validate_input() +``` + +A function name should be descriptive enough to make its purpose clear without a comment. Prefer clarity over brevity — don't abbreviate unless there is a widely understood convention (e.g. `df` for data frame, `dir` for directory). + +Internal helpers use a dot prefix: + +```r +.parse_response() +.validate_columns() +``` + +### Parameters + +Parameters use `snake_case` and should generally be **nouns**, occasionally adjectives. The same rule applies: clarity over brevity. + +```r +# Good +fetch_records(file_path, page_size, overwrite) + +# Bad — unclear abbreviations +fetch_records(fp, ps, ow) +``` + +## File organization + +One exported function per file: `R/{function_name}.R` (e.g. `fetch_records()` → `R/fetch_records.R`). Internal helpers used exclusively by that function live in the same file. Shared helpers go in `R/utils.R` or `R/utils-{topic}.R` (e.g. `R/utils-parsing.R`). + +## Coding style + +- Always run `air format .` after generating code. +- Use the base pipe operator (`|>`) not the magrittr pipe (`%>%`). +- Use `\() ...` for single-line anonymous functions. For all other cases, use `function() {...}`. + +## Function design + +**Functional core, imperative shell** — pure, testable functions that accept data and return data form the core. The imperative shell orchestrates program flow, manages state, and calls the functional core. + +Functions should be **small and single-purpose**. Each function should operate at a **single level of abstraction**: it either orchestrates calls to other functions, or performs a direct operation on data, but does not mix the two. + +```r +# Orchestrator — delegates to focused helpers +build_report <- function(data, output_path) { + data <- .clean_data(data) + summary <- .compute_summary(data) + .write_report(summary, output_path) +} + +# Worker — performs one direct operation +.clean_data <- function(data) { + data |> + dplyr::filter(!is.na(value)) |> + dplyr::mutate(value = round(value, 2)) +} +``` + +Name functions well enough that their purpose is obvious from the call site. When reading the orchestrator above, each step is self-documenting — no comments needed. + +**Simplify control flow** — prefer guard clauses and returning early over complex if/else structures. + +**Pure conditionals** — the expression inside a conditional check should not cause side effects. Extract the pure check from the impure action into separate functions if needed. + +## General API design patterns + +**Enum-like arguments** — declare choices as the default vector; resolve with `rlang::arg_match()` at the top of the function: + +```r +summarize_data <- function(x, method = c("mean", "median")) { + method <- rlang::arg_match(method) + # method is now guaranteed to be "mean" or "median" +} +``` + +**`NULL` as "not provided"** — use `NULL` as the default for optional arguments where there is no sensible scalar fallback; check with `is.null()`: + +```r +fetch_records <- function(x, output_column = NULL) { + if (!is.null(output_column)) { ... } +} +``` + +**S3 object construction** — build as a named list, set class explicitly: + +```r +.new_summary <- function(values, method) { + out <- list(values = values, method = method) + class(out) <- c(paste0("summary_", method), "data_summary") + out +} +``` + +**`call` propagation in internal validators** — helpers that validate arguments and may throw errors should accept and forward `call`: + +```r +.check_non_empty <- function(x, call = rlang::caller_env()) { + if (length(x) == 0L) { + .pkg_abort("Input {.arg x} cannot be empty.", "empty_input", call = call) + } +} + +process_data <- function(x, call = rlang::caller_env()) { + .check_non_empty(x, call = call) + ... +} +``` + +**Return tibbles, not data frames:** + +```r +summarize_data <- function(x) { + result |> tibble::as_tibble() +} +``` + +## Input validation + +Use `stbl::to_*()` and `stbl::stabilize_*()` to validate parameters. These functions coerce when safe and fail with clear error messages when not. + +- **`to_*()`** — simple type coercion. Use when you need to ensure a parameter is the right type but don't need additional constraints. +- **`stabilize_*()`** — coercion plus content validation (regex, ranges, etc.). Use when simple type coercion isn't enough. + +**Validate in the function that uses the parameter**, not in a caller that passes it through. This preserves R's lazy evaluation — if a parameter is never used on a code path, it is never evaluated or validated. + +```r +# Good — validation happens where the parameter is used +build_report <- function(data, title, page_size) { + data <- .clean_data(data) + summary <- .compute_summary(data, page_size) + .write_report(summary, title) +} + +.compute_summary <- function(data, page_size, call = rlang::caller_env()) { + page_size <- stbl::to_int_scalar(page_size, call = call) + ... +} + +.write_report <- function(summary, title, call = rlang::caller_env()) { + title <- stbl::to_chr_scalar(title, call = call) + ... +} +``` + +```r +# Bad — validates everything eagerly, breaking lazy evaluation +build_report <- function(data, title, page_size) { + title <- stbl::to_chr_scalar(title) + page_size <- stbl::to_int_scalar(page_size) + ... +} +``` + +When `call` is available (because the function accepts it), always pass it to `stbl` calls so error messages point to the user's call frame. + +## Internal vs. exported functions + +Export a function when: +- Users will call it directly +- Other packages may want to extend it +- It is a stable, intentional part of the API + +Keep a function internal when: +- It is an implementation detail that may change +- It is only used within the package +- Exporting it would clutter the user-facing API + +Internal helpers use a dot prefix (e.g. `.parse_response()`). + +## Error handling + +Use `.pkg_abort()` (defined in `R/aaa-conditions.R`) rather than calling `cli::cli_abort()` directly. This wraps `stbl::pkg_abort()` and ensures consistent error class formatting: + +```r +.pkg_abort( + "Column {.field {name}} not found in {.arg data}.", + "column_not_found", + call = call +) +``` + +Always pass `call = call` (or `call = rlang::caller_env()`) so errors point to the user's call frame, not an internal helper. + +## Common package mistakes + +```r +# Never use library() inside package code +library(dplyr) # Wrong +dplyr::filter(...) # Right +# or `@importFrom dplyr filter` if used extensively + +# Never modify global state without restoring it +options(my_option = TRUE) # Wrong +withr::local_options(list(my_option = TRUE)) # Right + +# Use system.file() for package data, not hardcoded paths +read.csv("/home/user/data.csv") # Wrong +system.file("extdata", "data.csv", package = "mypkg") # Right +``` + +## Dependencies + +### Use existing imports first + +Packages already in `Imports` in `DESCRIPTION` should be preferred over base R equivalents: `purrr::map()` over `lapply()`, `rlang::is_*()` predicates over `is.*()`, and `withr::local_*()` over manual `on.exit()` state management. + +### When to add a new dependency + +Add a dependency when it provides significant functionality that would be complex or brittle to reimplement — date parsing, web requests, complex string manipulation. Stick with base R or existing imports when the solution is straightforward. + +**Adding a new dependency requires explicit discussion with the developer.** diff --git a/.github/skills/search-code/SKILL.md b/.github/skills/search-code/SKILL.md new file mode 100644 index 0000000..c5cf978 --- /dev/null +++ b/.github/skills/search-code/SKILL.md @@ -0,0 +1,84 @@ +--- +name: search-code +trigger: search / rewrite code +description: Search and rewrite R source code by syntax using astgrepr. Use when asked to find patterns in code, search for function calls, identify usage of specific arguments, locate structural patterns across R files, or perform find-and-replace on code structure. +--- + +# Search and rewrite code with astgrepr + +`{astgrepr}` enables AST-based code search — structural queries that regex can't express cleanly. If not installed: `install.packages("astgrepr")` + +```r +library(astgrepr) + +src <- " +add <- function(x, y) x + y +greet <- function(name, greeting, sep) paste0(greeting, sep, name) +square <- function(x) x^2 +" +root <- src |> tree_new() |> tree_root() # or tree_new(file = "R/my_file.R") + +# µNAME/µA/µB capture matched nodes; only `add` matches (2 params) +matches <- node_find_all(root, + ast_rule(id = "two_arg", pattern = "µNAME <- function(µA, µB) µBODY") +) +matches #> |--two_arg: 1 nodes +node_text_all(matches) # source text of each match +lapply(matches$two_arg, \(n) node_get_match(n, "NAME") |> node_text()) #> "add" +``` + +## Reference + +Metavariables: `µVAR` captures one node (uppercase only); `µµµ` captures zero or more (ellipsis). In *replacement* strings, refer to captures as `~~VAR~~`. + +`ast_rule()` — see `?ast_rule`. Key args: + +- `pattern` — code pattern with metavariables +- `kind` — tree-sitter node kind; see the [R grammar](https://github.com/r-lib/tree-sitter-r/blob/main/src/grammar.json) +- `regex` — match node text with a Rust regex +- `id` — names the rule; results accessible as `matches$id` +- `inside`, `has`, `precedes`, `follows` — relational rules, each takes another `ast_rule()` +- `all`, `any`, `not` — boolean combinators, each takes a list of `ast_rule()`s + +For advanced pattern syntax: [ast-grep pattern docs](https://ast-grep.github.io/guide/pattern-syntax.html). + +## Searching across files + +```r +lapply(list.files("R", pattern = "\\.R$", full.names = TRUE), \(f) { + root <- tree_root(tree_new(file = f)) + texts <- node_find_all(root, ast_rule(id = "r", pattern = "YOUR_PATTERN")) |> + node_text_all() |> _$r + if (length(texts) > 0) list(file = basename(f), matches = texts) +}) |> Filter(Negate(is.null), x = _) +``` + +## Patterns + +```r +ast_rule(pattern = "if (µµµ) { return(µµµ) } else µµµ") # if-else with return() +ast_rule(pattern = "util_fun(debug = µµµ)") # named argument +ast_rule(kind = "while_statement") # by node kind +ast_rule(pattern = "df$µCOL") # df$col, any column +ast_rule(pattern = "print(µA)", # relational: inside loop + inside = ast_rule(any = ast_rule(kind = c("for_statement", "while_statement")))) +ast_rule(any = list(ast_rule(pattern = "any(is.na(µµµ))"), # boolean OR + ast_rule(pattern = "any(duplicated(µµµ))"))) +``` + +## Find and replace + +See `?node_replace_all`, `?tree_rewrite`. + +```r +root <- tree_root(tree_new(file = "R/my_file.R")) +fixes <- root |> + node_find_all( + ast_rule(id = "any_na", pattern = "any(is.na(µVAR))"), + ast_rule(id = "any_dup", pattern = "any(duplicated(µVAR))") + ) |> + node_replace_all(any_na = "anyNA(~~VAR~~)", + any_dup = "anyDuplicated(~~VAR~~) > 0") +tree_rewrite(root, fixes) # preview +writeLines(tree_rewrite(root, fixes), con = "R/my_file.R") # write back +``` diff --git a/.github/skills/tdd-workflow/SKILL.md b/.github/skills/tdd-workflow/SKILL.md new file mode 100644 index 0000000..a480565 --- /dev/null +++ b/.github/skills/tdd-workflow/SKILL.md @@ -0,0 +1,250 @@ +--- +name: tdd-workflow +trigger: writing or reviewing tests +description: Test-driven development workflow. Use when writing any R code (writing new features, fixing bugs, refactoring, or reviewing tests). +--- + +# TDD workflow + +## Core principle + +Write a failing test first, then implement the minimal code to make it pass, then refactor. Never write implementation code without a failing test driving it. + +## File naming + +Tests for `R/{name}.R` go in `tests/testthat/test-{name}.R`. Place new tests next to similar existing ones. + +## Running tests + +```r +# Full suite +devtools::test(reporter = "check") + +# Single file +devtools::test(filter = "name", reporter = "check") +``` + +Testing functions load code automatically. You do not need to call `library()` or `devtools::load_all()` separately. + +## Coverage + +Goal: **100%** for every edited file. After editing `R/file_name.R`, verify: + +```r +covr_res <- devtools:::test_coverage_active_file("R/file_name.R") +which(purrr::map_int(covr_res, "value") == 0) +``` + +Files excluded from the coverage requirement: +- `R/*-package.R` +- `R/aaa-shared_params.R` +- Files matching `R/import-standalone-*.R` + +## Test types + +### Unit tests + +Test individual functions in isolation: + +```r +test_that("fetch_records() returns a tibble (#2)", { + result <- fetch_records(sample_input) + expect_s3_class(result, "tbl_df") +}) +``` + +### Integration tests + +Test end-to-end pipelines through multiple functions: + +```r +test_that("build_report() produces expected output (#15)", { + input <- data.frame(value = c(1.123, 2.456, NA)) + result <- build_report(input, tempfile()) + expect_equal(nrow(result), 2L) +}) +``` + +### Snapshot tests + +For complex outputs that are hard to specify with equality assertions: + +```r +test_that("build_summary print method is stable (#123)", { + expect_snapshot(print(build_summary(sample_data))) +}) +``` + +When snapshots change intentionally, check the content of the file corresponding to the edited test file, then accept: + +```r +testthat::snapshot_accept("test_name") +``` + +Snapshots are stored in `tests/testthat/_snaps/`. The filename corresponds to the R file being tested, ending with `.md`. + +## Test design principles + +- **Self-sufficient:** each test contains its own setup, execution, and assertion. Tests must be runnable in isolation. +- **Duplication over factoring:** repeat setup code rather than extracting it. Clarity beats DRY in tests. +- **One concept per test:** a failing test should tell you exactly what broke. +- **Minimal with few comments:** keep tests lean. Avoid over-commenting. +- **Issue reference in description:** the `desc` of every new `test_that()` call should end with one or more parenthetical issue references for the issue(s) *verified by those tests* — typically the issue currently being solved. **Never guess or invent issue numbers.** Determine the number from the user's prompt, the branch name (`git branch --show-current`), or `gh issue list`. Before writing a number, verify you can trace it to one of these sources. If no tracked issue applies, use `#noissue`. The numbers in the examples below are illustrative placeholders — do not copy them: + ```r + test_that("fetch_records() returns correct columns (#1)", { ... }) + test_that("build_summary() returns correct columns (#2, #3)", { ... }) + test_that(".check_record() errors on empty input (#noissue)", { ... }) + ``` + +## testthat Edition 3 — deprecated patterns + +```r +# Deprecated → Modern +context("Data validation") # Remove — filename serves this purpose +expect_equivalent(x, y) # expect_equal(x, y, ignore_attr = TRUE) +with_mock(...) # local_mocked_bindings(...) +expect_is(x, "data.frame") # expect_s3_class(x, "data.frame") +``` + +## Essential expectations + +### Equality & identity + +```r +expect_equal(x, y) # with numeric tolerance +expect_equal(x, y, tolerance = 0.001) +expect_equal(x, y, ignore_attr = TRUE) +expect_identical(x, y) # exact match +``` + +### Conditions + +**Errors thrown by this package** (via `.pkg_abort()`) should always be tested +with `stbl::expect_pkg_error_snapshot()`, which captures both the error class +hierarchy and the user-facing message in one snapshot: + +```r +test_that("process_data() errors on empty input (#42)", { + stbl::expect_pkg_error_snapshot( + process_data(data.frame()), + "beekeeper", + "empty_input" + ) +}) +``` + +Pass `transform = stbl::.transform_path(path)` to scrub volatile values (e.g. temp +paths) from the snapshot before comparison. + +**Errors thrown by `stbl`** (via `stbl::to_*()` / `stbl::stabilize_*()`) +should be tested with `stbl::expect_pkg_error_classes()`. Since the message +text is controlled by `stbl`, only the class hierarchy needs to be asserted: + +```r +test_that("process_data() errors on non-integer page_size (#43)", { + stbl::expect_pkg_error_classes( + process_data(sample_data, page_size = "abc"), + "stbl", + "incompatible_type" + ) +}) +``` + +For **composite** stbl error classes (where the class name contains dashes, +e.g. `stbl-error-coerce-character`), pass each dash-separated component as a +separate argument. Underscores within a component are kept as-is: + +```r +test_that("process_data() errors on non-coercible input (#43)", { + stbl::expect_pkg_error_classes( + process_data(sample_data, value = list(bad = "input")), + "stbl", + "coerce", + "character" + ) +}) +``` + +**Errors from other packages** can be tested with `expect_error()`, optionally +wrapped in `expect_snapshot()` to lock down the message text: + +```r +expect_error(code, "pattern") +expect_error(code, class = "some-error-class") + +# Lock down both class and message text: +test_that("fetch_records errors on invalid input (#456)", { + expect_snapshot( + (expect_error( + fetch_records("not valid input"), + class = "pkg-error" + )) + ) +}) +``` + +```r +expect_warning(code) +expect_no_warning(code) +expect_message(code) +expect_no_message(code) +``` + +### Collections + +```r +expect_setequal(x, y) # same elements, any order +expect_in(element, set) +expect_named(x, c("a", "b")) +``` + +### Type & structure + +```r +expect_type(x, "double") +expect_s3_class(x, "tbl_df") +expect_length(x, 10) +expect_null(x) +``` + +### Logical + +These expectations are a last resort when more-specific checks aren't available. + +```r +expect_true(x) +expect_false(x) +``` + +## `withr` patterns for temporary state + +```r +withr::local_options(list(beekeeper.verbose = TRUE)) +withr::local_envvar(MY_VAR = "value") +withr::local_tempfile(lines = c("a", "b")) +``` + +## Fixtures + +Store static test data in `tests/testthat/fixtures/` and access via: + +```r +test_path("fixtures", "sample.rds") +``` + +## Mocking + +Mock functions that might have unstable output, hit external servers, etc. + +```r +local_mocked_bindings( + .other_fn = function(...) "mocked_result" +) +result <- my_function_that_calls_other_fn() +``` + +## Common mistakes + +- **Do not modify tests to make them pass.** Fix the implementation. +- **Do not write tests that depend on other tests' state.** Each test must be independently runnable. +- **Ask for help if test is bad.** If you think a test might be invalid, do not loop through trying to make impossible tests pass. Ask for help if possible. diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 8127dc4..efb3807 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -11,6 +11,7 @@ name: R-CMD-check jobs: R-CMD-check: runs-on: ${{ matrix.config.os }} + container: ${{ matrix.config.container }} name: ${{ matrix.config.os }} (${{ matrix.config.r }}) @@ -18,31 +19,24 @@ jobs: fail-fast: false matrix: config: - - {os: ubuntu-latest, r: 'release'} - {os: macos-latest, r: 'release'} - {os: windows-latest, r: 'release'} - - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} - - {os: ubuntu-latest, r: 'oldrel-1'} - - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - R_KEEP_PKG_SOURCE: yes + - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release', container: 'ghcr.io/api2r/pkgskills-ci:devel'} + - {os: ubuntu-latest, r: 'release', container: 'ghcr.io/api2r/pkgskills-ci:release'} + - {os: ubuntu-latest, r: 'oldrel-1', container: 'ghcr.io/api2r/pkgskills-ci:oldrel-1'} steps: - - uses: actions/checkout@v3 - - - uses: r-lib/actions/setup-pandoc@v2 + - uses: actions/checkout@v6 - - uses: r-lib/actions/setup-r@v2 + - uses: api2r/actions/install@v1 with: + use-container: "${{ matrix.config.container != '' }}" + token: ${{ secrets.GITHUB_TOKEN }} r-version: ${{ matrix.config.r }} http-user-agent: ${{ matrix.config.http-user-agent }} - use-public-rspm: true - - - uses: r-lib/actions/setup-r-dependencies@v2 - with: - extra-packages: any::rcmdcheck needs: check + extra-packages: any::rcmdcheck + cache-version: "1" - uses: r-lib/actions/check-r-package@v2 with: diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000..e1907e1 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,52 @@ +name: "Copilot Setup Steps" + +# Automatically run when the file changes to allow for easy validation, +# and allow manual testing through the repository's "Actions" tab. +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + + - uses: api2r/actions/install@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + cache-version: copilot + needs: build, check, website + # Extra packages include things referenced in skills, to make sure the + # agent has them available. + optional-packages: any::astgrepr + extra-packages: >- + any::cli + any::covr + any::devtools + any::magick + any::pak + any::pkgdown + any::purrr + any::rcmdcheck + any::rlang + any::roxygen2 + any::stbl + any::testthat + any::usethis + any::withr + Gilead-BioStats/qcthat + local::. + + - name: Install air + uses: posit-dev/setup-air@v1 diff --git a/.github/workflows/format-suggest.yaml b/.github/workflows/format-suggest.yaml new file mode 100644 index 0000000..af50210 --- /dev/null +++ b/.github/workflows/format-suggest.yaml @@ -0,0 +1,46 @@ +# Workflow derived from https://github.com/posit-dev/setup-air/tree/main/examples + +on: + # Using `pull_request_target` over `pull_request` for elevated `GITHUB_TOKEN` + # privileges, otherwise we can't set `pull-requests: write` when the pull + # request comes from a fork, which is our main use case (external contributors). + # + # `pull_request_target` runs in the context of the target branch (`main`, usually), + # rather than in the context of the pull request like `pull_request` does. Due + # to this, we must explicitly checkout `ref: ${{ github.event.pull_request.head.sha }}`. + # This is typically frowned upon by GitHub, as it exposes you to potentially running + # untrusted code in a context where you have elevated privileges, but they explicitly + # call out the use case of reformatting and committing back / commenting on the PR + # as a situation that should be safe (because we aren't actually running the untrusted + # code, we are just treating it as passive data). + # https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + pull_request_target: + +name: format-suggest.yaml + +jobs: + format-suggest: + name: format-suggest + runs-on: ubuntu-latest + + permissions: + # Required to push suggestion comments to the PR + pull-requests: write + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Install + uses: posit-dev/setup-air@v1 + + - name: Format + run: air format . + + - name: Suggest + uses: reviewdog/action-suggester@v1 + with: + level: error + fail_level: error + tool_name: air diff --git a/.github/workflows/pkgdown-cleanup.yaml b/.github/workflows/pkgdown-cleanup.yaml new file mode 100644 index 0000000..d8ea7c2 --- /dev/null +++ b/.github/workflows/pkgdown-cleanup.yaml @@ -0,0 +1,33 @@ +# This workflow removes the pkgdown preview directory when a PR is closed. +name: Clean up pkgdown preview + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +jobs: + clean-pr-preview: + runs-on: ubuntu-latest + steps: + - name: Checkout gh-pages branch + uses: actions/checkout@v6 + with: + repository: ${{ github.repository }} + token: ${{ secrets.GITHUB_TOKEN }} + ref: gh-pages + + - name: Remove PR preview directory + run: | + pr_dir="pr/${{ github.event.number }}" + if [ -d "$pr_dir" ]; then + git config --local user.name "$GITHUB_ACTOR" + git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com" + git rm -rf "$pr_dir" + git commit -m "Remove preview for PR #${{ github.event.number }}" + git push + else + echo "Directory $pr_dir does not exist, skipping cleanup." + fi diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 087f0b0..7f4f411 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -2,9 +2,9 @@ # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help on: push: - branches: [main, master] + branches: [main] pull_request: - branches: [main, master] + branches: [main] release: types: [published] workflow_dispatch: @@ -14,32 +14,59 @@ name: pkgdown jobs: pkgdown: runs-on: ubuntu-latest + container: + image: ghcr.io/api2r/pkgskills-ci:release # Only restrict concurrency for non-PR jobs concurrency: group: pkgdown-${{ github.event_name != 'pull_request' || github.run_id }} env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: write + pull-requests: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - - uses: r-lib/actions/setup-pandoc@v2 - - - uses: r-lib/actions/setup-r@v2 - with: - use-public-rspm: true - - - uses: r-lib/actions/setup-r-dependencies@v2 + - uses: api2r/actions/install@v1 with: - extra-packages: any::pkgdown, local::. + use-container: "true" + token: ${{ secrets.GITHUB_TOKEN }} needs: website + extra-packages: any::pkgdown gilead-biostats/qcthat local::. any::glue - name: Build site run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) shell: Rscript {0} + - name: Deploy PR preview 🧪 + if: github.event_name == 'pull_request' + uses: JamesIves/github-pages-deploy-action@v4.8.0 + with: + clean: false + branch: gh-pages + folder: docs + target-folder: pr/${{ github.event.number }} + + - name: Comment with PR site URL 🌐 + if: github.event_name == 'pull_request' + run: | + intPRNumber <- ${{ github.event.pull_request.number }} + strOwner <- tolower(qcthat::GetGHOwner()) + strRepo <- qcthat::GetGHRepo() + strURL <- glue::glue( + "https://{strOwner}.github.io/{strRepo}/pr/{intPRNumber}" + ) + print(paste("🌐 URL:", strURL)) + qcthat::CommentIssue( + intPRNumber, + glue::glue("🌐 [PR pkgdown deployed]({strURL})"), + NULL + ) + shell: Rscript {0} + - name: Deploy to GitHub pages 🚀 if: github.event_name != 'pull_request' - uses: JamesIves/github-pages-deploy-action@v4.4.1 + uses: JamesIves/github-pages-deploy-action@v4.8.0 with: clean: false branch: gh-pages diff --git a/.github/workflows/pr-commands.yaml b/.github/workflows/pr-commands.yaml index 71f335b..8c2f575 100644 --- a/.github/workflows/pr-commands.yaml +++ b/.github/workflows/pr-commands.yaml @@ -11,23 +11,23 @@ jobs: if: ${{ github.event.issue.pull_request && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') && startsWith(github.event.comment.body, '/document') }} name: document runs-on: ubuntu-latest - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + container: + image: ghcr.io/api2r/pkgskills-ci:release + permissions: + contents: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - uses: r-lib/actions/pr-fetch@v2 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: r-lib/actions/setup-r@v2 + - uses: api2r/actions/install@v1 with: - use-public-rspm: true - - - uses: r-lib/actions/setup-r-dependencies@v2 - with: - extra-packages: any::roxygen2 + use-container: "true" + token: ${{ secrets.GITHUB_TOKEN }} needs: pr-document + extra-packages: any::roxygen2 - name: Document run: roxygen2::roxygenise() @@ -43,37 +43,3 @@ jobs: - uses: r-lib/actions/pr-push@v2 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - - style: - if: ${{ github.event.issue.pull_request && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') && startsWith(github.event.comment.body, '/style') }} - name: style - runs-on: ubuntu-latest - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v3 - - - uses: r-lib/actions/pr-fetch@v2 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - uses: r-lib/actions/setup-r@v2 - - - name: Install dependencies - run: install.packages("styler") - shell: Rscript {0} - - - name: Style - run: styler::style_pkg() - shell: Rscript {0} - - - name: commit - run: | - git config --local user.name "$GITHUB_ACTOR" - git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com" - git add \*.R - git commit -m 'Style' - - - uses: r-lib/actions/pr-push@v2 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/qcthat.yaml b/.github/workflows/qcthat.yaml new file mode 100644 index 0000000..2adec84 --- /dev/null +++ b/.github/workflows/qcthat.yaml @@ -0,0 +1,157 @@ +# Workflow derived from +# https://github.com/Gilead-BioStats/qcthat/tree/v1.1.1/inst/workflows/qcthat.yaml. +on: + pull_request: + types: [opened, edited, reopened, synchronize, milestoned] + release: + types: [released] + issues: + types: [closed] + workflow_dispatch: + inputs: + pr: + description: PR number to which reports should be added (leave blank for none). + required: false + milestone: + description: Milestone name to use for the milestone report (leave blank for none). + required: false + tag: + description: Release tag to which the report should be attached (leave blank for none). + required: false + issueNumber: + description: The closed issue number to process to update user acceptance testing information. + required: false + +name: qcthat Quality Control + +permissions: + # read: Required for generating reports and updating UAT status. + # write: Required for initiating the UAT process. + issues: write + # read: Required for updating UAT status. + # write: Required for adding reports to pull requests. + pull-requests: write + # write: Required for attaching reports to releases. + contents: write + # write: Required for updating UAT status. + actions: write + +# Configuration variables for controlling workflow behavior +env: + qcthat_UAT: true + qcthat_PR_REPORT: true + qcthat_COMPLETED_REPORT: true + qcthat_MILESTONE_REPORT: true + qcthat_RELEASE_REPORT: true + qcthat_FAIL_FOR_TEST_FAILURES: true + +jobs: + qcthat: + runs-on: ubuntu-latest + container: + image: ghcr.io/api2r/pkgskills-ci:release + if: >- + (github.event_name == 'issues' && contains(github.event.issue.labels.*.name, 'qcthat-uat')) || + github.event_name != 'issues' + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + + - uses: api2r/actions/install@v1 + with: + use-container: "true" + token: ${{ secrets.GITHUB_TOKEN }} + extra-packages: Gilead-BioStats/qcthat@main local::. + + - name: Manage User Acceptance Testing + if: >- + env.qcthat_UAT == 'true' && ( + (github.event_name == 'issues' && contains(github.event.issue.labels.*.name, 'qcthat-uat')) || + (github.event_name == 'workflow_dispatch' && inputs.issueNumber != '') + ) + run: | + Rscript -e "qcthat::TriggerUAT()" + + - name: Generate Issue-Test Matrix + if: >- + (env.qcthat_PR_REPORT == 'true' || env.qcthat_RELEASE_REPORT == 'true' || env.qcthat_FAIL_FOR_TEST_FAILURES == 'true') && ( + github.event_name == 'pull_request' || + github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && inputs.issueNumber == '') + ) + run: | + # Generate the full matrix for the package + IssueTestMatrix <- qcthat::QCPackage() + print(IssueTestMatrix) + + # Save the matrix and UAT data for subsequent steps + saveRDS(IssueTestMatrix, "ITM.rds") + qcthat::SaveUATIssues() + shell: Rscript {0} + + - name: Update PR Reports + if: >- + env.qcthat_PR_REPORT == 'true' && ( + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && inputs.pr != '') + ) + run: | + issueTestMatrix <- readRDS("ITM.rds") + qcthat::LoadUATIssues() + qcthat::CommentAllReports( + dfITM = issueTestMatrix, + lglPR = as.logical("${{ env.qcthat_PR_REPORT }}"), + lglMilestone = as.logical("${{ env.qcthat_MILESTONE_REPORT }}"), + lglCompleted = as.logical("${{ env.qcthat_COMPLETED_REPORT }}"), + lglUAT = as.logical("${{ env.qcthat_UAT }}") + ) + shell: Rscript {0} + + - name: Update Release Reports + if: >- + env.qcthat_RELEASE_REPORT == 'true' && ( + github.event_name == 'release' || inputs.tag != '' + ) + run: | + issueTestMatrix <- readRDS("ITM.rds") + qcthat::LoadUATIssues() + qcthat::AttachReleaseReports( + dfITM = issueTestMatrix, + lglCompleted = as.logical("${{ env.qcthat_COMPLETED_REPORT }}"), + lglMilestone = as.logical("${{ env.qcthat_MILESTONE_REPORT }}") + ) + shell: Rscript {0} + + - name: Flag failure for PR + if: >- + env.qcthat_FAIL_FOR_TEST_FAILURES == 'true' && ( + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && inputs.pr != '') + ) + run: | + issueTestMatrix <- readRDS("ITM.rds") + dfPR <- qcthat::QCPR(dfITM = issueTestMatrix) + if (any(dfPR$Disposition == "fail", na.rm = TRUE)) { + cli::cli_abort( + "One or more tests failed or were skipped for PR-associated issues." + ) + } + shell: Rscript {0} + + - name: Flag failure for completed + if: >- + env.qcthat_FAIL_FOR_TEST_FAILURES == 'true' && ( + github.event_name == 'pull_request' || + github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && inputs.issueNumber == '') + ) + run: | + issueTestMatrix <- readRDS("ITM.rds") + dfCompleted = qcthat::QCCompletedIssues(dfITM = issueTestMatrix) + if (any(dfCompleted$Disposition == "fail", na.rm = TRUE)) { + cli::cli_abort( + "One or more tests failed or were skipped for completed issues." + ) + } + shell: Rscript {0} diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 2c5bb50..8b7a5f3 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -11,40 +11,48 @@ name: test-coverage jobs: test-coverage: runs-on: ubuntu-latest - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - + container: + image: ghcr.io/api2r/pkgskills-ci:release steps: - - uses: actions/checkout@v3 - - - uses: r-lib/actions/setup-r@v2 - with: - use-public-rspm: true + - uses: actions/checkout@v6 - - uses: r-lib/actions/setup-r-dependencies@v2 + - uses: api2r/actions/install@v1 with: - extra-packages: any::covr + use-container: "true" + token: ${{ secrets.GITHUB_TOKEN }} needs: coverage + extra-packages: any::covr any::xml2 - name: Test coverage run: | - covr::codecov( + cov <- covr::package_coverage( quiet = FALSE, clean = FALSE, - install_path = file.path(Sys.getenv("RUNNER_TEMP"), "package") + install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package") ) + print(cov) + covr::to_cobertura(cov) shell: Rscript {0} + - uses: codecov/codecov-action@v5 + with: + # Fail if error if not on PR, or if on PR and token is given + fail_ci_if_error: ${{ github.event_name != 'pull_request' || secrets.CODECOV_TOKEN }} + files: ./cobertura.xml + plugins: noop + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} + - name: Show testthat output if: always() run: | ## -------------------------------------------------------------------- - find ${{ runner.temp }}/package -name 'testthat.Rout*' -exec cat '{}' \; || true + find '${{ runner.temp }}/package' -name 'testthat.Rout*' -exec cat '{}' \; || true shell: bash - name: Upload test results if: failure() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v6 with: name: coverage-test-failures path: ${{ runner.temp }}/package diff --git a/.gitignore b/.gitignore index 2e563c9..0cc1754 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ .DS_Store docs inst/doc -_beekeeper.yml +/_beekeeper.yml exploration -_beekeeper_rapid.rds +/_beekeeper_rapid.rds +_slackapi_rapid.rds +.positai +conversation-export.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..344f76e --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "Posit.air-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a9f69fe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "[r]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "Posit.air-vscode" + }, + "[quarto]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "quarto.quarto" + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4d44d53 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# AGENTS.md + +## Repository overview + +**beekeeper** — Rapidly Scaffold API Client Packages + +Automatically generate R package skeletons from 'application + programming interfaces (APIs)' that follow the 'OpenAPI Specification + (OAS)'. The skeletons implement best practices to streamline package + development. + +https://beekeeper.api2r.org, https://github.com/jonthegeek/beekeeper + +### Overall structure + +The project follows standard R package conventions with these key directories: + +beekeeper/ +├── R/ # R source code +│ ├── beekeeper-package.R # Auto-generated package docs +│ └── *.R # Function definitions, 1 file ~= 1 exported function +├── .github/ +│ ├── ISSUE_TEMPLATE/ # GitHub issue templates +│ ├── skills/ # Agent skill definitions +│ └── workflows/ # CI/CD configurations +├── tests/testthat/ # Test suite +├── man/ # Generated documentation +├── AGENTS.md # Main agent setup file +├── DESCRIPTION # Package metadata +├── NAMESPACE # Auto-generated export information +├── NEWS.md # Changelog +└── Various config files # .gitignore, codecov.yml, etc. + +--- + +## Standard workflow + +For any feature, fix, or refactor: + +1. **Update packages**: `pak::pak()` +2. **Run tests** — confirm passing before changes: `devtools::test(reporter = "check")`. If any fail, stop and ask. +3. **Plan** — identify affected R files; check if new exports are needed. +4. **Test first** — write failing test, then implement: `devtools::test(filter = "name", reporter = "check")`. +5. **Implement** — minimal code to pass tests. +6. **Refactor** — clean up, keep tests green. +7. **Document** — document any new or changed exports. +8. **Verify**: Run `devtools::test(reporter = "check")`, then `devtools::check(error_on = "warning")`. Resolve warnings, errors, and NOTEs. +9. **News** — add bullet at top of `NEWS.md` (under dev heading): + - User-facing changes only. 1 line, end with `.` + - Present tense, positive framing, function names (backticks + `()`) near start: `` * `fn()` now accepts ... `` not `* Fixed ...` + - Issue/contributor before final period: `` * `fn()` now accepts ... (@user, #N). `` where `#N` is the GitHub issue number being implemented (e.g. `#42`). + - Get username: `gh api user --jq .login`; get issue number from the user's prompt, the branch name (`git branch --show-current`), or `gh issue list`. + - **Never guess or invent an issue number.** Before writing it, verify: (1) you received it from the user or the branch name, OR (2) you looked it up with `gh`. If you cannot trace the number to a concrete source, use `#noissue`. + +--- + +## General + +- R console: use `--quiet --vanilla`. +- Always run `air format .` after generating R code. +- Comments explain *why*, not *what*. + +## Skills + +| Triggers | Path | +|----------|------| +| create GitHub issues | @.github/skills/create-issue/SKILL.md | +| document functions | @.github/skills/document/SKILL.md | +| from github | @.github/skills/github/SKILL.md | +| implement issue / work on #NNN | @.github/skills/implement-issue/SKILL.md | +| writing R functions / API design / error handling | @.github/skills/r-code/SKILL.md | +| search / rewrite code | @.github/skills/search-code/SKILL.md | +| writing or reviewing tests | @.github/skills/tdd-workflow/SKILL.md | diff --git a/DESCRIPTION b/DESCRIPTION index a869324..32a370c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -13,11 +13,11 @@ Description: Automatically generate R package skeletons from 'application License: MIT + file LICENSE URL: https://beekeeper.api2r.org, https://github.com/jonthegeek/beekeeper BugReports: https://github.com/jonthegeek/beekeeper/issues -Depends: - R (>= 3.5.0) -Imports: +Depends: + R (>= 4.1.0) +Imports: cli, - desc, + dplyr, fs, glue, httptest2, @@ -28,25 +28,31 @@ Imports: rprojroot, S7, snakecase, + stbl (>= 0.3.0), stringr, - styler, testthat, tibble, tidyr, usethis, utils, yaml -Suggests: +Suggests: covr, + desc, + janitor, knitr, rmarkdown, + rvest, + whisker, withr -VignetteBuilder: +VignetteBuilder: knitr Remotes: - jonthegeek/nectar, - jonthegeek/rapid + api2r/nectar, + api2r/rapid +Config/roxygen2/version: 8.0.0 Config/testthat/edition: 3 Encoding: UTF-8 +Language: en-US Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.1 +RoxygenNote: 8.0.0 diff --git a/NAMESPACE b/NAMESPACE index 1f235b4..6b4ac9a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,7 +1,6 @@ # Generated by roxygen2: do not edit by hand export(generate_pkg) -export(generate_pkg_agent) export(use_beekeeper) if (getRversion() < "4.3.0") importFrom("S7", "@") importFrom(S7,class_any) @@ -9,26 +8,21 @@ importFrom(S7,class_data.frame) importFrom(S7,class_list) importFrom(cli,cli_abort) importFrom(cli,cli_warn) -importFrom(desc,desc) importFrom(fs,file_delete) importFrom(fs,file_exists) -importFrom(fs,is_dir) importFrom(fs,path) importFrom(fs,path_dir) -importFrom(fs,path_file) importFrom(fs,path_rel) importFrom(glue,glue) importFrom(glue,glue_collapse) importFrom(httptest2,use_httptest2) -importFrom(nectar,stabilize_string) +importFrom(nectar,req_prepare) importFrom(purrr,discard) importFrom(purrr,imap) -importFrom(purrr,list_rbind) importFrom(purrr,map) importFrom(purrr,map2) importFrom(purrr,map_chr) importFrom(purrr,pmap) -importFrom(purrr,quietly) importFrom(rapid,as_rapid) importFrom(rapid,class_api_key_security_scheme) importFrom(rapid,class_paths) @@ -38,17 +32,7 @@ importFrom(rlang,.data) importFrom(rlang,check_dots_empty) importFrom(rlang,set_names) importFrom(rlang,try_fetch) -importFrom(rprojroot,find_package_root_file) -importFrom(snakecase,to_snake_case) -importFrom(stringr,str_remove) -importFrom(stringr,str_replace_all) -importFrom(stringr,str_squish) -importFrom(stringr,str_to_sentence) -importFrom(styler,style_file) importFrom(testthat,test_that) -importFrom(tibble,as_tibble) -importFrom(tidyr,nest) -importFrom(tidyr,unnest) importFrom(usethis,proj_get) importFrom(usethis,proj_path) importFrom(usethis,use_build_ignore) @@ -57,5 +41,3 @@ importFrom(usethis,use_package) importFrom(usethis,use_template) importFrom(usethis,use_testthat) importFrom(utils,capture.output) -importFrom(yaml,read_yaml) -importFrom(yaml,write_yaml) diff --git a/R/aaa-conditions.R b/R/aaa-conditions.R new file mode 100644 index 0000000..1e416a3 --- /dev/null +++ b/R/aaa-conditions.R @@ -0,0 +1,22 @@ +#' Raise a package-scoped error +#' +#' @inheritParams .shared-params +#' @inheritParams stbl::pkg_abort +#' @returns Does not return. +#' @keywords internal +.pkg_abort <- function( + message, + subclass, + call = rlang::caller_env(), + message_env = rlang::caller_env(), + ... +) { + stbl::pkg_abort( + "beekeeper", + message, + subclass, + call = call, + message_env = message_env, + ... + ) +} diff --git a/R/aaa-shared_params.R b/R/aaa-shared_params.R new file mode 100644 index 0000000..b578a6c --- /dev/null +++ b/R/aaa-shared_params.R @@ -0,0 +1,10 @@ +#' Shared parameters +#' +#' These parameters are used in multiple functions. They are defined here to +#' make them easier to import and to find. +#' +#' @param call (`environment`) The caller environment for error messages. +#' +#' @name .shared-params +#' @keywords internal +NULL diff --git a/R/beekeeper-package.R b/R/beekeeper-package.R index 9005276..a146a86 100644 --- a/R/beekeeper-package.R +++ b/R/beekeeper-package.R @@ -2,28 +2,24 @@ "_PACKAGE" ## usethis namespace: start +# Needed for installation: nectar (likely others, TBD) #' @importFrom cli cli_abort #' @importFrom cli cli_warn -#' @importFrom desc desc #' @importFrom fs file_delete #' @importFrom fs file_exists -#' @importFrom fs is_dir #' @importFrom fs path #' @importFrom fs path_dir -#' @importFrom fs path_file #' @importFrom fs path_rel #' @importFrom glue glue #' @importFrom glue glue_collapse #' @importFrom httptest2 use_httptest2 -#' @importFrom nectar stabilize_string +#' @importFrom nectar req_prepare #' @importFrom purrr discard #' @importFrom purrr imap -#' @importFrom purrr list_rbind #' @importFrom purrr map #' @importFrom purrr map_chr #' @importFrom purrr map2 #' @importFrom purrr pmap -#' @importFrom purrr quietly #' @importFrom rapid as_rapid #' @importFrom rapid class_api_key_security_scheme #' @importFrom rapid class_paths @@ -33,20 +29,10 @@ #' @importFrom rlang check_dots_empty #' @importFrom rlang set_names #' @importFrom rlang try_fetch -#' @importFrom rprojroot find_package_root_file #' @importFrom S7 class_any #' @importFrom S7 class_data.frame #' @importFrom S7 class_list -#' @importFrom snakecase to_snake_case -#' @importFrom stringr str_remove -#' @importFrom stringr str_replace_all -#' @importFrom stringr str_squish -#' @importFrom stringr str_to_sentence -#' @importFrom styler style_file #' @importFrom testthat test_that -#' @importFrom tibble as_tibble -#' @importFrom tidyr nest -#' @importFrom tidyr unnest #' @importFrom usethis proj_get #' @importFrom usethis proj_path #' @importFrom usethis use_build_ignore @@ -55,7 +41,5 @@ #' @importFrom usethis use_template #' @importFrom usethis use_testthat #' @importFrom utils capture.output -#' @importFrom yaml read_yaml -#' @importFrom yaml write_yaml ## usethis namespace: end NULL diff --git a/R/generate_pkg-agent.R b/R/generate_pkg-agent.R deleted file mode 100644 index 56fbf19..0000000 --- a/R/generate_pkg-agent.R +++ /dev/null @@ -1,31 +0,0 @@ -#' Create a user agent for the active package -#' -#' @param path The path to the DESCRIPTION file, or to a directory within a -#' package. -#' -#' @return A string with the name of the package and (if available) the first -#' URL associated with the package. -#' -#' @export -generate_pkg_agent <- function(path = ".") { - if (!is_dir(path) && path_file(path) != "DESCRIPTION") { - path <- path_dir(path) # nocov - } - pkg_desc <- desc(file = path) - pkg_name <- pkg_desc$get_field("Package") - pkg_url <- .get_pkg_url(pkg_desc) - return( - glue("{pkg_name}{pkg_url}") - ) -} - -.get_pkg_url <- function(pkg_desc) { - pkg_url_glue <- "" - pkg_url <- pkg_desc$get_urls() - if (length(pkg_url)) { - pkg_url_glue <- glue( - " ({pkg_url[[1]]})" - ) - } - return(pkg_url_glue) -} diff --git a/R/generate_pkg-call.R b/R/generate_pkg-call.R deleted file mode 100644 index 8e0e04e..0000000 --- a/R/generate_pkg-call.R +++ /dev/null @@ -1,31 +0,0 @@ -.generate_call <- function(config, api_definition, pkg_agent, security_data) { - touched_files <- c( - .generate_call_r(config, api_definition, pkg_agent, security_data), - .generate_call_test(config$api_abbr) - ) - return(touched_files) -} - -.generate_call_r <- function(config, api_definition, pkg_agent, security_data) { - .bk_use_template( - template = "010-call.R", - data = list( - api_title = config$api_title, - api_abbr = config$api_abbr, - base_url = api_definition@servers@url, - pkg_agent = pkg_agent, - has_security = security_data$has_security, - security_arg_helps = security_data$security_arg_helps, - security_signature = security_data$security_signature, - security_arg_list = security_data$security_arg_list - ) - ) -} - -.generate_call_test <- function(api_abbr) { - .bk_use_template( - template = "test-010-call.R", - dir = "tests/testthat", - data = list(api_abbr = api_abbr) - ) -} diff --git a/R/generate_pkg-pagination.R b/R/generate_pkg-pagination.R new file mode 100644 index 0000000..8e70eaa --- /dev/null +++ b/R/generate_pkg-pagination.R @@ -0,0 +1,4 @@ +.generate_pagination <- function() { + # TODO: Accept pagination config and generate 030-pagination.R (#63) + list() +} diff --git a/R/generate_pkg-paths.R b/R/generate_pkg-paths.R index d6d9615..385b9e1 100644 --- a/R/generate_pkg-paths.R +++ b/R/generate_pkg-paths.R @@ -1,13 +1,18 @@ -.generate_paths <- function(paths, api_abbr, security_data, base_url) { - # TODO: Do any APIDs lack tags? - # TODO: Do any APIDs have multiple tags? - paths_by_tag <- as_bk_data(paths) +.generate_paths <- function( + paths, + api_abbr, + security_data, + pagination_data = list(), + base_url +) { + paths_by_operation <- as_bk_data(paths) paths_file_paths <- character() - if (length(paths_by_tag)) { + if (length(paths_by_operation)) { paths_file_paths <- .generate_paths_files( - paths_by_tag, + paths_by_operation, api_abbr, - security_data + security_data, + pagination_data ) setup_file <- .bk_use_template( template = "setup.R", @@ -25,95 +30,110 @@ S7::method(as_bk_data, class_paths) <- function(x) { if (!length(x)) { return(list()) } - paths_tags_df <- .paths_to_tags_df(as_tibble(x)) - return(.paths_to_tag_list(paths_tags_df)) + paths_df <- .paths_to_clean_df(x) + result <- purrr::pmap(paths_df, .path_row_to_list) + names(result) <- paths_df$operation_id + result } -.paths_to_tags_df <- function(x) { - x <- unnest(x, "operations") - x <- x[!x$deprecated, ] - nest( - x, - .by = "tags", .key = "endpoints" - ) -} - -## to tag list ----------------------------------------------------------------- -.paths_to_tag_list <- function(paths_tags_df) { - set_names( - map( - paths_tags_df$endpoints, - .paths_endpoints_to_lists - ), - .to_snake(paths_tags_df$tags) +.paths_to_clean_df <- function(x) { + x <- tibble::as_tibble(x) |> + tidyr::unnest("operations") + if (length(x$deprecated)) { + x <- x[!x$deprecated, ] + } + x$deprecated <- NULL + x$tags <- .paths_fill_tags(x$tags) + x$operation_id <- .paths_fill_operation_id( + x$operation_id, + x$endpoint, + x$operation ) -} - -.paths_endpoints_to_lists <- function(endpoints) { - pmap( - list( - operation_id = .paths_fill_operation_id( - endpoints$operation_id, - endpoints$endpoint, - endpoints$operation - ), - path = endpoints$endpoint, - summary = .paths_fill_summary( - endpoints$summary, - endpoints$endpoint, - endpoints$operation - ), - description = .paths_fill_descriptions(endpoints$description), - params_df = endpoints$parameters, - method = endpoints$operation - ), - .paths_endpoint_to_list + x$summary <- .paths_fill_summary( + x$summary, + x$endpoint, + x$operation ) + x$description <- .paths_fill_descriptions(x$description, x$summary) + # TODO: Deal with x$global_parameters if present + x$parameters <- purrr::map(x$parameters, .prepare_params_df) + return(x) } ### fill data ------------------------------------------------------------------ +.paths_fill_tags <- function(tags) { + tags[lengths(tags) == 0] <- "general" + tags <- purrr::map_chr(tags, 1) + return(.to_snake(tags)) +} + .paths_fill_operation_id <- function(operation_id, endpoint, method) { .coalesce(.to_snake(operation_id), glue("{method}_{.to_snake(endpoint)}")) } .paths_fill_summary <- function(summary, endpoint, method) { - endpoint_spaced <- str_replace_all(.to_snake(endpoint), "_", " ") + endpoint_spaced <- stringr::str_replace_all(.to_snake(endpoint), "_", " ") .coalesce( - str_squish(summary), - str_to_sentence(glue("{method} {endpoint_spaced}")) + stringr::str_squish(summary), + stringr::str_to_sentence(glue("{method} {endpoint_spaced}")) ) } -### create whisker data -------------------------------------------------------- +.paths_fill_descriptions <- function(descriptions, summaries) { + descriptions[is.na(descriptions)] <- summaries[is.na(descriptions)] + descriptions[is.na(descriptions)] <- "" + return(stringr::str_squish(descriptions)) +} -.paths_endpoint_to_list <- function(operation_id, - path, - summary, - description, - params_df, - method) { - params_df <- .prepare_paths_df(params_df) - return( - list( - operation_id = operation_id, - path = .path_as_arg(path, params_df), - method = method, - summary = summary, - description = description, - params = .params_to_list(params_df), - params_query = .extract_params_type(params_df, "query"), - params_header = .extract_params_type(params_df, "header"), - params_cookie = .extract_params_type(params_df, "cookie") - ) +### create template data ------------------------------------------------------- + +.path_row_to_list <- function( + operation_id, + endpoint, + operation, + summary, + description, + tags, + parameters, + ... +) { + list( + operation_id = operation_id, + tag = tags, + path = .path_as_arg(endpoint, parameters), + method = operation, + summary = summary, + description = description, + params = .params_to_list(parameters), + params_query_raw = .extract_params_by_location(parameters, "query"), + params_header_raw = .extract_params_by_location(parameters, "header"), + params_cookie_raw = .extract_params_by_location(parameters, "cookie") ) } -.prepare_paths_df <- function(params_df) { +.prepare_params_df <- function(params_df) { + params_df <- .flatten_params_df(params_df) + if (nrow(params_df)) { + params_df$class <- .describe_param_classes( + params_df$schema, + params_df$allowEmptyValue, + params_df$required + ) + params_df$description <- .paths_fill_descriptions( + params_df$description, + params_df$schema$description + ) + } + params_df$schema <- NULL + params_df$style <- NULL + return(params_df) +} + +.flatten_params_df <- function(params_df) { params_df <- .flatten_df(params_df) if (nrow(params_df)) { params_df <- params_df[!params_df$deprecated, ] - params_df$description <- .paths_fill_descriptions(params_df$description) } return(params_df) } @@ -122,34 +142,60 @@ S7::method(as_bk_data, class_paths) <- function(x) { if (!nrow(params_df)) { return(list()) } - # TODO: Deal with all the available data. - params <- pmap( + purrr::pmap( list( name = params_df$name, + class = params_df$class, description = params_df$description ), - .paths_param_to_list + function(name, class, description) { + list(name = name, class = class, description = description) + } ) - return(params) } -.extract_params_type <- function(params_df, filter_in) { +.extract_params_by_location <- function(params_df, filter_in) { if (!nrow(params_df)) { return(character()) } return(params_df$name[params_df$`in` == filter_in]) } -.paths_fill_descriptions <- function(descriptions) { - descriptions[is.na(descriptions)] <- "BKTODO: No description provided." - return(str_squish(descriptions)) +.describe_param_classes <- function(params_schema, allow_empty, required) { + # TODO: Use enum and/or description when available. + # + # TODO: What should we do for `object`? Currently falls back to list, same as + # array. + type <- dplyr::left_join( + dplyr::select(params_schema, "type", "format"), + oas_format_registry, + by = c("type", "format") + ) + # Fall back to list for unknown types (array, object, etc.) + type$r_class_name <- dplyr::coalesce(type$r_class_name, "list") + type$r_class_package <- dplyr::coalesce(type$r_class_package, "base") + type$r_class_link <- dplyr::coalesce(type$r_class_link, "list") + type$r_class_name_display <- stringr::str_remove( + glue::glue("{type$r_class_package}::{type$r_class_name}"), + "^base::" + ) + return(.compile_param_class_descriptions(type, allow_empty, required)) } -.paths_param_to_list <- function(name, description) { - list( - name = name, - description = description - ) +.compile_param_class_descriptions <- function(type, allow_empty, required) { + r_class_descriptions <- .glue_pipe_brace( + "length-1 [|{type$r_class_package}|::|{type$r_class_link}|()]" + ) |> + .paste0_if( + allow_empty, + " or `NULL`" + ) |> + .paste0_if( + !required, + ", optional" + ) + + return(r_class_descriptions) } .path_as_arg <- function(path, params_df) { @@ -165,59 +211,61 @@ S7::method(as_bk_data, class_paths) <- function(x) { .collapse_comma(glue("{x} = {x}")) } -# generate files ---------------------------------------------------------- +# generate files --------------------------------------------------------------- + +.generate_paths_files <- function( + paths_by_operation, + api_abbr, + security_data, + pagination_data +) { + security_arg_names <- security_data$security_arg_names %|0|% character() -.generate_paths_files <- function(paths_by_tag, api_abbr, security_data) { - unlist(imap( - paths_by_tag, - function(path_tag, path_tag_name) { - .generate_paths_tag_files( - path_tag, - path_tag_name, - api_abbr, - security_data + # Prep each operation: remove security args, compile args strings + prepped <- imap(paths_by_operation, function(op, op_id) { + params <- .remove_security_args(op$params, security_arg_names) + params_query <- .prep_param_args(op$params_query_raw, security_arg_names) + params_header <- .prep_param_args(op$params_header_raw, security_arg_names) + args <- .params_to_args(params) + args_named <- .params_to_named_args(params) + c( + op, + list( + params = params, + params_query = params_query, + params_header = params_header, + args = args, + args_named = args_named, + test_args = args ) - } - )) -} + ) + }) -.generate_paths_tag_files <- function(path_tag, - path_tag_name, - api_abbr, - security_data) { - path_tag <- .prepare_path_tag( - path_tag, - security_data$security_arg_names - ) - file_path <- .generate_paths_file( - path_tag, - path_tag_name, - api_abbr, - security_data - ) - test_path <- .generate_paths_test_file(path_tag, path_tag_name, api_abbr) - return(c(unname(file_path), unname(test_path))) -} + # One R file per operation + r_files <- unname(unlist(imap(prepped, function(op, op_id) { + .generate_paths_file(op, op_id, api_abbr, security_data) + }))) -.prepare_path_tag <- function(path_tag, security_args) { - path_tag <- map( - path_tag, - function(path) { - path$params <- .remove_security_args(path$params, security_args) - path$params_cookie <- .prep_param_args(path$params_cookie, security_args) - path$params_header <- .prep_param_args(path$params_header, security_args) - path$params_query <- .prep_param_args(path$params_query, security_args) - path$args <- .params_to_args(path$params) - path$test_args <- path$args - return(path) - } - ) + # One test file per tag (operations grouped by tag, preserving encounter + # order) + tags <- map_chr(prepped, "tag") + unique_tags <- unique(tags) + test_files <- unname(unlist(lapply(unique_tags, function(tag_name) { + tag_ops <- prepped[tags == tag_name] + .generate_paths_test_file(tag_ops, tag_name, api_abbr) + }))) + + return(c(r_files, test_files)) } .params_to_args <- function(params) { .collapse_comma(map_chr(params, "name")) %|"|% character() } +.params_to_named_args <- function(params) { + .collapse_comma_self_equal(map_chr(params, "name")) %|"|% character() +} + .remove_security_args <- function(params, security_args) { discard( params, @@ -231,32 +279,44 @@ S7::method(as_bk_data, class_paths) <- function(x) { .collapse_comma_self_equal(setdiff(params, security_args)) %|"|% character() } -.generate_paths_file <- function(path_tag, - path_tag_name, - api_abbr, - security_data) { +.generate_paths_file <- function( + path_operation, + operation_id, + api_abbr, + security_data +) { .bk_use_template( template = "paths.R", - data = list( - paths = path_tag, - api_abbr = api_abbr, - has_security = security_data$has_security, - security_signature = security_data$security_signature, - security_arg_list = security_data$security_arg_list + data = c( + path_operation, + list( + api_abbr = api_abbr, + has_security = security_data$has_security %|0|% FALSE, + security_signature = security_data$security_signature %|0|% "", + security_arg_list = security_data$security_arg_list %|0|% "", + pagination = FALSE, + pagination_fn = "" + ) ), - target = glue("paths-{path_tag_name}.R") + target = glue("paths-{path_operation$tag}-{operation_id}.R") ) } -.generate_paths_test_file <- function(path_tag, path_tag_name, api_abbr) { +.generate_paths_test_file <- function(tag_operations, tag_name, api_abbr) { + paths_list <- unname(imap(tag_operations, function(op, op_id) { + list( + operation_id = op_id, + test_args = op$test_args %|0|% "" + ) + })) .bk_use_template( template = "test-paths.R", data = list( - paths = path_tag, - tag = path_tag_name, + paths = paths_list, + tag = tag_name, api_abbr = api_abbr ), dir = "tests/testthat", - target = glue("test-paths-{path_tag_name}.R") + target = glue("test-paths-{tag_name}.R") ) } diff --git a/R/generate_pkg-prepare.R b/R/generate_pkg-prepare.R index 6a256cd..8ad4e80 100644 --- a/R/generate_pkg-prepare.R +++ b/R/generate_pkg-prepare.R @@ -1,64 +1,29 @@ -#' Error if not in package -#' -#' @inheritParams .is_pkg -#' -#' @return `NULL`, invisibly. -#' @keywords internal -.assert_is_pkg <- function(base_path = usethis::proj_get()) { - if (.is_pkg(base_path)) { - return(invisible(NULL)) - } - cli_abort(c( - "Can't generate package files outside of a package.", - x = "{.path {base_path}} is not inside a package." - )) -} - -#' Check whether we're in a package -#' -#' Inspired by usethis:::is_package. -#' -#' @param base_path The root URL of the current project. -#' -#' @return `TRUE` if the project is a package, `FALSE` if not. -#' @keywords internal -.is_pkg <- function(base_path = proj_get()) { - root_file <- try_fetch( - find_package_root_file(path = base_path), - error = function(cnd) NULL +.generate_prepare <- function(config, api_definition, security_data) { + c( + .generate_prepare_r(config, api_definition, security_data), + .generate_prepare_test(config$api_abbr) ) - !is.null(root_file) -} - -.read_config <- function(config_file = "_beekeeper.yml") { - config <- read_yaml(config_file) - config <- .stabilize_config(config) - return(config) } -.stabilize_config <- function(config) { - config$api_title <- stabilize_string(config$api_title) - config$api_abbr <- stabilize_string(config$api_abbr) - config$api_version <- stabilize_string(config$api_version) - config$rapid_file <- stabilize_string(config$rapid_file) - config$updated_on <- strptime( - config$updated_on, - format = "%Y-%m-%d %H:%M:%S", - tz = "UTC" +.generate_prepare_r <- function(config, api_definition, security_data) { + .bk_use_template( + template = "010-prepare.R", + data = list( + api_title = config$api_title, + api_abbr = config$api_abbr, + base_url = api_definition@servers@url, + has_security = security_data$has_security, + security_arg_helps = security_data$security_arg_helps, + security_signature = security_data$security_signature, + security_arg_list = security_data$security_arg_list + ) ) - return(config) } -.read_api_definition <- function(config_file, rapid_file) { - readRDS( - path(path_dir(config_file), rapid_file) +.generate_prepare_test <- function(api_abbr) { + .bk_use_template( + template = "test-010-prepare.R", + dir = "tests/testthat", + data = list(api_abbr = api_abbr) ) } - -.prepare_r <- function() { - use_directory("R") - use_testthat() - quietly(use_httptest2)() - use_package("nectar") - use_package("beekeeper", type = "Suggests") -} diff --git a/R/generate_pkg-security.R b/R/generate_pkg-security.R index 93ed27a..765b0b7 100644 --- a/R/generate_pkg-security.R +++ b/R/generate_pkg-security.R @@ -6,7 +6,8 @@ data = c(security_data, api_abbr = api_abbr) ) security_data$security_signature <- .generate_security_signature( - security_data$security_arg_names, api_abbr + security_data$security_arg_names, + api_abbr ) } return(security_data) @@ -58,7 +59,8 @@ S7::method(as_bk_data, class_security_schemes) <- function(x) { .security_scheme_description_fill <- function(description, type) { if (is.na(description)) { return( - switch(type, + switch( + type, api_key = .security_scheme_description_api_key, NA_character_ ) @@ -129,7 +131,7 @@ S7::method(as_bk_data, class_api_key_security_scheme) <- function(x) { return( list( parameter_name = x@parameter_name, - arg_name = str_remove(.to_snake(x@parameter_name), "^x_"), + arg_name = stringr::str_remove(.to_snake(x@parameter_name), "^x_"), location = x@location, type = "api_key", api_key = TRUE diff --git a/R/generate_pkg-setup.R b/R/generate_pkg-setup.R new file mode 100644 index 0000000..7354a2a --- /dev/null +++ b/R/generate_pkg-setup.R @@ -0,0 +1,69 @@ +#' Error if not in package +#' +#' @inheritParams .is_pkg +#' +#' @return `NULL`, invisibly. +#' @keywords internal +.assert_is_pkg <- function(base_path = usethis::proj_get()) { + if (.is_pkg(base_path)) { + return(invisible(NULL)) + } + cli_abort(c( + "Can't generate package files outside of a package.", + x = "{.path {base_path}} is not inside a package." + )) +} + +#' Check whether we're in a package +#' +#' Inspired by usethis:::is_package. +#' +#' @param base_path The root URL of the current project. +#' +#' @return `TRUE` if the project is a package, `FALSE` if not. +#' @keywords internal +.is_pkg <- function(base_path = proj_get()) { + root_file <- try_fetch( + rprojroot::find_package_root_file(path = base_path), + error = function(cnd) NULL + ) + !is.null(root_file) +} + +.read_config <- function(config_file = "_beekeeper.yml") { + config <- yaml::read_yaml(config_file) + return(.stabilize_config(config)) # nocov +} + +# covr doesn't see the line above and a bunch below for some reason. +# nocov start +.stabilize_config <- function(config) { + config$api_title <- stbl::stabilize_character_scalar(config$api_title) + config$api_abbr <- stbl::stabilize_character_scalar(config$api_abbr) + config$api_version <- stbl::stabilize_character_scalar(config$api_version) + config$rapid_file <- stbl::stabilize_character_scalar(config$rapid_file) + config$updated_on <- strptime( + config$updated_on, + format = "%Y-%m-%d %H:%M:%S", + tz = "UTC" + ) + return(config) +} +# nocov end + +.read_api_definition <- function(pkg_dir, rapid_file) { + readRDS( + path(pkg_dir, rapid_file) + ) +} + +.setup_r <- function(pkg_dir) { + if (as.character(pkg_dir) != ".") { + usethis::local_project(pkg_dir, quiet = TRUE) # nocov + } + use_directory("R") + use_testthat() + purrr::quietly(use_httptest2)() + use_package("nectar") + use_package("beekeeper", type = "Suggests") +} diff --git a/R/generate_pkg-shared.R b/R/generate_pkg-shared.R new file mode 100644 index 0000000..7564df1 --- /dev/null +++ b/R/generate_pkg-shared.R @@ -0,0 +1,10 @@ +.generate_shared_params <- function(security_data) { + shared_file_path <- .bk_use_template( + template = "000-shared.R", + data = list( + shared_arg_helps = list(), + security_arg_helps = security_data$security_arg_helps + ) + ) + return(shared_file_path) +} diff --git a/R/generate_pkg-template.R b/R/generate_pkg-template.R index 8d4e2cf..555355d 100644 --- a/R/generate_pkg-template.R +++ b/R/generate_pkg-template.R @@ -8,15 +8,16 @@ #' #' @return The path to the generated or updated file, invisibly. #' @keywords internal -.bk_use_template <- function(template, - data, - ..., - target = template, - dir = c("R", "tests/testthat")) { +.bk_use_template <- function( + template, + data, + ..., + target = template, + dir = c("R", "tests/testthat") +) { check_dots_empty() dir <- match.arg(dir) target <- .bk_use_template_impl(template, data, target, dir) - capture.output(style_file(target)) return(invisible(target)) } diff --git a/R/generate_pkg.R b/R/generate_pkg.R new file mode 100644 index 0000000..2cbba5b --- /dev/null +++ b/R/generate_pkg.R @@ -0,0 +1,54 @@ +#' Use a beekeeper config file to generate code +#' +#' Creates or updates package files based on the information in a beekeeper +#' config file (generated by [use_beekeeper()] or manually). The files enforce +#' an opinionated framework for API packages. +#' +#' @param config_file (`length-1 character` or `fs_path`) The path to a +#' beekeeper yaml file. All package files are created relative to this file. +#' @param pkg_dir (`length-1 character` or `fs_path`) The directory in which the +#' package files will be created. Defaults to the directory of the config +#' file. +#' +#' @return A character vector of paths to files that were added or updated, +#' invisibly. +#' @export +generate_pkg <- function( + config_file = "_beekeeper.yml", + pkg_dir = fs::path_dir(config_file) +) { + # TODO: Confirm that they use github & everything is committed. Error or warn + # if not, letting them know that this can be destructive. Skip this check in + # tests. + .assert_is_pkg(pkg_dir) + config <- .read_config(config_file) + api_definition <- .read_api_definition(pkg_dir, config$rapid_file) + .setup_r(pkg_dir) + touched_files <- .generate_pkg_impl(config, api_definition) + return(invisible(touched_files)) +} + +.generate_pkg_impl <- function(config, api_definition) { + security_data <- .generate_security( + config$api_abbr, + api_definition@components@security_schemes + ) + prep_files <- .generate_prepare(config, api_definition, security_data) + pagination_data <- .generate_pagination() + path_files <- .generate_paths( + paths = api_definition@paths, + api_abbr = config$api_abbr, + security_data = security_data, + pagination_data = pagination_data, + base_url = api_definition@servers@url + ) + shared_file_path <- .generate_shared_params(security_data) + touched_files <- c( + shared_file_path, + prep_files, + security_data$security_file_path, + pagination_data$pagination_file_path, + path_files + ) + return(invisible(touched_files)) +} diff --git a/R/generate_pkg_main.R b/R/generate_pkg_main.R deleted file mode 100644 index 01256c6..0000000 --- a/R/generate_pkg_main.R +++ /dev/null @@ -1,41 +0,0 @@ -#' Use a beekeeper config file to generate code -#' -#' Creates or updates package files based on the information in a beekeeper -#' config file (generated by [use_beekeeper()] or manually). The files enforce -#' an opinionated framework for API packages. -#' -#' @param config_file The path to a beekeeper yaml file. -#' @param pkg_agent A string to identify this package, for use in the -#' `user_agent` argument of [nectar::req_setup()]. -#' -#' @return A character vector of paths to files that were added or updated, -#' invisibly. -#' @export -generate_pkg <- function(config_file = "_beekeeper.yml", - pkg_agent = generate_pkg_agent(config_file)) { - # TODO: Confirm that they use github & everything is committed. Error or warn - # if not, letting them know that this can be destructive. Skip this check in - # tests. - .assert_is_pkg() - config <- .read_config(config_file) - api_definition <- .read_api_definition(config_file, config$rapid_file) - .prepare_r() - touched_files <- .generate_pkg_impl(config, api_definition, pkg_agent) - return(invisible(touched_files)) -} - -.generate_pkg_impl <- function(config, api_definition, pkg_agent) { - security_data <- .generate_security( - config$api_abbr, - api_definition@components@security_schemes - ) - call_files <- .generate_call(config, api_definition, pkg_agent, security_data) - path_files <- .generate_paths( - api_definition@paths, - config$api_abbr, - security_data, - api_definition@servers@url - ) - touched_files <- c(call_files, security_data$security_file_path, path_files) - return(invisible(touched_files)) -} diff --git a/R/sysdata.rda b/R/sysdata.rda new file mode 100644 index 0000000..9698cd2 Binary files /dev/null and b/R/sysdata.rda differ diff --git a/R/use_beekeeper.R b/R/use_beekeeper.R index 49df626..8d5c5c9 100644 --- a/R/use_beekeeper.R +++ b/R/use_beekeeper.R @@ -19,11 +19,13 @@ #' written as a side effect of this function. The rapid object is also #' written, and the path to that file is saved in the config file. #' @export -use_beekeeper <- function(x, - api_abbr, - ..., - config_file = "_beekeeper.yml", - rapid_file = "_beekeeper_rapid.rds") { +use_beekeeper <- function( + x, + api_abbr, + ..., + config_file = "_beekeeper.yml", + rapid_file = "_beekeeper_rapid.rds" +) { x <- as_rapid(x) rapid_file <- .write_rapid(x, rapid_file) config_file <- .write_config(x, api_abbr, rapid_file, config_file) @@ -32,19 +34,19 @@ use_beekeeper <- function(x, } .write_rapid <- function(x, rapid_file) { - rapid_file <- stabilize_string(rapid_file) + rapid_file <- stbl::stabilize_character_scalar(rapid_file) saveRDS(x, rapid_file) use_build_ignore(rapid_file) return(rapid_file) } .write_config <- function(x, api_abbr, rapid_file, config_file) { - config_file <- stabilize_string(config_file) + config_file <- stbl::stabilize_character_scalar(config_file) update_time <- strptime(Sys.time(), format = "%Y-%m-%d %H:%M:%S", tz = "UTC") - write_yaml( + yaml::write_yaml( list( api_title = x@info@title, - api_abbr = stabilize_string(api_abbr), + api_abbr = stbl::stabilize_character_scalar(api_abbr), api_version = x@info@version, rapid_file = path_rel(rapid_file, path_dir(config_file)), updated_on = as.character(update_time) diff --git a/R/utils.R b/R/utils.R index 0d0ad64..87a7cd2 100644 --- a/R/utils.R +++ b/R/utils.R @@ -26,8 +26,29 @@ glue_collapse(x, sep = ",\n") } +.collapse_quote_comma <- function(x) { + stringr::str_flatten_comma(paste0('"', x, '"')) +} + +.paste0_if <- function(original, test, addition) { + ifelse( + test, + paste0(original, addition), + original + ) +} + +.glue_pipe_brace <- function(..., .envir = rlang::caller_env()) { + glue::glue( + ..., + .open = "|{", + .close = "}|", + .envir = .envir + ) +} + .to_snake <- function(x) { - to_snake_case(x, parsing_option = 3) + snakecase::to_snake_case(x, parsing_option = 3) } .flatten_df <- S7::new_generic( @@ -40,7 +61,7 @@ S7::method(.flatten_df, class_data.frame) <- function(x) { } S7::method(.flatten_df, class_list) <- function(x) { - return(list_rbind(x)) + return(purrr::list_rbind(x)) } S7::method(.flatten_df, NULL) <- function(x) { diff --git a/README.Rmd b/README.Rmd index 95b8a38..251d1b1 100644 --- a/README.Rmd +++ b/README.Rmd @@ -59,16 +59,16 @@ Most of the outline was included in the grant proposal. - **Potential challenges:** This step will involve more reading and documenting than code, to gather examples of how different APIs implement limits and batching. It's possible systems will be so different that it will be difficult to summarize them. For example, Slack has two separate batching systems in its API, with some functions moved to the newer system, and others not. - **UPDATE:** The [development version of {httr2}](https://github.com/r-lib/httr2/) has functionality to help with this quite a lot, thankfully! I'm skipping this milestone while that functionality stabilizes (this was previously 0.3.0). - [ ] **0.5.0: More robust scaffolding.** - - [ ] Add parameter documentation. - - [ ] Also add parameter type checking. - - [ ] **Potential challenges:** By this point I'll need an OAS definition document to use for testing that includes all of the possible parameter types. I'll likely need to generate a fake API specification that goes beyond a typical individual example. -- **0.6.0: Expected results.** - - Add response (return value) documentation. - - Use expected responses to generate better test scaffolds. - - **Potential challenges:** Testing the generation of tests might present unique challenges. I'll need to look into how testthat tests itself. -- **0.7.0: Error messaging.** - - Add more robust error messaging for non-standard responses. - - **Potential challenges:** Mocking cases where things fail can be tricky. Ideally this step will involve pushing the package to a stable 1.0.0, but that will require enough usage to feel confident that the core function definitions are stable. + - [x] Add parameter documentation. + - [ ] Deal with pagination semi-automatically. +- [ ] **0.6.0: Expected results.** + - [ ] Add response (return value) documentation. + - [ ] Use response (return value) schemas to parse responses. + - [ ] Add parameter type checking. + - [ ] Use expected responses to generate better test scaffolds. +- [ ] **0.7.0: Error messaging.** + - [ ] Add more robust error messaging for non-standard responses. + - [ ] **Potential challenges:** Mocking cases where things fail can be tricky. Ideally this step will involve pushing the package to a stable 1.0.0, but that will require enough usage to feel confident that the core function definitions are stable. ## Installation diff --git a/README.md b/README.md index 106ba14..746ff2b 100644 --- a/README.md +++ b/README.md @@ -87,21 +87,16 @@ included in the grant proposal. milestone while that functionality stabilizes (this was previously 0.3.0). - [ ] **0.5.0: More robust scaffolding.** - - [ ] Add parameter documentation. - - [ ] Also add parameter type checking. - - [ ] **Potential challenges:** By this point I’ll need an OAS - definition document to use for testing that includes all of the - possible parameter types. I’ll likely need to generate a fake API - specification that goes beyond a typical individual example. -- **0.6.0: Expected results.** - - Add response (return value) documentation. - - Use expected responses to generate better test scaffolds. - - **Potential challenges:** Testing the generation of tests might - present unique challenges. I’ll need to look into how testthat tests - itself. -- **0.7.0: Error messaging.** - - Add more robust error messaging for non-standard responses. - - **Potential challenges:** Mocking cases where things fail can be + - [x] Add parameter documentation. + - [ ] Deal with pagination semi-automatically. +- [ ] **0.6.0: Expected results.** + - [ ] Add response (return value) documentation. + - [ ] Use response (return value) schemas to parse responses. + - [ ] Add parameter type checking. + - [ ] Use expected responses to generate better test scaffolds. +- [ ] **0.7.0: Error messaging.** + - [ ] Add more robust error messaging for non-standard responses. + - [ ] **Potential challenges:** Mocking cases where things fail can be tricky. Ideally this step will involve pushing the package to a stable 1.0.0, but that will require enough usage to feel confident that the core function definitions are stable. diff --git a/_pkgdown.yml b/_pkgdown.yml index 38f322a..1dd6490 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -1,3 +1,6 @@ url: https://beekeeper.api2r.org +development: + mode: auto template: bootstrap: 5 +lang: en-US diff --git a/air.toml b/air.toml new file mode 100644 index 0000000..f19c117 --- /dev/null +++ b/air.toml @@ -0,0 +1,2 @@ +[format] +exclude = ["inst/templates", "tests/testthat/_fixtures"] diff --git a/beekeeper.Rproj b/beekeeper.Rproj index 69fafd4..270314b 100644 --- a/beekeeper.Rproj +++ b/beekeeper.Rproj @@ -1,7 +1,7 @@ Version: 1.0 -RestoreWorkspace: No -SaveWorkspace: No +RestoreWorkspace: Default +SaveWorkspace: Default AlwaysSaveHistory: Default EnableCodeIndexing: Yes @@ -14,7 +14,6 @@ LaTeX: pdfLaTeX AutoAppendNewline: Yes StripTrailingWhitespace: Yes -LineEndingConversion: Posix BuildType: Package PackageUseDevtools: Yes diff --git a/data-raw/oas_format_registry.R b/data-raw/oas_format_registry.R new file mode 100644 index 0000000..e765afe --- /dev/null +++ b/data-raw/oas_format_registry.R @@ -0,0 +1,101 @@ +oas_format_registry_raw <- rvest::read_html( + "https://spec.openapis.org/registry/format/" +) + +oas_format_registry <- + oas_format_registry_raw |> + rvest::html_table() |> + _[[1]] |> + janitor::clean_names() |> + dplyr::mutate( + json_data_type = stringr::str_split(.data$json_data_type, ", "), + source = dplyr::na_if(.data$source, ""), + deprecated = .data$deprecated == "Yes" + ) |> + tidyr::unnest_longer("json_data_type") |> + dplyr::select( + type = "json_data_type", + format = "value" #, UNCOMMENT THESE TO DIG INTO FORMATS + # "description", + # "source", + # "deprecated" + ) |> + dplyr::arrange(.data$type, .data$format) |> + dplyr::bind_rows( + data.frame( + type = c( + "boolean", + "integer", + "null", + "number", + "string" + ) + ) + ) |> + dplyr::mutate( + # TODO: Sort out exactly how this works. tibblify will process things. Can + # I make it give these things a class, and then stabilize back and forth + # via that? + # + # nectar re-exports from stbl, and adds HTTP-specific wrappers + to_r = dplyr::case_when( + ## Null + .data$type == "null" ~ "stabilize_null", + ## Numbers + .data$format == "int64" ~ "stabilize_int64", + stringr::str_detect(.data$format, "int") ~ "stabilize_int", + stringr::str_detect(.data$type, "int") ~ "stabilize_int", + .data$type == "number" ~ "stabilize_dbl", + stringr::str_detect(.data$format, "decimal") ~ "stabilize_dbl", + ## Dates and times + .data$format %in% c("date", "http-date") ~ "stabilize_date", + .data$format == "date-time" ~ "stabilize_datetime", + .data$format == "duration" ~ "stabilize_duration", + .data$format == "time" ~ "stabilize_time", + ## Binary + .data$format %in% c("byte", "sf-binary") ~ "stabilize_base64_to_chr", + .data$format == "binary" ~ "stabilize_binary_to_raw", + .data$format == "base64url" ~ "stabilize_base64url_to_chr", + ## Logical + .data$format == "sf-boolean" ~ "stabilize_structured_lgl", + .data$type == "boolean" ~ "stabilize_lgl", + ## Specific Strings + .data$format == "uuid" ~ "stabilize_uuid", + # TODO: Add more specific string formats. + .default = "stabilize_chr" + ), + r_class_name = dplyr::replace_values( + .data$to_r, + "stabilize_base64_to_chr" ~ "character", + "stabilize_base64url_to_chr" ~ "character", + "stabilize_binary_to_raw" ~ "raw", + "stabilize_chr" ~ "character", + "stabilize_date" ~ "Date", + "stabilize_datetime" ~ "POSIXct", + "stabilize_dbl" ~ "double", + "stabilize_duration" ~ "Duration", + "stabilize_int" ~ "integer", + "stabilize_int64" ~ "integer64", + "stabilize_lgl" ~ "logical", + "stabilize_null" ~ "NULL", + "stabilize_structured_lgl" ~ "logical", + "stabilize_time" ~ "hms", + "stabilize_uuid" ~ "UUID" + ), + r_class_package = dplyr::recode_values( + .data$r_class_name, + "Duration" ~ "lubridate", + "integer64" ~ "bit64", + "hms" ~ "hms", + "UUID" ~ "uuid", + default = "base" + ), + r_class_link = dplyr::recode_values( + .data$r_class_name, + "Duration" ~ "Duration-class", + default = .data$r_class_name + ) + ) + +usethis::use_data(oas_format_registry, overwrite = TRUE, internal = TRUE) +rm(oas_format_registry_raw, oas_format_registry) diff --git a/inst/talks/recruit/.gitignore b/inst/talks/recruit/.gitignore new file mode 100644 index 0000000..5a73e2c --- /dev/null +++ b/inst/talks/recruit/.gitignore @@ -0,0 +1,2 @@ +recruit_files +*.html diff --git a/inst/talks/recruit/_publish.yml b/inst/talks/recruit/_publish.yml new file mode 100644 index 0000000..f873076 --- /dev/null +++ b/inst/talks/recruit/_publish.yml @@ -0,0 +1,4 @@ +- source: recruit.qmd + quarto-pub: + - id: 8f439c7b-183f-42e3-b9ed-c04c332f2607 + url: https://jonthegeek.quarto.pub/building-the-hive diff --git a/inst/talks/recruit/images/Apple_logo_white.svg b/inst/talks/recruit/images/Apple_logo_white.svg new file mode 100644 index 0000000..133f976 --- /dev/null +++ b/inst/talks/recruit/images/Apple_logo_white.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/inst/talks/recruit/images/Google__G__Logo.svg b/inst/talks/recruit/images/Google__G__Logo.svg new file mode 100644 index 0000000..4cf163b --- /dev/null +++ b/inst/talks/recruit/images/Google__G__Logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/inst/talks/recruit/images/Zoom_Logo_2022.svg b/inst/talks/recruit/images/Zoom_Logo_2022.svg new file mode 100644 index 0000000..3990852 --- /dev/null +++ b/inst/talks/recruit/images/Zoom_Logo_2022.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/inst/talks/recruit/images/beekeeper.svg b/inst/talks/recruit/images/beekeeper.svg new file mode 100644 index 0000000..9bf7e10 --- /dev/null +++ b/inst/talks/recruit/images/beekeeper.svg @@ -0,0 +1,863 @@ + +image/svg+xmlbeekeeperJon HarmonCC0 beekeeper diff --git a/inst/talks/recruit/recruit.qmd b/inst/talks/recruit/recruit.qmd new file mode 100644 index 0000000..ee7b9dc --- /dev/null +++ b/inst/talks/recruit/recruit.qmd @@ -0,0 +1,288 @@ +--- +css: style.css +format: + revealjs: + theme: dark + logo: images/beekeeper.svg + footer: api2r.org/ghana2025 | Jon Harmon | @jonthegeek + link-external-newwindow: true + transition: slide + incremental: true +--- + +# qr code pointing to DSLC.io/nyr2024Building the Hive: + +[Collaborating on API Packages with {beekeeper}]{style="font-size: 1.5em"} + +::: notes +- Thank Francis and the Ghana R Users Community +- Introduce myself +- Data Science Learning Community (DSLC.io) +- @jonthegeek (mostly BlueSky, LinkedIn, GitHub) +- How you can help me make APIs easier to use in R +- Slides at api2r.org/ghana2025 +- Old talk introducing APIs: DSLC.io/ghana202306 +- This talk about what I've been up to since +::: + +# 🎯 What is a web API? + +## 📘 APIs are: {transition="slide-in none-out"} + +🤖↔️🤖 Services that + +- Enable communication +- Between computer programs + +::: notes +- "Application programming interface." (but almost always API) +- "Services that enable communication between computer programs." +- You might hear the arguments to a function referred to as the "API" of that function. +::: + +## 📘 Web APIs are: {transition="none-in slide-out"} + +🤖↔️🤖 Services that + +::: nonincremental +- Enable communication +- Between computer programs +- *On the internet* +::: + +::: notes +- For the rest of this talk, "API" = "web API". +::: + +## 📆 You use web APIs every day! + +- "Login with ![](images/Google__G__Logo.svg){height="30"} | ![](images/Apple_logo_white.svg){height="30" style="text-align: top;"}" +- "Create ![](images/Zoom_logo_2022.svg){height="20"} meeting" in work messenger +- "📅 Add to Calendar" +- *Loading any web page == `GET`* + +::: notes +- (don't comment on logo alignment) +- Zoom: Or similar app integrations. +- Button on a web page, goes to Google or Apple calendar, etc. +- For "Loading any": + - When you fetch a web page, you're using the GET method to ask the server to send you the page. + - There are 5 main methods for web apis. + - GET, POST = create something new usually, PUT = replace, PATCH = edit, DELETE; often just GET and/or POST + +::: + +## 🔍 How do you find APIs? + +- Use browser network tab + - Look for `fetch()` or `XHR` + - {apisniffer} experiment at [jonthegeek.github.io/apisniffer](https://jonthegeek.github.io/apisniffer/) +- [Find APIs](https://dslc-io.github.io/club-wapir/slides/apis-find.html) chapter + - Learn more about [Web APIs with R (DSLC.io/wapir)](https://DSLC.io/wapir) + +::: notes +- Network tab can reveal what the browser is calling behind the scenes. +- {apisniffer} is very experimental and I haven't updated it in a while, but hope to get back to it soon +- Lots more about finding APIs in my book! +- I'm writing a book! Join DSLC.io to join the next book club cohort! + +::: + + +# 📄 How are APIs documented? + +## 👩️ Human vs 🤖 Machine + +- 👩 : prose & examples +- 🤖 : structured specs +- **Swagger** = structured API specification +- **OpenAPI** = new name of Swagger + +::: notes +- Humans and sometimes genAI now, but mostly humans. +- As a programmer, I also like structure, so I can look for standard things! +- Swagger was made for a dictionary site, to describe APIs in a standard way. I *think* they were working with multiple APIs and wanted to standardize them. +- Donated to the Linux Foundation in 2015. +- Renamed to the "OpenAPI Specification". +- Swagger 2.0 = OpenAPI 2.0 (there isn't really OpenAPI 1.0). +- Currently on v3.1.1. +::: + +## 🔎 A sample OpenAPI spec (YAML/JSON) + +```yaml +openapi: 3.0.1 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + summary: Get all users + responses: + '200': + description: OK +``` + +::: notes +- JSON = JavaScript Object Notation +- YAML = superset of JSON, more human-readable +- This is a tiny sample of what a full API spec might include. +- Version of the specification on line 1. Sometimes this will be `swagger:` instead of `openapi:` in old specs. +- `info` contains basic information about the API. Can include contact info, license, etc. +- `paths` section is where the API endpoints live — this one has `/users`. +- I mentioned API methods before. This path uses `get`. +- `200` means OK/success. +- Specs can also describe `servers`, `security`, reusable `components` for things that recur in the spec, and more. +- (If connection stable, tab to full spec) +- Key point: tools can parse this to talk to the API. +::: + +# 🐝 The api2r project + +## 🚀 Goal: *Easier* API →️ R 📦 + +- Common patterns in API R packages +- The system should: + - Parse the spec + - Scaffold reusable code + - Add docs and tests *by default* + +::: notes +- I've wrapped APIs manually—it’s not fun. +- My goal: reduce effort, make it repeatable. +- We should start with the spec and generate most of the rest. +- Think of this as the {usethis} for API packages. +- R Consortium grant to create {api2r}. +- Ended up with three packages, each handling part of the process. +::: + +## 📦 Package 1: `{rapid}` + +- [rapid.api2r.org](https://rapid.api2r.org) +- Input: OpenAPI (JSON/YAML) +- Output: structured **R object** ([{S7}](https://rconsortium.github.io/S7/)) +- Can inspect/edit before pkg step + +::: notes +- R API Description +- `{rapid}` is the parser — it takes your OpenAPI spec and builds a structured R object. +- Can work with URLs to JSON or YAML specs, local spec files, or even in-memory lists. +- Uses the new S7 object system, which makes the output strict and composable. +- These objects are nested — one for the whole API, one per path, one per method, etc. +- You can look at or manipulate them before building the actual package. +- That’s useful if the API is too big or you only want a few endpoints, or if you want to edit the authentication. +::: + +## 📦 Package 2: `{beekeeper}` + +- [beekeeper.api2r.org](https://beekeeper.api2r.org) +- Input: a `rapid` object +- Output: Full R **package skeleton** + - Standardized functions for every API path + method + - Full {roxygen2} docs + - Starter {testthat} / {httptest2} tests + +::: notes +- apis = Latin name for "bee", so beekeepers work with "apis". Also fits with package hex logos. +- `{beekeeper}` takes the parsed API and scaffolds a complete package. +- That includes function files, tests, docs, and a DESCRIPTION file. +- Mostly uses the next package we'll talk about for function calls. +- You'll need to refine manually, but it gives a solid foundation. +- I'm actively working on a big update, watch the repo! +::: + +## 📦 Package 3: `{nectar}` + +- [nectar.api2r.org](https://nectar.api2r.org) +- Used inside generated packages +- Wraps {httr2}, {tibblify}, {stbl} +- Helper functions for: + - Making requests + - Handling pagination/retries + - Tidying responses + +::: notes +- `{nectar}` is an *opinionated* helper package. + - Opinionated = Do things the way I think you should (slightly lower flexibility vs httr2). +- Wraps my {stbl} package to "stabilize" inputs to expected formats, or error before hitting the API if inputs won't work. +- Wraps {httr2} for common API tasks. +- Optionally wraps {tibblify} to make it easier to get tidy outputs. +- Also includes helpers for common pain points like pagination and retries. +- Keeps generated packages clean and consistent. +::: + +## 🔁 Full process + +```r +spec <- rapid::as_rapid("https://my.api/openapi.yaml") +beekeeper::use_beekeeper(spec, "myapi") +beekeeper::generate_pkg() +``` + +- Generated packages are *R packages* +- Edit and maintain as usual! + +::: notes +- This is the whole flow in 3 lines! +- `as_rapid()` reads & parses the OpenAPI spec. +- `use_beekeeper()` sets up the package configuration and logs info about the spec. Gives opportunity to tweak before generation. +- `generate_pkg()` writes all the package files, including docs and tests. +- After that, it’s just like any other R package. +- The goal is not to avoid editing — it’s to give you a head start. +::: + +# 🤝 How can you help? + +## 🧩 Do you work with a weird API? + +- Data science, gov, research, etc +- Lots of APIs aren't R-friendly +- Let's fix that! + +::: notes +- I *want* to collaborate. +- If you’ve wrestled with an obscure or homegrown API, that’s exactly what I want to hear about. +- Right now the API needs to have an OpenAPI or Swagger spec. +- Right now I need people who want to dig in and create their own package. +::: + +## 🔍 Find an OpenAPI spec + +- Start with the docs! +- Try `/swagger.json`, `/openapi.json` + - `/swagger.yaml`, `/swagger.yml`, `/openapi.yaml`, `/openapi.yml` +- ChatGPT (etc) pretty good at spotting specs + +::: notes +- If specs have a common structure & place to test the code, it’s probably using OpenAPI under the hood. +- Also try .yaml, .yml. +- Even if the spec isn’t linked, it might be discoverable. +::: + +## 🐝 What to do + +- Visit [beekeeper.api2r.org](https://beekeeper.api2r.org/) +- Click "Report a bug" +- Open a new issue for your API + +::: notes +- You can find all these links at api2r.org/ghana2025. +- I'd love to know what kinds of APIs you want in R. +- The more real users I hear from, the better I can shape the tools. +::: + +# 🙏 Thanks! + +::: nonincremental +- 🐙 GitHub: [github.com/jonthegeek/beekeeper](https://github.com/jonthegeek/beekeeper) +- 🐝 Website: [beekeeper.api2r.org](https://beekeeper.api2r.org) +- 📬 Contact: [@jonthegeek](https://bsky.app/profile/jonthegeek.com), [DSLC.io](https://DSLC.io) +::: + +::: notes +- Thanks again to Francis and the Ghana R community. +- Please reach out with questions, ideas, or weird APIs! +- Hope to see some collaboration soon. +- For Q&A: I'd love to hear about any APIs people use or want to use! +::: diff --git a/inst/talks/recruit/recruit_generation.R b/inst/talks/recruit/recruit_generation.R new file mode 100644 index 0000000..71f6e38 --- /dev/null +++ b/inst/talks/recruit/recruit_generation.R @@ -0,0 +1,83 @@ +# pak::pak("jonthegeek/robodeck") +# +# Use {robodeck} to help generate an initial version of a slide deck. + +library(robodeck) +title <- "Building the Hive: Collaborating on API Packages with {beekeeper}" +description <- + "Do you use a web API in R, or rely on a particular tool on the web and wish that you could access it in your R code? Do you often find yourself consulting the documentation to remember how to access that tool? Do you wish there was an R package to make all of that easier? Let's work together to create that package! +{beekeeper} is a new package to help you create and maintain an R package to wrap your favorite web API. It takes care of the \"drone work\" of API package creation, so you can quickly generate a package and make sure it's easy-to-use for you and others in the R community. It applies an opinionated framework to help you follow best practices, like consistently documenting parameters, and testing your package to make sure it works how you think it works. By working together and sharing experiences, we can make {beekeeper} even better and ensure it addresses real-world challenges developers face. +This talk is not just about introducing {beekeeper}, but also about building a supportive community of developers who can help each other succeed. Let’s collaborate to create reliable, user-friendly, API-wrapping R packages." |> + stringr::str_squish() + +section_titles <- robodeck::gen_deck_section_titles( + title = "Building the Hive: Collaborating on API Packages with {beekeeper}", + description = description, + minutes = 30, + model = "gpt-4o" +) + +section_titles <- list( + list(title = "Introduction and Motivation", minutes = 6), + list(title = "Creating an API Package with {beekeeper}", minutes = 6), + list(title = "What Works Today", minutes = 6), + list(title = "What's Next", minutes = 6), + list(title = "Collaboration and Community", minutes = 6) +) + +outline <- robodeck::gen_deck_outline( + title = title, + description = description, + minutes = 30, + model = "gpt-4o", + section_titles = section_titles +) + +outline <- list( + `Introduction and Motivation` = c( + "How I Use APIs", + "API Developer Docs: Where's R?", + "The Vision for {beekeeper}" + ), + `Creating an API Package with {beekeeper}` = c( + "Finding the API Spec", + "usethis::create_package()", + "beekeeper::use_beekeeper()", + "beekeeper::generate_pkg()" + ), + `What Works Today` = c( + "Raw generate_pkg() Output", + "Improving Parameter Documentation", + "Response Parsing with {tibblify}", + "Testing & Iterating" + ), + `What's Next` = c( + "What I Have Planned", + "What I Don't Know" + ), + `Collaboration and Community` = c( + "Promoting Your Package", + "Feature Requests and Testing", + "How You Can Contribute to {beekeeper}" + ) +) + +talk <- robodeck::gen_deck( + title = title, + description = description, + minutes = 30, + model = "gpt-4o", + section_titles = section_titles, + outline = outline, + additional_information = "The goal of the talk is to recruit early adopters to develop their own API packages with {beekeeper}. The tone of the talk should be fun and upbeat. Use an emoji at the start of every bullet in bulleted lists." +) + +talk2 <- robodeck::gen_deck( + title = title, + description = description, + minutes = 30, + model = "gpt-4o", + section_titles = section_titles, + outline = outline, + additional_information = "The goal of the talk is to recruit early adopters to develop their own API packages with {beekeeper}. The tone of the talk should be fun and upbeat. Use an emoji at the start of every bullet in bulleted lists. Minimize use of code blocks when possible." +) diff --git a/inst/talks/recruit/style.css b/inst/talks/recruit/style.css new file mode 100644 index 0000000..dfae54e --- /dev/null +++ b/inst/talks/recruit/style.css @@ -0,0 +1,33 @@ +/* +h1.title { + font-size: 2em !important; +} +*/ +.reveal .footer { + font-size: 30px !important; +} + +.reveal .slide-logo { + max-height: unset !important; + height: 70px !important; +} + +.reveal .title-slide { + display: flex; + align-items: center; /* Vertically center the content */ + justify-content: space-between; /* Horizontally space out content */ +} + +.title-slide img { + width: 25%; + float: right; /* Keeps the image to the right */ +} + +.title-slide .title-text { + width: 50%; /* Adjust width as necessary */ + margin-left: 5%; /* Add some spacing between text and image */ +} + +strong { + color: #A0E9FF; +} diff --git a/inst/templates/000-shared.R b/inst/templates/000-shared.R new file mode 100644 index 0000000..f38b9ec --- /dev/null +++ b/inst/templates/000-shared.R @@ -0,0 +1,16 @@ +#' Parameters used in multiple functions +#' +#' Reused parameter definitions are gathered here for easier editing. +#' +#' @param max_reqs (`integer`) The maximum number of separate requests to +#' perform. Passed on to [nectar::req_perform_opinionated()]. +#' @param max_tries_per_req (`integer`) The maximum number of times to attempt +#' each individual request. Passed on to [nectar::req_perform_opinionated()]. +#' @param req (`httr2_request`) The request object to modify. +#' @param ... These dots are for future extensions and must be empty.{{#shared_arg_helps}} +#' @param {{name}} {{{description}}}{{/shared_arg_helps}}{{#security_arg_helps}} +#' @param {{name}} {{{description}}}{{/security_arg_helps}} +#' +#' @name .shared-params +#' @keywords internal +NULL diff --git a/inst/templates/010-call.R b/inst/templates/010-call.R deleted file mode 100644 index 2172fbe..0000000 --- a/inst/templates/010-call.R +++ /dev/null @@ -1,30 +0,0 @@ -# Set up the basic call once at package build. -{{api_abbr}}_req_base <- nectar::req_setup( - "{{base_url}}", - user_agent = "{{pkg_agent}}" -) - -#' Call the {{api_title}} API -#' -#' Generate a request to an {{api_title}} endpoint. -#' -#' @inheritParams nectar::req_modify{{#security_arg_helps}} -#' @param {{name}} {{{description}}}{{/security_arg_helps}} -#' -#' @return The response from the endpoint. -#' @export -{{api_abbr}}_call_api <- function(path, - query = NULL, - body = NULL, - method = NULL{{#has_security}},{{{security_signature}}}{{/has_security}}) { - req <- nectar::req_modify( - {{api_abbr}}_req_base, - path = path, - query = query, - body = body, - method = method - ) - {{#has_security}}req <- .{{api_abbr}}_req_auth(req, {{security_arg_list}}){{/has_security}} - resp <- nectar::req_perform_opinionated(req) - nectar::resp_parse(resp, response_parser = .{{api_abbr}}_response_parser) -} diff --git a/inst/templates/010-prepare.R b/inst/templates/010-prepare.R new file mode 100644 index 0000000..3de93aa --- /dev/null +++ b/inst/templates/010-prepare.R @@ -0,0 +1,33 @@ +#' Generate a request for the {{api_title}} API +#' +#' Prepare a request for the {{api_title}} API, using the opinionated framework +#' defined in [nectar::req_init()], [nectar::req_modify()], +#' [nectar::req_tidy_policy()], and [nectar::req_pagination_policy()]. +#' +#' You may wish to export this function (if the API changes often or you do not +#' fully implement the API, for example). +#'{{#has_security}} +#' @inheritParams .shared-params{{/has_security}} +#' @inheritParams nectar::req_prepare +#' @inherit nectar::req_prepare return +#' @keywords internal +{{api_abbr}}_req_prepare <- function( + path, + query = list(), + body = NULL, + method = NULL, + tidy_policy = nectar::tidy_policy_unknown(),{{#has_security}} +{{{security_signature}}},{{/has_security}} + call = rlang::caller_env()) { + req <- nectar::req_prepare( + "{{base_url}}", + path = path, + query = query, + body = body, + method = method, + tidy_policy = tidy_policy, + call = call + ) + {{#has_security}}req <- .{{api_abbr}}_req_auth(req, {{security_arg_list}}){{/has_security}} + return(req) +} diff --git a/inst/templates/030-pagination.R b/inst/templates/030-pagination.R new file mode 100644 index 0000000..8fb47f5 --- /dev/null +++ b/inst/templates/030-pagination.R @@ -0,0 +1,25 @@ +# These functions were generated by the {beekeeper} package, based on developer +# inputs. You may need to manually rename these functions or tweak their +# arguments. +# +# {beekeeper} currently only supports JSON response bodies with cursor +# pagination. + +{{#pagination_schemes}} +#' Apply pagination +#' +#' Process a `resp` object to determine how and whether to continue iteration, +#' then build the next `req`. Use as the `pagination_fn` argument to +#' [{{api_abbr}}_req_prepare()]. +#' +#' @inheritParams .shared-params +#' @inherit nectar::req_prepare return +#' @keywords internal +{{api_abbr}}_pagination_{{family}} <- function(resp, req) { + nectar::iterate_with_json_cursor( + "{{param_name}}"{{#next_cursor_path}}, + c({{{.}}}){{/next_cursor_path}} + )(resp, req) +} + +{{/pagination_schemes}} diff --git a/inst/templates/paths.R b/inst/templates/paths.R index e6d4571..f5eb9a1 100644 --- a/inst/templates/paths.R +++ b/inst/templates/paths.R @@ -1,25 +1,36 @@ # These functions were generated by the {beekeeper} package, based on the paths # element from the source API description. You should carefully review these -# functions. Missing documentation is tagged with "BKTODO" to make it easier for -# you to search for issues. +# functions. -{{#paths}} #' {{summary}} #' #' {{description}} -#' {{#has_security}} -#' @inheritParams {{api_abbr}}_call_api{{/has_security}}{{#params}} -#' @param {{name}} {{{description}}}{{/params}} -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper +#' {{#params}} +#' @param {{name}} ({{{class}}}) {{{description}}}{{/params}} +#' @inheritParams .shared-params +#' +#' @returns `{{api_abbr}}_{{operation_id}}()`: The API response. #' @export -{{api_abbr}}_{{operation_id}} <- function({{{args}}}{{#has_security}}{{#args}},{{/args}}{{{security_signature}}}{{/has_security}}) { - {{api_abbr}}_call_api( +{{api_abbr}}_{{operation_id}} <- function({{#args}}{{{args}}}, {{/args}}{{#has_security}}{{{security_signature}}}, {{/has_security}}max_reqs = Inf, max_tries_per_req = 3) { + req <- req_{{api_abbr}}_{{operation_id}}({{#args_named}}{{{args_named}}}{{/args_named}}{{#has_security}}{{#args_named}}, {{/args_named}}{{{security_arg_list}}}{{/has_security}}) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname {{api_abbr}}_{{operation_id}} +#' @returns `req_{{api_abbr}}_{{operation_id}}()`: A `httr2_request` request object. +req_{{api_abbr}}_{{operation_id}} <- function({{#args}}{{{args}}}{{/args}}{{#has_security}}{{#args}}, {{/args}}{{{security_signature}}}{{/has_security}}) { + {{api_abbr}}_req_prepare( path = {{{path}}}, method = "{{method}}"{{#has_security}}, {{security_arg_list}}{{/has_security}}{{#params_query}}, query = list({{params_query}}){{/params_query}}{{#params_header}}, - body = list({{params_header}}){{/params_header}} + body = list({{params_header}}){{/params_header}}{{#pagination}}, + pagination_fn = {{pagination_fn}}{{/pagination}}{{#tidy}}, + tidy_policy = {{tidy_policy}}{{/tidy}} ) } - -{{/paths}} diff --git a/inst/templates/test-010-call.R b/inst/templates/test-010-call.R deleted file mode 100644 index 67a0c5a..0000000 --- a/inst/templates/test-010-call.R +++ /dev/null @@ -1,10 +0,0 @@ -httptest2::with_mock_dir("api/01-call/valid", { - test_that("Can call an endpoint without errors", { - # A path will be auto-filled in a future version of beekeeper. - fail( - "Provide any path for this API in PROVIDED_PATH, then delete this fail." - ) - PROVIDED_PATH <- "path/to/endpoint" - expect_no_error({{api_abbr}}_call_api(PROVIDED_PATH)) - }) -}) diff --git a/inst/templates/test-010-prepare.R b/inst/templates/test-010-prepare.R new file mode 100644 index 0000000..198a855 --- /dev/null +++ b/inst/templates/test-010-prepare.R @@ -0,0 +1,12 @@ +test_that("Can prepare a request without errors", { + test_result <- expect_no_error({{api_abbr}}_req_prepare("testing")) + expect_s3_class(test_result, c("nectar_request", "httr2_request")) + expect_named( + test_result, + c("url", "method", "headers", "body", "fields", "options", "policies") + ) + expect_contains( + names(test_result$policies), + "resp_tidy" + ) +}) diff --git a/man/beekeeper-package.Rd b/man/beekeeper-package.Rd index 43e1ed9..8014463 100644 --- a/man/beekeeper-package.Rd +++ b/man/beekeeper-package.Rd @@ -6,6 +6,8 @@ \alias{beekeeper-package} \title{beekeeper: Rapidly Scaffold API Client Packages} \description{ +\if{html}{\figure{logo.svg}{options: style='float: right' alt='logo' width='120'}} + Automatically generate R package skeletons from 'application programming interfaces (APIs)' that follow the 'OpenAPI Specification (OAS)'. The skeletons implement best practices to streamline package development. } \seealso{ @@ -20,6 +22,11 @@ Useful links: \author{ \strong{Maintainer}: Jon Harmon \email{jonthegeek@gmail.com} (\href{https://orcid.org/0000-0003-4781-4346}{ORCID}) [copyright holder] +Authors: +\itemize{ + \item Jon Harmon \email{jonthegeek@gmail.com} (\href{https://orcid.org/0000-0003-4781-4346}{ORCID}) [copyright holder] +} + Other contributors: \itemize{ \item R Consortium [funder] diff --git a/man/dot-assert_is_pkg.Rd b/man/dot-assert_is_pkg.Rd index a486924..31c1b6c 100644 --- a/man/dot-assert_is_pkg.Rd +++ b/man/dot-assert_is_pkg.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/generate_pkg-prepare.R +% Please edit documentation in R/generate_pkg-setup.R \name{.assert_is_pkg} \alias{.assert_is_pkg} \title{Error if not in package} diff --git a/man/dot-is_pkg.Rd b/man/dot-is_pkg.Rd index 4ab171a..ae0dce2 100644 --- a/man/dot-is_pkg.Rd +++ b/man/dot-is_pkg.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/generate_pkg-prepare.R +% Please edit documentation in R/generate_pkg-setup.R \name{.is_pkg} \alias{.is_pkg} \title{Check whether we're in a package} diff --git a/man/dot-pkg_abort.Rd b/man/dot-pkg_abort.Rd new file mode 100644 index 0000000..a9b46ce --- /dev/null +++ b/man/dot-pkg_abort.Rd @@ -0,0 +1,36 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/aaa-conditions.R +\name{.pkg_abort} +\alias{.pkg_abort} +\title{Raise a package-scoped error} +\usage{ +.pkg_abort( + message, + subclass, + call = rlang::caller_env(), + message_env = rlang::caller_env(), + ... +) +} +\arguments{ +\item{message}{(\code{character}) The message for the new error. Messages will be +formatted with \code{\link[cli:cli_bullets]{cli::cli_bullets()}}.} + +\item{subclass}{(\code{character}) Class(es) to assign to the error. Will be +prefixed by "\{package\}-error-".} + +\item{call}{(\code{environment}) The caller environment for error messages.} + +\item{message_env}{(\code{environment}) The execution environment to use to +evaluate variables in error messages.} + +\item{...}{Additional parameters passed to \code{\link[cli:cli_abort]{cli::cli_abort()}} and on to +\code{\link[rlang:abort]{rlang::abort()}}.} +} +\value{ +Does not return. +} +\description{ +Raise a package-scoped error +} +\keyword{internal} diff --git a/man/dot-shared-params.Rd b/man/dot-shared-params.Rd new file mode 100644 index 0000000..381d147 --- /dev/null +++ b/man/dot-shared-params.Rd @@ -0,0 +1,13 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/aaa-shared_params.R +\name{.shared-params} +\alias{.shared-params} +\title{Shared parameters} +\arguments{ +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\description{ +These parameters are used in multiple functions. They are defined here to +make them easier to import and to find. +} +\keyword{internal} diff --git a/man/generate_pkg.Rd b/man/generate_pkg.Rd index 0487d99..d1c261c 100644 --- a/man/generate_pkg.Rd +++ b/man/generate_pkg.Rd @@ -1,19 +1,21 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/generate_pkg_main.R +% Please edit documentation in R/generate_pkg.R \name{generate_pkg} \alias{generate_pkg} \title{Use a beekeeper config file to generate code} \usage{ generate_pkg( config_file = "_beekeeper.yml", - pkg_agent = generate_pkg_agent(config_file) + pkg_dir = fs::path_dir(config_file) ) } \arguments{ -\item{config_file}{The path to a beekeeper yaml file.} +\item{config_file}{(\verb{length-1 character} or \code{fs_path}) The path to a +beekeeper yaml file. All package files are created relative to this file.} -\item{pkg_agent}{A string to identify this package, for use in the -\code{user_agent} argument of \code{\link[nectar:req_setup]{nectar::req_setup()}}.} +\item{pkg_dir}{(\verb{length-1 character} or \code{fs_path}) The directory in which the +package files will be created. Defaults to the directory of the config +file.} } \value{ A character vector of paths to files that were added or updated, diff --git a/man/generate_pkg_agent.Rd b/man/generate_pkg_agent.Rd deleted file mode 100644 index 1ed369d..0000000 --- a/man/generate_pkg_agent.Rd +++ /dev/null @@ -1,19 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/generate_pkg-agent.R -\name{generate_pkg_agent} -\alias{generate_pkg_agent} -\title{Create a user agent for the active package} -\usage{ -generate_pkg_agent(path = ".") -} -\arguments{ -\item{path}{The path to the DESCRIPTION file, or to a directory within a -package.} -} -\value{ -A string with the name of the package and (if available) the first -URL associated with the package. -} -\description{ -Create a user agent for the active package -} diff --git a/tests/testthat/.gitignore b/tests/testthat/.gitignore new file mode 100644 index 0000000..9eccf6f --- /dev/null +++ b/tests/testthat/.gitignore @@ -0,0 +1,2 @@ +testthat-problems.rds +_problems diff --git a/tests/testthat/_fixtures/000-create_fixtures.R b/tests/testthat/_fixtures/000-create_fixtures.R index 21a3ef6..68475c9 100644 --- a/tests/testthat/_fixtures/000-create_fixtures.R +++ b/tests/testthat/_fixtures/000-create_fixtures.R @@ -1,7 +1,11 @@ apid_url <- "https://api.apis.guru/v2/specs/apis.guru/2.2.0/openapi.yaml" api_abbr <- "guru" -rapid_write_path <- test_path(glue::glue("_fixtures/{api_abbr}_rapid.rds")) -config_path <- test_path(glue::glue("_fixtures/{api_abbr}_beekeeper.yml")) +rapid_write_path <- test_path(glue::glue( + "_fixtures/{api_abbr}/_beekeeper_rapid.rds" +)) +config_path <- test_path(glue::glue( + "_fixtures/{api_abbr}/_beekeeper.yml" +)) apid_url |> url() |> use_beekeeper( @@ -12,8 +16,12 @@ apid_url |> apid_url <- "https://api.apis.guru/v2/specs/fec.gov/1.0/openapi.yaml" api_abbr <- "fec" -rapid_write_path <- test_path(glue::glue("_fixtures/{api_abbr}_rapid.rds")) -config_path <- test_path(glue::glue("_fixtures/{api_abbr}_beekeeper.yml")) +rapid_write_path <- test_path(glue::glue( + "_fixtures/{api_abbr}/_beekeeper_rapid.rds" +)) +config_path <- test_path(glue::glue( + "_fixtures/{api_abbr}/_beekeeper.yml" +)) fec_apid <- apid_url |> url() |> yaml::read_yaml() @@ -40,8 +48,12 @@ fec_rapid@paths <- rapid::as_paths({ x$tags <- NULL x }) -rapid_write_path <- test_path(glue::glue("_fixtures/{api_abbr}_subset_rapid.rds")) -config_path <- test_path(glue::glue("_fixtures/{api_abbr}_subset_beekeeper.yml")) +rapid_write_path <- test_path(glue::glue( + "_fixtures/{api_abbr}/{api_abbr}_subset_rapid.rds" +)) +config_path <- test_path(glue::glue( + "_fixtures/{api_abbr}/{api_abbr}_subset_beekeeper.yml" +)) fec_rapid |> use_beekeeper( api_abbr = api_abbr, @@ -51,8 +63,12 @@ fec_rapid |> apid_url <- "https://api.apis.guru/v2/specs/trello.com/1.0/openapi.yaml" api_abbr <- "trello" -rapid_write_path <- test_path(glue::glue("_fixtures/{api_abbr}_rapid.rds")) -config_path <- test_path(glue::glue("_fixtures/{api_abbr}_beekeeper.yml")) +rapid_write_path <- test_path(glue::glue( + "_fixtures/{api_abbr}/_beekeeper_rapid.rds" +)) +config_path <- test_path(glue::glue( + "_fixtures/{api_abbr}/_beekeeper.yml" +)) trello_rapid <- apid_url |> url() |> rapid::as_rapid() diff --git a/tests/testthat/_fixtures/DESCRIPTION b/tests/testthat/_fixtures/DESCRIPTION index 4c50a0b..b669652 100644 --- a/tests/testthat/_fixtures/DESCRIPTION +++ b/tests/testthat/_fixtures/DESCRIPTION @@ -14,7 +14,7 @@ License: MIT + file LICENSE URL: https://beekeeper.api2r.org, https://github.com/jonthegeek/beekeeper BugReports: https://github.com/jonthegeek/beekeeper/issues -Imports: +Imports: cli, desc, fs, @@ -25,18 +25,17 @@ Imports: rlang (>= 1.1.0), rprojroot, S7, - styler, usethis, utils, yaml -Suggests: +Suggests: covr, knitr, rmarkdown, stringr, testthat, withr -VignetteBuilder: +VignetteBuilder: knitr Remotes: jonthegeek/nectar, @@ -44,4 +43,4 @@ Remotes: Config/testthat/edition: 3 Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.2.3 +RoxygenNote: 7.2.3 \ No newline at end of file diff --git a/tests/testthat/_fixtures/fec-paths-audit.R b/tests/testthat/_fixtures/fec-paths-audit.R deleted file mode 100644 index ef3233e..0000000 --- a/tests/testthat/_fixtures/fec-paths-audit.R +++ /dev/null @@ -1,121 +0,0 @@ -# These functions were generated by the {beekeeper} package, based on the paths -# element from the source API description. You should carefully review these -# functions. Missing documentation is tagged with "BKTODO" to make it easier for -# you to search for issues. - -#' Get audit case -#' -#' This endpoint contains Final Audit Reports approved by the Commission since inception. The search can be based on information about the audited committee (Name, FEC ID Number, Type, Election Cycle) or the issues covered in the report. -#' -#' @inheritParams fec_call_api -#' @param audit_case_id Primary/foreign key for audit tables -#' @param cycle Filter records to only those that are applicable to a given two-year period. This cycle follows the traditional House election cycle and subdivides the presidential and Senate elections into comparable two-year blocks. The cycle begins with an odd year and is named for its ending, even year. -#' @param sub_category_id The finding id of an audit. Finding are a category of broader issues. Each category has an unique ID. -#' @param sort_nulls_last Toggle that sorts null values last -#' @param sort_hide_null Hide null values on sorted column(s). -#' @param min_election_cycle Filter records to only those that are applicable to a given two-year period. This cycle follows the traditional House election cycle and subdivides the presidential and Senate elections into comparable two-year blocks. The cycle begins with an odd year and is named for its ending, even year. -#' @param audit_id The audit issue. Each subcategory has an unique ID -#' @param q The name of the committee. If a committee changes its name, the most recent name will be shown. Committee names are not unique. Use committee_id for looking up records. -#' @param per_page The number of results returned per page. Defaults to 20. -#' @param max_election_cycle Filter records to only those that are applicable to a given two-year period. This cycle follows the traditional House election cycle and subdivides the presidential and Senate elections into comparable two-year blocks. The cycle begins with an odd year and is named for its ending, even year. -#' @param candidate_id A unique identifier assigned to each candidate registered with the FEC. If a person runs for several offices, that person will have separate candidate IDs for each office. -#' @param committee_type The one-letter type code of the organization: - C communication cost - D delegate - E electioneering communication - H House - I independent expenditure filer (not a committee) - N PAC - nonqualified - O independent expenditure-only (super PACs) - P presidential - Q PAC - qualified - S Senate - U single candidate independent expenditure - V PAC with non-contribution account, nonqualified - W PAC with non-contribution account, qualified - X party, nonqualified - Y party, qualified - Z national party non-federal account -#' @param qq Name of candidate running for office -#' @param page For paginating through results, starting at page 1 -#' @param committee_id A unique identifier assigned to each committee or filer registered with the FEC. In general committee id's begin with the letter C which is followed by eight digits. -#' @param committee_designation Type of committee: - H or S - Congressional - P - Presidential - X or Y or Z - Party - N or Q - PAC - I - Independent expenditure - O - Super PAC -#' @param primary_category_id Audit category ID (table PK) -#' @param sort_null_only Toggle that filters out all rows having sort column that is non-null -#' @param sort Provide a field to sort by. Use `-` for descending order. ex: `-case_no` -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -fec_get_audit_case <- function(audit_case_id, cycle, sub_category_id, sort_nulls_last, sort_hide_null, min_election_cycle, audit_id, q, per_page, max_election_cycle, candidate_id, committee_type, qq, page, committee_id, committee_designation, primary_category_id, sort_null_only, sort, api_key = Sys.getenv("FEC_API_KEY")) { - fec_call_api( - path = "/audit-case/", - method = "get", - api_key = api_key, - query = list(audit_case_id = audit_case_id, cycle = cycle, sub_category_id = sub_category_id, sort_nulls_last = sort_nulls_last, sort_hide_null = sort_hide_null, min_election_cycle = min_election_cycle, audit_id = audit_id, q = q, per_page = per_page, max_election_cycle = max_election_cycle, candidate_id = candidate_id, committee_type = committee_type, qq = qq, page = page, committee_id = committee_id, committee_designation = committee_designation, primary_category_id = primary_category_id, sort_null_only = sort_null_only, sort = sort) - ) -} - -#' Get audit category -#' -#' This lists the options for the categories and subcategories available in the /audit-search/ endpoint. -#' -#' @inheritParams fec_call_api -#' @param sort_nulls_last Toggle that sorts null values last -#' @param page For paginating through results, starting at page 1 -#' @param primary_category_name Primary Audit Category - No Findings or Issues/Not a Committee - Net Outstanding Campaign/Convention Expenditures/Obligations - Payments/Disgorgements - Allocation Issues - Prohibited Contributions - Disclosure - Recordkeeping - Repayment to US Treasury - Other - Misstatement of Financial Activity - Excessive Contributions - Failure to File Reports/Schedules/Notices - Loans - Referred Findings Not Listed -#' @param sort_hide_null Hide null values on sorted column(s). -#' @param primary_category_id Audit category ID (table PK) -#' @param sort_null_only Toggle that filters out all rows having sort column that is non-null -#' @param per_page The number of results returned per page. Defaults to 20. -#' @param sort Provide a field to sort by. Use `-` for descending order. -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -fec_get_audit_category <- function(sort_nulls_last, page, primary_category_name, sort_hide_null, primary_category_id, sort_null_only, per_page, sort, api_key = Sys.getenv("FEC_API_KEY")) { - fec_call_api( - path = "/audit-category/", - method = "get", - api_key = api_key, - query = list(sort_nulls_last = sort_nulls_last, page = page, primary_category_name = primary_category_name, sort_hide_null = sort_hide_null, primary_category_id = primary_category_id, sort_null_only = sort_null_only, per_page = per_page, sort = sort) - ) -} - -#' Get audit primary category -#' -#' This lists the options for the primary categories available in the /audit-search/ endpoint. -#' -#' @inheritParams fec_call_api -#' @param sort_nulls_last Toggle that sorts null values last -#' @param page For paginating through results, starting at page 1 -#' @param primary_category_name Primary Audit Category - No Findings or Issues/Not a Committee - Net Outstanding Campaign/Convention Expenditures/Obligations - Payments/Disgorgements - Allocation Issues - Prohibited Contributions - Disclosure - Recordkeeping - Repayment to US Treasury - Other - Misstatement of Financial Activity - Excessive Contributions - Failure to File Reports/Schedules/Notices - Loans - Referred Findings Not Listed -#' @param sort_hide_null Hide null values on sorted column(s). -#' @param primary_category_id Audit category ID (table PK) -#' @param sort_null_only Toggle that filters out all rows having sort column that is non-null -#' @param per_page The number of results returned per page. Defaults to 20. -#' @param sort Provide a field to sort by. Use `-` for descending order. -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -fec_get_audit_primary_category <- function(sort_nulls_last, page, primary_category_name, sort_hide_null, primary_category_id, sort_null_only, per_page, sort, api_key = Sys.getenv("FEC_API_KEY")) { - fec_call_api( - path = "/audit-primary-category/", - method = "get", - api_key = api_key, - query = list(sort_nulls_last = sort_nulls_last, page = page, primary_category_name = primary_category_name, sort_hide_null = sort_hide_null, primary_category_id = primary_category_id, sort_null_only = sort_null_only, per_page = per_page, sort = sort) - ) -} - -#' Get names audit candidates -#' -#' Search for candidates or committees by name. If you're looking for information on a particular person or group, using a name to find the `candidate_id` or `committee_id` on this endpoint can be a helpful first step. -#' -#' @inheritParams fec_call_api -#' @param q Name (candidate or committee) to search for -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -fec_get_names_audit_candidates <- function(q, api_key = Sys.getenv("FEC_API_KEY")) { - fec_call_api( - path = "/names/audit_candidates/", - method = "get", - api_key = api_key, - query = list(q = q) - ) -} - -#' Get names audit committees -#' -#' Search for candidates or committees by name. If you're looking for information on a particular person or group, using a name to find the `candidate_id` or `committee_id` on this endpoint can be a helpful first step. -#' -#' @inheritParams fec_call_api -#' @param q Name (candidate or committee) to search for -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -fec_get_names_audit_committees <- function(q, api_key = Sys.getenv("FEC_API_KEY")) { - fec_call_api( - path = "/names/audit_committees/", - method = "get", - api_key = api_key, - query = list(q = q) - ) -} diff --git a/tests/testthat/_fixtures/fec/_beekeeper.yml b/tests/testthat/_fixtures/fec/_beekeeper.yml new file mode 100644 index 0000000..cbd7a2a --- /dev/null +++ b/tests/testthat/_fixtures/fec/_beekeeper.yml @@ -0,0 +1,5 @@ +api_title: OpenFEC +api_abbr: fec +api_version: '1.0' +rapid_file: _beekeeper_rapid.rds +updated_on: 2026-05-12 07:57:21 diff --git a/tests/testthat/_fixtures/fec/_beekeeper_rapid.rds b/tests/testthat/_fixtures/fec/_beekeeper_rapid.rds new file mode 100644 index 0000000..681384a Binary files /dev/null and b/tests/testthat/_fixtures/fec/_beekeeper_rapid.rds differ diff --git a/tests/testthat/_fixtures/fec_subset_beekeeper.yml b/tests/testthat/_fixtures/fec/fec_subset_beekeeper.yml similarity index 72% rename from tests/testthat/_fixtures/fec_subset_beekeeper.yml rename to tests/testthat/_fixtures/fec/fec_subset_beekeeper.yml index 42e2dc2..1f25328 100644 --- a/tests/testthat/_fixtures/fec_subset_beekeeper.yml +++ b/tests/testthat/_fixtures/fec/fec_subset_beekeeper.yml @@ -2,4 +2,4 @@ api_title: OpenFEC api_abbr: fec api_version: '1.0' rapid_file: fec_subset_rapid.rds -updated_on: 2024-03-29 19:53:51 +updated_on: 2026-05-12 07:57:29 diff --git a/tests/testthat/_fixtures/fec_subset_rapid.rds b/tests/testthat/_fixtures/fec/fec_subset_rapid.rds similarity index 100% rename from tests/testthat/_fixtures/fec_subset_rapid.rds rename to tests/testthat/_fixtures/fec/fec_subset_rapid.rds diff --git a/tests/testthat/_fixtures/fec/paths-audit-get_audit_case.R b/tests/testthat/_fixtures/fec/paths-audit-get_audit_case.R new file mode 100644 index 0000000..4444ada --- /dev/null +++ b/tests/testthat/_fixtures/fec/paths-audit-get_audit_case.R @@ -0,0 +1,137 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Get audit case +#' +#' This endpoint contains Final Audit Reports approved by the Commission since inception. The search can be based on information about the audited committee (Name, FEC ID Number, Type, Election Cycle) or the issues covered in the report. +#' +#' @param audit_case_id (length-1 [base::list()], optional) Primary/foreign key for audit tables +#' @param cycle (length-1 [base::list()], optional) Filter records to only those that are applicable to a given two-year period. This cycle follows the traditional House election cycle and subdivides the presidential and Senate elections into comparable two-year blocks. The cycle begins with an odd year and is named for its ending, even year. +#' @param sub_category_id (length-1 [base::character()], optional) The finding id of an audit. Finding are a category of broader issues. Each category has an unique ID. +#' @param sort_nulls_last (length-1 [base::logical()], optional) Toggle that sorts null values last +#' @param sort_hide_null (length-1 [base::logical()], optional) Hide null values on sorted column(s). +#' @param min_election_cycle (length-1 [base::list()], optional) Filter records to only those that are applicable to a given two-year period. This cycle follows the traditional House election cycle and subdivides the presidential and Senate elections into comparable two-year blocks. The cycle begins with an odd year and is named for its ending, even year. +#' @param audit_id (length-1 [base::list()], optional) The audit issue. Each subcategory has an unique ID +#' @param q (length-1 [base::list()], optional) The name of the committee. If a committee changes its name, the most recent name will be shown. Committee names are not unique. Use committee_id for looking up records. +#' @param per_page (length-1 [base::list()], optional) The number of results returned per page. Defaults to 20. +#' @param max_election_cycle (length-1 [base::list()], optional) Filter records to only those that are applicable to a given two-year period. This cycle follows the traditional House election cycle and subdivides the presidential and Senate elections into comparable two-year blocks. The cycle begins with an odd year and is named for its ending, even year. +#' @param candidate_id (length-1 [base::list()], optional) A unique identifier assigned to each candidate registered with the FEC. If a person runs for several offices, that person will have separate candidate IDs for each office. +#' @param committee_type (length-1 [base::list()], optional) The one-letter type code of the organization: - C communication cost - D delegate - E electioneering communication - H House - I independent expenditure filer (not a committee) - N PAC - nonqualified - O independent expenditure-only (super PACs) - P presidential - Q PAC - qualified - S Senate - U single candidate independent expenditure - V PAC with non-contribution account, nonqualified - W PAC with non-contribution account, qualified - X party, nonqualified - Y party, qualified - Z national party non-federal account +#' @param qq (length-1 [base::list()], optional) Name of candidate running for office +#' @param page (length-1 [base::list()], optional) For paginating through results, starting at page 1 +#' @param committee_id (length-1 [base::list()], optional) A unique identifier assigned to each committee or filer registered with the FEC. In general committee id's begin with the letter C which is followed by eight digits. +#' @param api_key (length-1 [base::character()]) API key for https://api.data.gov. Get one at https://api.data.gov/signup. +#' @param committee_designation (length-1 [base::character()], optional) Type of committee: - H or S - Congressional - P - Presidential - X or Y or Z - Party - N or Q - PAC - I - Independent expenditure - O - Super PAC +#' @param primary_category_id (length-1 [base::character()], optional) Audit category ID (table PK) +#' @param sort_null_only (length-1 [base::logical()], optional) Toggle that filters out all rows having sort column that is non-null +#' @param sort (length-1 [base::list()], optional) Provide a field to sort by. Use `-` for descending order. ex: `-case_no` +#' @inheritParams .shared-params +#' +#' @returns `fec_get_audit_case()`: The API response. +#' @export +fec_get_audit_case <- function( + audit_case_id, + cycle, + sub_category_id, + sort_nulls_last, + sort_hide_null, + min_election_cycle, + audit_id, + q, + per_page, + max_election_cycle, + candidate_id, + committee_type, + qq, + page, + committee_id, + committee_designation, + primary_category_id, + sort_null_only, + sort, + api_key = Sys.getenv("FEC_API_KEY"), + max_reqs = Inf, + max_tries_per_req = 3 +) { + req <- req_fec_get_audit_case( + audit_case_id = audit_case_id, + cycle = cycle, + sub_category_id = sub_category_id, + sort_nulls_last = sort_nulls_last, + sort_hide_null = sort_hide_null, + min_election_cycle = min_election_cycle, + audit_id = audit_id, + q = q, + per_page = per_page, + max_election_cycle = max_election_cycle, + candidate_id = candidate_id, + committee_type = committee_type, + qq = qq, + page = page, + committee_id = committee_id, + committee_designation = committee_designation, + primary_category_id = primary_category_id, + sort_null_only = sort_null_only, + sort = sort, + api_key = api_key + ) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname fec_get_audit_case +#' @returns `req_fec_get_audit_case()`: A `httr2_request` request object. +req_fec_get_audit_case <- function( + audit_case_id, + cycle, + sub_category_id, + sort_nulls_last, + sort_hide_null, + min_election_cycle, + audit_id, + q, + per_page, + max_election_cycle, + candidate_id, + committee_type, + qq, + page, + committee_id, + committee_designation, + primary_category_id, + sort_null_only, + sort, + api_key = Sys.getenv("FEC_API_KEY") +) { + fec_req_prepare( + path = "/audit-case/", + method = "get", + api_key = api_key, + query = list( + audit_case_id = audit_case_id, + cycle = cycle, + sub_category_id = sub_category_id, + sort_nulls_last = sort_nulls_last, + sort_hide_null = sort_hide_null, + min_election_cycle = min_election_cycle, + audit_id = audit_id, + q = q, + per_page = per_page, + max_election_cycle = max_election_cycle, + candidate_id = candidate_id, + committee_type = committee_type, + qq = qq, + page = page, + committee_id = committee_id, + committee_designation = committee_designation, + primary_category_id = primary_category_id, + sort_null_only = sort_null_only, + sort = sort + ) + ) +} diff --git a/tests/testthat/_fixtures/fec/paths-audit-get_audit_category.R b/tests/testthat/_fixtures/fec/paths-audit-get_audit_category.R new file mode 100644 index 0000000..d779c74 --- /dev/null +++ b/tests/testthat/_fixtures/fec/paths-audit-get_audit_category.R @@ -0,0 +1,82 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Get audit category +#' +#' This lists the options for the categories and subcategories available in the /audit-search/ endpoint. +#' +#' @param sort_nulls_last (length-1 [base::logical()], optional) Toggle that sorts null values last +#' @param page (length-1 [base::list()], optional) For paginating through results, starting at page 1 +#' @param primary_category_name (length-1 [base::list()], optional) Primary Audit Category - No Findings or Issues/Not a Committee - Net Outstanding Campaign/Convention Expenditures/Obligations - Payments/Disgorgements - Allocation Issues - Prohibited Contributions - Disclosure - Recordkeeping - Repayment to US Treasury - Other - Misstatement of Financial Activity - Excessive Contributions - Failure to File Reports/Schedules/Notices - Loans - Referred Findings Not Listed +#' @param sort_hide_null (length-1 [base::logical()], optional) Hide null values on sorted column(s). +#' @param api_key (length-1 [base::character()]) API key for https://api.data.gov. Get one at https://api.data.gov/signup. +#' @param primary_category_id (length-1 [base::list()], optional) Audit category ID (table PK) +#' @param sort_null_only (length-1 [base::logical()], optional) Toggle that filters out all rows having sort column that is non-null +#' @param per_page (length-1 [base::list()], optional) The number of results returned per page. Defaults to 20. +#' @param sort (length-1 [base::character()], optional) Provide a field to sort by. Use `-` for descending order. +#' @inheritParams .shared-params +#' +#' @returns `fec_get_audit_category()`: The API response. +#' @export +fec_get_audit_category <- function( + sort_nulls_last, + page, + primary_category_name, + sort_hide_null, + primary_category_id, + sort_null_only, + per_page, + sort, + api_key = Sys.getenv("FEC_API_KEY"), + max_reqs = Inf, + max_tries_per_req = 3 +) { + req <- req_fec_get_audit_category( + sort_nulls_last = sort_nulls_last, + page = page, + primary_category_name = primary_category_name, + sort_hide_null = sort_hide_null, + primary_category_id = primary_category_id, + sort_null_only = sort_null_only, + per_page = per_page, + sort = sort, + api_key = api_key + ) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname fec_get_audit_category +#' @returns `req_fec_get_audit_category()`: A `httr2_request` request object. +req_fec_get_audit_category <- function( + sort_nulls_last, + page, + primary_category_name, + sort_hide_null, + primary_category_id, + sort_null_only, + per_page, + sort, + api_key = Sys.getenv("FEC_API_KEY") +) { + fec_req_prepare( + path = "/audit-category/", + method = "get", + api_key = api_key, + query = list( + sort_nulls_last = sort_nulls_last, + page = page, + primary_category_name = primary_category_name, + sort_hide_null = sort_hide_null, + primary_category_id = primary_category_id, + sort_null_only = sort_null_only, + per_page = per_page, + sort = sort + ) + ) +} diff --git a/tests/testthat/_fixtures/fec/paths-audit-get_audit_primary_category.R b/tests/testthat/_fixtures/fec/paths-audit-get_audit_primary_category.R new file mode 100644 index 0000000..259e0c2 --- /dev/null +++ b/tests/testthat/_fixtures/fec/paths-audit-get_audit_primary_category.R @@ -0,0 +1,82 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Get audit primary category +#' +#' This lists the options for the primary categories available in the /audit-search/ endpoint. +#' +#' @param sort_nulls_last (length-1 [base::logical()], optional) Toggle that sorts null values last +#' @param page (length-1 [base::list()], optional) For paginating through results, starting at page 1 +#' @param primary_category_name (length-1 [base::list()], optional) Primary Audit Category - No Findings or Issues/Not a Committee - Net Outstanding Campaign/Convention Expenditures/Obligations - Payments/Disgorgements - Allocation Issues - Prohibited Contributions - Disclosure - Recordkeeping - Repayment to US Treasury - Other - Misstatement of Financial Activity - Excessive Contributions - Failure to File Reports/Schedules/Notices - Loans - Referred Findings Not Listed +#' @param sort_hide_null (length-1 [base::logical()], optional) Hide null values on sorted column(s). +#' @param api_key (length-1 [base::character()]) API key for https://api.data.gov. Get one at https://api.data.gov/signup. +#' @param primary_category_id (length-1 [base::list()], optional) Audit category ID (table PK) +#' @param sort_null_only (length-1 [base::logical()], optional) Toggle that filters out all rows having sort column that is non-null +#' @param per_page (length-1 [base::list()], optional) The number of results returned per page. Defaults to 20. +#' @param sort (length-1 [base::character()], optional) Provide a field to sort by. Use `-` for descending order. +#' @inheritParams .shared-params +#' +#' @returns `fec_get_audit_primary_category()`: The API response. +#' @export +fec_get_audit_primary_category <- function( + sort_nulls_last, + page, + primary_category_name, + sort_hide_null, + primary_category_id, + sort_null_only, + per_page, + sort, + api_key = Sys.getenv("FEC_API_KEY"), + max_reqs = Inf, + max_tries_per_req = 3 +) { + req <- req_fec_get_audit_primary_category( + sort_nulls_last = sort_nulls_last, + page = page, + primary_category_name = primary_category_name, + sort_hide_null = sort_hide_null, + primary_category_id = primary_category_id, + sort_null_only = sort_null_only, + per_page = per_page, + sort = sort, + api_key = api_key + ) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname fec_get_audit_primary_category +#' @returns `req_fec_get_audit_primary_category()`: A `httr2_request` request object. +req_fec_get_audit_primary_category <- function( + sort_nulls_last, + page, + primary_category_name, + sort_hide_null, + primary_category_id, + sort_null_only, + per_page, + sort, + api_key = Sys.getenv("FEC_API_KEY") +) { + fec_req_prepare( + path = "/audit-primary-category/", + method = "get", + api_key = api_key, + query = list( + sort_nulls_last = sort_nulls_last, + page = page, + primary_category_name = primary_category_name, + sort_hide_null = sort_hide_null, + primary_category_id = primary_category_id, + sort_null_only = sort_null_only, + per_page = per_page, + sort = sort + ) + ) +} diff --git a/tests/testthat/_fixtures/fec/paths-audit-get_names_audit_candidates.R b/tests/testthat/_fixtures/fec/paths-audit-get_names_audit_candidates.R new file mode 100644 index 0000000..da32a64 --- /dev/null +++ b/tests/testthat/_fixtures/fec/paths-audit-get_names_audit_candidates.R @@ -0,0 +1,34 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Get names audit candidates +#' +#' Search for candidates or committees by name. If you're looking for information on a particular person or group, using a name to find the `candidate_id` or `committee_id` on this endpoint can be a helpful first step. +#' +#' @param api_key (length-1 [base::character()]) API key for https://api.data.gov. Get one at https://api.data.gov/signup. +#' @param q (length-1 [base::list()]) Name (candidate or committee) to search for +#' @inheritParams .shared-params +#' +#' @returns `fec_get_names_audit_candidates()`: The API response. +#' @export +fec_get_names_audit_candidates <- function(q, api_key = Sys.getenv("FEC_API_KEY"), max_reqs = Inf, max_tries_per_req = 3) { + req <- req_fec_get_names_audit_candidates(q = q, api_key = api_key) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname fec_get_names_audit_candidates +#' @returns `req_fec_get_names_audit_candidates()`: A `httr2_request` request object. +req_fec_get_names_audit_candidates <- function(q, api_key = Sys.getenv("FEC_API_KEY")) { + fec_req_prepare( + path = "/names/audit_candidates/", + method = "get", + api_key = api_key, + query = list(q = q) + ) +} diff --git a/tests/testthat/_fixtures/fec/paths-audit-get_names_audit_committees.R b/tests/testthat/_fixtures/fec/paths-audit-get_names_audit_committees.R new file mode 100644 index 0000000..41c3c69 --- /dev/null +++ b/tests/testthat/_fixtures/fec/paths-audit-get_names_audit_committees.R @@ -0,0 +1,43 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Get names audit committees +#' +#' Search for candidates or committees by name. If you're looking for information on a particular person or group, using a name to find the `candidate_id` or `committee_id` on this endpoint can be a helpful first step. +#' +#' +#' @param api_key (length-1 [base::character()]) API key for https://api.data.gov. Get one at https://api.data.gov/signup. +#' @param q (length-1 [base::list()]) Name (candidate or committee) to search for +#' @inheritParams .shared-params +#' +#' @returns `fec_get_names_audit_committees()`: The API response. +#' @export +fec_get_names_audit_committees <- function( + q, + api_key = Sys.getenv("FEC_API_KEY"), + max_reqs = Inf, + max_tries_per_req = 3 +) { + req <- req_fec_get_names_audit_committees(q = q, api_key = api_key) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname fec_get_names_audit_committees +#' @returns `req_fec_get_names_audit_committees()`: A `httr2_request` request object. +req_fec_get_names_audit_committees <- function( + q, + api_key = Sys.getenv("FEC_API_KEY") +) { + fec_req_prepare( + path = "/names/audit_committees/", + method = "get", + api_key = api_key, + query = list(q = q) + ) +} diff --git a/tests/testthat/_fixtures/fec/paths-debts-get_schedules_schedule_d.R b/tests/testthat/_fixtures/fec/paths-debts-get_schedules_schedule_d.R new file mode 100644 index 0000000..d529d72 --- /dev/null +++ b/tests/testthat/_fixtures/fec/paths-debts-get_schedules_schedule_d.R @@ -0,0 +1,157 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Get schedules schedule d +#' +#' Schedule D, it shows debts and obligations owed to or by the committee that are required to be disclosed. +#' +#' @param creditor_debtor_name (length-1 [base::list()], optional) +#' @param max_image_number (length-1 [base::character()], optional) Maxium image number of the page where the schedule item is reported +#' @param sort_nulls_last (length-1 [base::logical()], optional) Toggle that sorts null values last +#' @param max_amount_outstanding_beginning (length-1 [base::double()], optional) +#' @param sort_hide_null (length-1 [base::logical()], optional) Hide null values on sorted column(s). +#' @param min_payment_period (length-1 [base::double()], optional) +#' @param max_amount_incurred (length-1 [base::double()], optional) +#' @param nature_of_debt (length-1 [base::character()], optional) +#' @param per_page (length-1 [base::list()], optional) The number of results returned per page. Defaults to 20. +#' @param max_amount_outstanding_close (length-1 [base::double()], optional) +#' @param candidate_id (length-1 [base::list()], optional) A unique identifier assigned to each candidate registered with the FEC. If a person runs for several offices, that person will have separate candidate IDs for each office. +#' @param page (length-1 [base::list()], optional) For paginating through results, starting at page 1 +#' @param min_date (length-1 [base::Date()], optional) Minimum load date +#' @param committee_id (length-1 [base::list()], optional) A unique identifier assigned to each committee or filer registered with the FEC. In general committee id's begin with the letter C which is followed by eight digits. +#' @param min_amount_outstanding_close (length-1 [base::double()], optional) +#' @param api_key (length-1 [base::character()]) API key for https://api.data.gov. Get one at https://api.data.gov/signup. +#' @param max_payment_period (length-1 [base::double()], optional) +#' @param min_image_number (length-1 [base::character()], optional) Minium image number of the page where the schedule item is reported +#' @param min_amount_incurred (length-1 [base::double()], optional) +#' @param sort_null_only (length-1 [base::logical()], optional) Toggle that filters out all rows having sort column that is non-null +#' @param image_number (length-1 [base::list()], optional) An unique identifier for each page where the electronic or paper filing is reported. +#' @param sort (length-1 [base::character()], optional) Provide a field to sort by. Use `-` for descending order. +#' @param min_amount_outstanding_beginning (length-1 [base::double()], optional) +#' @param max_date (length-1 [base::Date()], optional) Maximum load date +#' @inheritParams .shared-params +#' +#' @returns `fec_get_schedules_schedule_d()`: The API response. +#' @export +fec_get_schedules_schedule_d <- function( + creditor_debtor_name, + max_image_number, + sort_nulls_last, + max_amount_outstanding_beginning, + sort_hide_null, + min_payment_period, + max_amount_incurred, + nature_of_debt, + per_page, + max_amount_outstanding_close, + candidate_id, + page, + min_date, + committee_id, + min_amount_outstanding_close, + max_payment_period, + min_image_number, + min_amount_incurred, + sort_null_only, + image_number, + sort, + min_amount_outstanding_beginning, + max_date, + api_key = Sys.getenv("FEC_API_KEY"), + max_reqs = Inf, + max_tries_per_req = 3 +) { + req <- req_fec_get_schedules_schedule_d( + creditor_debtor_name = creditor_debtor_name, + max_image_number = max_image_number, + sort_nulls_last = sort_nulls_last, + max_amount_outstanding_beginning = max_amount_outstanding_beginning, + sort_hide_null = sort_hide_null, + min_payment_period = min_payment_period, + max_amount_incurred = max_amount_incurred, + nature_of_debt = nature_of_debt, + per_page = per_page, + max_amount_outstanding_close = max_amount_outstanding_close, + candidate_id = candidate_id, + page = page, + min_date = min_date, + committee_id = committee_id, + min_amount_outstanding_close = min_amount_outstanding_close, + max_payment_period = max_payment_period, + min_image_number = min_image_number, + min_amount_incurred = min_amount_incurred, + sort_null_only = sort_null_only, + image_number = image_number, + sort = sort, + min_amount_outstanding_beginning = min_amount_outstanding_beginning, + max_date = max_date, + api_key = api_key + ) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname fec_get_schedules_schedule_d +#' @returns `req_fec_get_schedules_schedule_d()`: A `httr2_request` request object. +req_fec_get_schedules_schedule_d <- function( + creditor_debtor_name, + max_image_number, + sort_nulls_last, + max_amount_outstanding_beginning, + sort_hide_null, + min_payment_period, + max_amount_incurred, + nature_of_debt, + per_page, + max_amount_outstanding_close, + candidate_id, + page, + min_date, + committee_id, + min_amount_outstanding_close, + max_payment_period, + min_image_number, + min_amount_incurred, + sort_null_only, + image_number, + sort, + min_amount_outstanding_beginning, + max_date, + api_key = Sys.getenv("FEC_API_KEY") +) { + fec_req_prepare( + path = "/schedules/schedule_d/", + method = "get", + api_key = api_key, + query = list( + creditor_debtor_name = creditor_debtor_name, + max_image_number = max_image_number, + sort_nulls_last = sort_nulls_last, + max_amount_outstanding_beginning = max_amount_outstanding_beginning, + sort_hide_null = sort_hide_null, + min_payment_period = min_payment_period, + max_amount_incurred = max_amount_incurred, + nature_of_debt = nature_of_debt, + per_page = per_page, + max_amount_outstanding_close = max_amount_outstanding_close, + candidate_id = candidate_id, + page = page, + min_date = min_date, + committee_id = committee_id, + min_amount_outstanding_close = min_amount_outstanding_close, + max_payment_period = max_payment_period, + min_image_number = min_image_number, + min_amount_incurred = min_amount_incurred, + sort_null_only = sort_null_only, + image_number = image_number, + sort = sort, + min_amount_outstanding_beginning = min_amount_outstanding_beginning, + max_date = max_date + ) + ) +} diff --git a/tests/testthat/_fixtures/fec/paths-debts-get_schedules_schedule_d_sub_id.R b/tests/testthat/_fixtures/fec/paths-debts-get_schedules_schedule_d_sub_id.R new file mode 100644 index 0000000..6666c89 --- /dev/null +++ b/tests/testthat/_fixtures/fec/paths-debts-get_schedules_schedule_d_sub_id.R @@ -0,0 +1,76 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Get schedules schedule d sub id +#' +#' Schedule D, it shows debts and obligations owed to or by the committee that are required to be disclosed. +#' +#' @param sort_nulls_last (length-1 [base::logical()], optional) Toggle that sorts null values last +#' @param per_page (length-1 [base::list()], optional) The number of results returned per page. Defaults to 20. +#' @param sort_null_only (length-1 [base::logical()], optional) Toggle that filters out all rows having sort column that is non-null +#' @param sort_hide_null (length-1 [base::logical()], optional) Hide null values on sorted column(s). +#' @param sort (length-1 [base::character()], optional) Provide a field to sort by. Use `-` for descending order. +#' @param api_key (length-1 [base::character()]) API key for https://api.data.gov. Get one at https://api.data.gov/signup. +#' @param page (length-1 [base::list()], optional) For paginating through results, starting at page 1 +#' @param sub_id (length-1 [base::character()]) +#' @inheritParams .shared-params +#' +#' @returns `fec_get_schedules_schedule_d_sub_id()`: The API response. +#' @export +fec_get_schedules_schedule_d_sub_id <- function( + sort_nulls_last, + per_page, + sort_null_only, + sort_hide_null, + sort, + page, + sub_id, + api_key = Sys.getenv("FEC_API_KEY"), + max_reqs = Inf, + max_tries_per_req = 3 +) { + req <- req_fec_get_schedules_schedule_d_sub_id( + sort_nulls_last = sort_nulls_last, + per_page = per_page, + sort_null_only = sort_null_only, + sort_hide_null = sort_hide_null, + sort = sort, + page = page, + sub_id = sub_id, + api_key = api_key + ) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname fec_get_schedules_schedule_d_sub_id +#' @returns `req_fec_get_schedules_schedule_d_sub_id()`: A `httr2_request` request object. +req_fec_get_schedules_schedule_d_sub_id <- function( + sort_nulls_last, + per_page, + sort_null_only, + sort_hide_null, + sort, + page, + sub_id, + api_key = Sys.getenv("FEC_API_KEY") +) { + fec_req_prepare( + path = c("/schedules/schedule_d/{sub_id}/", sub_id = sub_id), + method = "get", + api_key = api_key, + query = list( + sort_nulls_last = sort_nulls_last, + per_page = per_page, + sort_null_only = sort_null_only, + sort_hide_null = sort_hide_null, + sort = sort, + page = page + ) + ) +} diff --git a/tests/testthat/_fixtures/fec/paths-legal-get_legal_search.R b/tests/testthat/_fixtures/fec/paths-legal-get_legal_search.R new file mode 100644 index 0000000..d25908f --- /dev/null +++ b/tests/testthat/_fixtures/fec/paths-legal-get_legal_search.R @@ -0,0 +1,252 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Get legal search +#' +#' Search legal documents by document type, or across all document types using keywords, parameter values and ranges. +#' +#' @param hits_returned (length-1 [base::list()], optional) Number of results to return (max 10) +#' @param af_report_year (length-1 [base::character()], optional) Admin fine report year +#' @param case_max_open_date (length-1 [base::Date()], optional) The latest date opened of case +#' @param ao_max_issue_date (length-1 [base::Date()], optional) Latest issue date of advisory opinion +#' @param case_statutory_citation (length-1 [base::list()], optional) Statutory citations +#' @param case_respondents (length-1 [base::character()], optional) Cases respondents +#' @param q (length-1 [base::character()], optional) Text to search legal documents for +#' @param ao_min_issue_date (length-1 [base::Date()], optional) Earliest issue date of advisory opinion +#' @param af_max_fd_date (length-1 [base::Date()], optional) The latest Final Determination date +#' @param from_hit (length-1 [base::list()], optional) Get results starting from this index +#' @param af_fd_fine_amount (length-1 [base::list()], optional) Final Determination fine amount +#' @param type (length-1 [base::character()], optional) Legal Document type to refine search by - statutes - regulations - advisory_opinions - murs - admin_fines +#' @param api_key (length-1 [base::character()]) API key for https://api.data.gov. Get one at https://api.data.gov/signup. +#' @param af_name (length-1 [base::list()], optional) Admin fine committee name +#' @param ao_requestor_type (length-1 [base::list()], optional) Code of the advisory opinion requestor type. +#' @param ao_statutory_citation (length-1 [base::list()], optional) Statutory citations +#' @param ao_entity_name (length-1 [base::list()], optional) Name of commenter or representative +#' @param mur_type (length-1 [base::character()], optional) Type of MUR : current or archived +#' @param ao_regulatory_citation (length-1 [base::list()], optional) Regulatory citations +#' @param af_committee_id (length-1 [base::character()], optional) Admin fine committee ID +#' @param ao_requestor (length-1 [base::character()], optional) The requestor of the advisory opinion +#' @param case_citation_require_all (length-1 [base::logical()], optional) Require all citations to be in document (default behavior is any) +#' @param af_min_fd_date (length-1 [base::Date()], optional) The earliest Final Determination date +#' @param ao_is_pending (length-1 [base::logical()], optional) AO is pending +#' @param af_rtb_fine_amount (length-1 [base::list()], optional) Reason to Believe fine amount +#' @param case_election_cycles (length-1 [base::list()], optional) Cases election cycles +#' @param ao_category (length-1 [base::list()], optional) Category of the document +#' @param ao_citation_require_all (length-1 [base::logical()], optional) Require all citations to be in document (default behavior is any) +#' @param case_dispositions (length-1 [base::list()], optional) Cases dispositions +#' @param af_max_rtb_date (length-1 [base::Date()], optional) The latest Reason to Believe date +#' @param case_min_open_date (length-1 [base::Date()], optional) The earliest date opened of case +#' @param case_max_close_date (length-1 [base::Date()], optional) The latest date closed of case +#' @param ao_min_request_date (length-1 [base::Date()], optional) Earliest request date of advisory opinion +#' @param ao_status (length-1 [base::character()], optional) Status of AO (pending, withdrawn, or final) +#' @param case_doc_category_id (length-1 [base::list()], optional) Select one or more case_doc_category_id to filter by corresponding CASE_DOCUMENT_CATEGORY: - 1 - Conciliation Agreements - 2 - Complaint, Responses, Designation of Counsel and Extensions of Timee - 3 - General Counsel Reports, Briefs, Notifications and Responses - 4 - Certifications - 5 - Civil Penalties, Disgorgements and Other Payments - 6 - Statements of Reasons +#' @param af_min_rtb_date (length-1 [base::Date()], optional) The earliest Reason to Believe date +#' @param ao_name (length-1 [base::list()], optional) Force advisory opinion name +#' @param case_regulatory_citation (length-1 [base::list()], optional) Regulatory citations +#' @param ao_no (length-1 [base::list()], optional) Force advisory opinion number +#' @param case_min_close_date (length-1 [base::Date()], optional) The earliest date closed of case +#' @param sort (length-1 [base::character()], optional) Provide a field to sort by. Use `-` for descending order. ex: `-case_no` +#' @param ao_max_request_date (length-1 [base::Date()], optional) Latest request date of advisory opinion +#' @param case_no (length-1 [base::list()], optional) Enforcement matter case number +#' @inheritParams .shared-params +#' +#' @returns `fec_get_legal_search()`: The API response. +#' @export +fec_get_legal_search <- function( + hits_returned, + af_report_year, + case_max_open_date, + ao_max_issue_date, + case_statutory_citation, + case_respondents, + q, + ao_min_issue_date, + af_max_fd_date, + from_hit, + af_fd_fine_amount, + type, + af_name, + ao_requestor_type, + ao_statutory_citation, + ao_entity_name, + mur_type, + ao_regulatory_citation, + af_committee_id, + ao_requestor, + case_citation_require_all, + af_min_fd_date, + ao_is_pending, + af_rtb_fine_amount, + case_election_cycles, + ao_category, + ao_citation_require_all, + case_dispositions, + af_max_rtb_date, + case_min_open_date, + case_max_close_date, + ao_min_request_date, + ao_status, + case_doc_category_id, + af_min_rtb_date, + ao_name, + case_regulatory_citation, + ao_no, + case_min_close_date, + sort, + ao_max_request_date, + case_no, + api_key = Sys.getenv("FEC_API_KEY"), + max_reqs = Inf, + max_tries_per_req = 3 +) { + req <- req_fec_get_legal_search( + hits_returned = hits_returned, + af_report_year = af_report_year, + case_max_open_date = case_max_open_date, + ao_max_issue_date = ao_max_issue_date, + case_statutory_citation = case_statutory_citation, + case_respondents = case_respondents, + q = q, + ao_min_issue_date = ao_min_issue_date, + af_max_fd_date = af_max_fd_date, + from_hit = from_hit, + af_fd_fine_amount = af_fd_fine_amount, + type = type, + af_name = af_name, + ao_requestor_type = ao_requestor_type, + ao_statutory_citation = ao_statutory_citation, + ao_entity_name = ao_entity_name, + mur_type = mur_type, + ao_regulatory_citation = ao_regulatory_citation, + af_committee_id = af_committee_id, + ao_requestor = ao_requestor, + case_citation_require_all = case_citation_require_all, + af_min_fd_date = af_min_fd_date, + ao_is_pending = ao_is_pending, + af_rtb_fine_amount = af_rtb_fine_amount, + case_election_cycles = case_election_cycles, + ao_category = ao_category, + ao_citation_require_all = ao_citation_require_all, + case_dispositions = case_dispositions, + af_max_rtb_date = af_max_rtb_date, + case_min_open_date = case_min_open_date, + case_max_close_date = case_max_close_date, + ao_min_request_date = ao_min_request_date, + ao_status = ao_status, + case_doc_category_id = case_doc_category_id, + af_min_rtb_date = af_min_rtb_date, + ao_name = ao_name, + case_regulatory_citation = case_regulatory_citation, + ao_no = ao_no, + case_min_close_date = case_min_close_date, + sort = sort, + ao_max_request_date = ao_max_request_date, + case_no = case_no, + api_key = api_key + ) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname fec_get_legal_search +#' @returns `req_fec_get_legal_search()`: A `httr2_request` request object. +req_fec_get_legal_search <- function( + hits_returned, + af_report_year, + case_max_open_date, + ao_max_issue_date, + case_statutory_citation, + case_respondents, + q, + ao_min_issue_date, + af_max_fd_date, + from_hit, + af_fd_fine_amount, + type, + af_name, + ao_requestor_type, + ao_statutory_citation, + ao_entity_name, + mur_type, + ao_regulatory_citation, + af_committee_id, + ao_requestor, + case_citation_require_all, + af_min_fd_date, + ao_is_pending, + af_rtb_fine_amount, + case_election_cycles, + ao_category, + ao_citation_require_all, + case_dispositions, + af_max_rtb_date, + case_min_open_date, + case_max_close_date, + ao_min_request_date, + ao_status, + case_doc_category_id, + af_min_rtb_date, + ao_name, + case_regulatory_citation, + ao_no, + case_min_close_date, + sort, + ao_max_request_date, + case_no, + api_key = Sys.getenv("FEC_API_KEY") +) { + fec_req_prepare( + path = "/legal/search/", + method = "get", + api_key = api_key, + query = list( + hits_returned = hits_returned, + af_report_year = af_report_year, + case_max_open_date = case_max_open_date, + ao_max_issue_date = ao_max_issue_date, + case_statutory_citation = case_statutory_citation, + case_respondents = case_respondents, + q = q, + ao_min_issue_date = ao_min_issue_date, + af_max_fd_date = af_max_fd_date, + from_hit = from_hit, + af_fd_fine_amount = af_fd_fine_amount, + type = type, + af_name = af_name, + ao_requestor_type = ao_requestor_type, + ao_statutory_citation = ao_statutory_citation, + ao_entity_name = ao_entity_name, + mur_type = mur_type, + ao_regulatory_citation = ao_regulatory_citation, + af_committee_id = af_committee_id, + ao_requestor = ao_requestor, + case_citation_require_all = case_citation_require_all, + af_min_fd_date = af_min_fd_date, + ao_is_pending = ao_is_pending, + af_rtb_fine_amount = af_rtb_fine_amount, + case_election_cycles = case_election_cycles, + ao_category = ao_category, + ao_citation_require_all = ao_citation_require_all, + case_dispositions = case_dispositions, + af_max_rtb_date = af_max_rtb_date, + case_min_open_date = case_min_open_date, + case_max_close_date = case_max_close_date, + ao_min_request_date = ao_min_request_date, + ao_status = ao_status, + case_doc_category_id = case_doc_category_id, + af_min_rtb_date = af_min_rtb_date, + ao_name = ao_name, + case_regulatory_citation = case_regulatory_citation, + ao_no = ao_no, + case_min_close_date = case_min_close_date, + sort = sort, + ao_max_request_date = ao_max_request_date, + case_no = case_no + ) + ) +} diff --git a/tests/testthat/_fixtures/fec_beekeeper.yml b/tests/testthat/_fixtures/fec_beekeeper.yml deleted file mode 100644 index 6bd2871..0000000 --- a/tests/testthat/_fixtures/fec_beekeeper.yml +++ /dev/null @@ -1,5 +0,0 @@ -api_title: OpenFEC -api_abbr: fec -api_version: '1.0' -rapid_file: fec_rapid.rds -updated_on: 2024-03-27 19:14:26 diff --git a/tests/testthat/_fixtures/fec_rapid.rds b/tests/testthat/_fixtures/fec_rapid.rds deleted file mode 100644 index 156bb72..0000000 Binary files a/tests/testthat/_fixtures/fec_rapid.rds and /dev/null differ diff --git a/tests/testthat/_fixtures/guru-010-call.R b/tests/testthat/_fixtures/guru-010-call.R deleted file mode 100644 index 61ca27b..0000000 --- a/tests/testthat/_fixtures/guru-010-call.R +++ /dev/null @@ -1,29 +0,0 @@ -# Set up the basic call once at package build. -guru_req_base <- nectar::req_setup( - "https://api.apis.guru/v2", - user_agent = "TESTPKG (https://example.com)" -) - -#' Call the APIs.guru API -#' -#' Generate a request to an APIs.guru endpoint. -#' -#' @inheritParams nectar::req_modify -#' -#' @return The response from the endpoint. -#' @export -guru_call_api <- function(path, - query = NULL, - body = NULL, - method = NULL) { - req <- nectar::req_modify( - guru_req_base, - path = path, - query = query, - body = body, - method = method - ) - - resp <- nectar::req_perform_opinionated(req) - nectar::resp_parse(resp, response_parser = .guru_response_parser) -} diff --git a/tests/testthat/_fixtures/guru-paths-apis.R b/tests/testthat/_fixtures/guru-paths-apis.R deleted file mode 100644 index 237d607..0000000 --- a/tests/testthat/_fixtures/guru-paths-apis.R +++ /dev/null @@ -1,102 +0,0 @@ -# These functions were generated by the {beekeeper} package, based on the paths -# element from the source API description. You should carefully review these -# functions. Missing documentation is tagged with "BKTODO" to make it easier for -# you to search for issues. - -#' List all APIs -#' -#' List all APIs in the directory. Returns links to the OpenAPI definitions for each API in the directory. If API exist in multiple versions `preferred` one is explicitly marked. Some basic info from the OpenAPI definition is cached inside each object. This allows you to generate some simple views without needing to fetch the OpenAPI definition for each API. -#' -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -guru_list_apis <- function() { - guru_call_api( - path = "/list.json", - method = "get" - ) -} - -#' Get basic metrics -#' -#' Some basic metrics for the entire directory. Just stunning numbers to put on a front page and are intended purely for WoW effect :) -#' -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -guru_get_metrics <- function() { - guru_call_api( - path = "/metrics.json", - method = "get" - ) -} - -#' List all providers -#' -#' List all the providers in the directory -#' -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -guru_get_providers <- function() { - guru_call_api( - path = "/providers.json", - method = "get" - ) -} - -#' Retrieve one version of a particular API -#' -#' Returns the API entry for one specific version of an API where there is no serviceName. -#' -#' @param provider BKTODO: No description provided. -#' @param api BKTODO: No description provided. -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -guru_get_api <- function(provider, api) { - guru_call_api( - path = c("/specs/{provider}/{api}.json", provider = provider, api = api), - method = "get" - ) -} - -#' Retrieve one version of a particular API with a serviceName. -#' -#' Returns the API entry for one specific version of an API where there is a serviceName. -#' -#' @param provider BKTODO: No description provided. -#' @param service BKTODO: No description provided. -#' @param api BKTODO: No description provided. -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -guru_get_service_api <- function(provider, service, api) { - guru_call_api( - path = c("/specs/{provider}/{service}/{api}.json", provider = provider, service = service, api = api), - method = "get" - ) -} - -#' List all APIs for a particular provider -#' -#' List all APIs in the directory for a particular providerName Returns links to the individual API entry for each API. -#' -#' @param provider BKTODO: No description provided. -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -guru_get_provider <- function(provider) { - guru_call_api( - path = c("/{provider}.json", provider = provider), - method = "get" - ) -} - -#' List all serviceNames for a particular provider -#' -#' List all serviceNames in the directory for a particular providerName -#' -#' @param provider BKTODO: No description provided. -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -guru_get_services <- function(provider) { - guru_call_api( - path = c("/{provider}/services.json", provider = provider), - method = "get" - ) -} diff --git a/tests/testthat/_fixtures/guru-test-010-call.R b/tests/testthat/_fixtures/guru-test-010-call.R deleted file mode 100644 index 53dc0bb..0000000 --- a/tests/testthat/_fixtures/guru-test-010-call.R +++ /dev/null @@ -1,10 +0,0 @@ -httptest2::with_mock_dir("api/01-call/valid", { - test_that("Can call an endpoint without errors", { - # A path will be auto-filled in a future version of beekeeper. - fail( - "Provide any path for this API in PROVIDED_PATH, then delete this fail." - ) - PROVIDED_PATH <- "path/to/endpoint" - expect_no_error(guru_call_api(PROVIDED_PATH)) - }) -}) diff --git a/tests/testthat/_fixtures/guru/000-shared.R b/tests/testthat/_fixtures/guru/000-shared.R new file mode 100644 index 0000000..7d8be44 --- /dev/null +++ b/tests/testthat/_fixtures/guru/000-shared.R @@ -0,0 +1,14 @@ +#' Parameters used in multiple functions +#' +#' Reused parameter definitions are gathered here for easier editing. +#' +#' @param max_reqs (`integer`) The maximum number of separate requests to +#' perform. Passed on to [nectar::req_perform_opinionated()]. +#' @param max_tries_per_req (`integer`) The maximum number of times to attempt +#' each individual request. Passed on to [nectar::req_perform_opinionated()]. +#' @param req (`httr2_request`) The request object to modify. +#' @param ... These dots are for future extensions and must be empty. +#' +#' @name .shared-params +#' @keywords internal +NULL diff --git a/tests/testthat/_fixtures/guru/010-prepare.R b/tests/testthat/_fixtures/guru/010-prepare.R new file mode 100644 index 0000000..d4dc478 --- /dev/null +++ b/tests/testthat/_fixtures/guru/010-prepare.R @@ -0,0 +1,31 @@ +#' Generate a request for the APIs.guru API +#' +#' Prepare a request for the APIs.guru API, using the opinionated framework +#' defined in [nectar::req_init()], [nectar::req_modify()], +#' [nectar::req_tidy_policy()], and [nectar::req_pagination_policy()]. +#' +#' You may wish to export this function (if the API changes often or you do not +#' fully implement the API, for example). +#' +#' @inheritParams nectar::req_prepare +#' @inherit nectar::req_prepare return +#' @keywords internal +guru_req_prepare <- function( + path, + query = list(), + body = NULL, + method = NULL, + tidy_policy = nectar::tidy_policy_unknown(), + call = rlang::caller_env()) { + req <- nectar::req_prepare( + "https://api.apis.guru/v2", + path = path, + query = query, + body = body, + method = method, + tidy_policy = tidy_policy, + call = call + ) + + return(req) +} diff --git a/tests/testthat/_fixtures/guru/_beekeeper.yml b/tests/testthat/_fixtures/guru/_beekeeper.yml new file mode 100644 index 0000000..3c59cd4 --- /dev/null +++ b/tests/testthat/_fixtures/guru/_beekeeper.yml @@ -0,0 +1,5 @@ +api_title: APIs.guru +api_abbr: guru +api_version: 2.2.0 +rapid_file: _beekeeper_rapid.rds +updated_on: 2026-05-12 07:57:02 diff --git a/tests/testthat/_fixtures/guru/_beekeeper_rapid.rds b/tests/testthat/_fixtures/guru/_beekeeper_rapid.rds new file mode 100644 index 0000000..aedfdf2 Binary files /dev/null and b/tests/testthat/_fixtures/guru/_beekeeper_rapid.rds differ diff --git a/tests/testthat/_fixtures/guru/paths-apis-get_api.R b/tests/testthat/_fixtures/guru/paths-apis-get_api.R new file mode 100644 index 0000000..346f0a6 --- /dev/null +++ b/tests/testthat/_fixtures/guru/paths-apis-get_api.R @@ -0,0 +1,32 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Retrieve one version of a particular API +#' +#' Returns the API entry for one specific version of an API where there is no serviceName. +#' +#' @param provider (length-1 [base::character()]) +#' @param api (length-1 [base::character()]) +#' @inheritParams .shared-params +#' +#' @returns `guru_get_api()`: The API response. +#' @export +guru_get_api <- function(provider, api, max_reqs = Inf, max_tries_per_req = 3) { + req <- req_guru_get_api(provider = provider, api = api) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname guru_get_api +#' @returns `req_guru_get_api()`: A `httr2_request` request object. +req_guru_get_api <- function(provider, api) { + guru_req_prepare( + path = c("/specs/{provider}/{api}.json", provider = provider, api = api), + method = "get" + ) +} diff --git a/tests/testthat/_fixtures/guru/paths-apis-get_metrics.R b/tests/testthat/_fixtures/guru/paths-apis-get_metrics.R new file mode 100644 index 0000000..04d2e6b --- /dev/null +++ b/tests/testthat/_fixtures/guru/paths-apis-get_metrics.R @@ -0,0 +1,30 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Get basic metrics +#' +#' Some basic metrics for the entire directory. Just stunning numbers to put on a front page and are intended purely for WoW effect :) +#' +#' @inheritParams .shared-params +#' +#' @returns `guru_get_metrics()`: The API response. +#' @export +guru_get_metrics <- function(max_reqs = Inf, max_tries_per_req = 3) { + req <- req_guru_get_metrics() + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname guru_get_metrics +#' @returns `req_guru_get_metrics()`: A `httr2_request` request object. +req_guru_get_metrics <- function() { + guru_req_prepare( + path = "/metrics.json", + method = "get" + ) +} diff --git a/tests/testthat/_fixtures/guru/paths-apis-get_provider.R b/tests/testthat/_fixtures/guru/paths-apis-get_provider.R new file mode 100644 index 0000000..d4230b1 --- /dev/null +++ b/tests/testthat/_fixtures/guru/paths-apis-get_provider.R @@ -0,0 +1,31 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' List all APIs for a particular provider +#' +#' List all APIs in the directory for a particular providerName Returns links to the individual API entry for each API. +#' +#' @param provider (length-1 [base::character()]) +#' @inheritParams .shared-params +#' +#' @returns `guru_get_provider()`: The API response. +#' @export +guru_get_provider <- function(provider, max_reqs = Inf, max_tries_per_req = 3) { + req <- req_guru_get_provider(provider = provider) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname guru_get_provider +#' @returns `req_guru_get_provider()`: A `httr2_request` request object. +req_guru_get_provider <- function(provider) { + guru_req_prepare( + path = c("/{provider}.json", provider = provider), + method = "get" + ) +} diff --git a/tests/testthat/_fixtures/guru/paths-apis-get_providers.R b/tests/testthat/_fixtures/guru/paths-apis-get_providers.R new file mode 100644 index 0000000..ca20227 --- /dev/null +++ b/tests/testthat/_fixtures/guru/paths-apis-get_providers.R @@ -0,0 +1,30 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' List all providers +#' +#' List all the providers in the directory +#' +#' @inheritParams .shared-params +#' +#' @returns `guru_get_providers()`: The API response. +#' @export +guru_get_providers <- function(max_reqs = Inf, max_tries_per_req = 3) { + req <- req_guru_get_providers() + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname guru_get_providers +#' @returns `req_guru_get_providers()`: A `httr2_request` request object. +req_guru_get_providers <- function() { + guru_req_prepare( + path = "/providers.json", + method = "get" + ) +} diff --git a/tests/testthat/_fixtures/guru/paths-apis-get_service_api.R b/tests/testthat/_fixtures/guru/paths-apis-get_service_api.R new file mode 100644 index 0000000..2c6c727 --- /dev/null +++ b/tests/testthat/_fixtures/guru/paths-apis-get_service_api.R @@ -0,0 +1,33 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' Retrieve one version of a particular API with a serviceName. +#' +#' Returns the API entry for one specific version of an API where there is a serviceName. +#' +#' @param provider (length-1 [base::character()]) +#' @param service (length-1 [base::character()]) +#' @param api (length-1 [base::character()]) +#' @inheritParams .shared-params +#' +#' @returns `guru_get_service_api()`: The API response. +#' @export +guru_get_service_api <- function(provider, service, api, max_reqs = Inf, max_tries_per_req = 3) { + req <- req_guru_get_service_api(provider = provider, service = service, api = api) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname guru_get_service_api +#' @returns `req_guru_get_service_api()`: A `httr2_request` request object. +req_guru_get_service_api <- function(provider, service, api) { + guru_req_prepare( + path = c("/specs/{provider}/{service}/{api}.json", provider = provider, service = service, api = api), + method = "get" + ) +} diff --git a/tests/testthat/_fixtures/guru/paths-apis-get_services.R b/tests/testthat/_fixtures/guru/paths-apis-get_services.R new file mode 100644 index 0000000..37dd8e8 --- /dev/null +++ b/tests/testthat/_fixtures/guru/paths-apis-get_services.R @@ -0,0 +1,31 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' List all serviceNames for a particular provider +#' +#' List all serviceNames in the directory for a particular providerName +#' +#' @param provider (length-1 [base::character()]) +#' @inheritParams .shared-params +#' +#' @returns `guru_get_services()`: The API response. +#' @export +guru_get_services <- function(provider, max_reqs = Inf, max_tries_per_req = 3) { + req <- req_guru_get_services(provider = provider) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname guru_get_services +#' @returns `req_guru_get_services()`: A `httr2_request` request object. +req_guru_get_services <- function(provider) { + guru_req_prepare( + path = c("/{provider}/services.json", provider = provider), + method = "get" + ) +} diff --git a/tests/testthat/_fixtures/guru/paths-apis-list_apis.R b/tests/testthat/_fixtures/guru/paths-apis-list_apis.R new file mode 100644 index 0000000..d8a7e16 --- /dev/null +++ b/tests/testthat/_fixtures/guru/paths-apis-list_apis.R @@ -0,0 +1,30 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' List all APIs +#' +#' List all APIs in the directory. Returns links to the OpenAPI definitions for each API in the directory. If API exist in multiple versions `preferred` one is explicitly marked. Some basic info from the OpenAPI definition is cached inside each object. This allows you to generate some simple views without needing to fetch the OpenAPI definition for each API. +#' +#' @inheritParams .shared-params +#' +#' @returns `guru_list_apis()`: The API response. +#' @export +guru_list_apis <- function(max_reqs = Inf, max_tries_per_req = 3) { + req <- req_guru_list_apis() + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname guru_list_apis +#' @returns `req_guru_list_apis()`: A `httr2_request` request object. +req_guru_list_apis <- function() { + guru_req_prepare( + path = "/list.json", + method = "get" + ) +} diff --git a/tests/testthat/_fixtures/guru-setup.R b/tests/testthat/_fixtures/guru/setup.R similarity index 100% rename from tests/testthat/_fixtures/guru-setup.R rename to tests/testthat/_fixtures/guru/setup.R diff --git a/tests/testthat/_fixtures/guru/test-010-prepare.R b/tests/testthat/_fixtures/guru/test-010-prepare.R new file mode 100644 index 0000000..06b5bc5 --- /dev/null +++ b/tests/testthat/_fixtures/guru/test-010-prepare.R @@ -0,0 +1,12 @@ +test_that("Can prepare a request without errors", { + test_result <- expect_no_error(guru_req_prepare("testing")) + expect_s3_class(test_result, c("nectar_request", "httr2_request")) + expect_named( + test_result, + c("url", "method", "headers", "body", "fields", "options", "policies") + ) + expect_contains( + names(test_result$policies), + "resp_tidy" + ) +}) diff --git a/tests/testthat/_fixtures/guru-test-paths-apis.R b/tests/testthat/_fixtures/guru/test-paths-apis.R similarity index 99% rename from tests/testthat/_fixtures/guru-test-paths-apis.R rename to tests/testthat/_fixtures/guru/test-paths-apis.R index eecb1ef..c0b452e 100644 --- a/tests/testthat/_fixtures/guru-test-paths-apis.R +++ b/tests/testthat/_fixtures/guru/test-paths-apis.R @@ -52,4 +52,6 @@ with_mock_dir("api/path/apis", { test_result }) }) + + }) diff --git a/tests/testthat/_fixtures/guru_beekeeper.yml b/tests/testthat/_fixtures/guru_beekeeper.yml deleted file mode 100644 index bafdc56..0000000 --- a/tests/testthat/_fixtures/guru_beekeeper.yml +++ /dev/null @@ -1,5 +0,0 @@ -api_title: APIs.guru -api_abbr: guru -api_version: 2.2.0 -rapid_file: guru_rapid.rds -updated_on: 2024-03-27 19:14:00 diff --git a/tests/testthat/_fixtures/guru_rapid.rds b/tests/testthat/_fixtures/guru_rapid.rds deleted file mode 100644 index 692b328..0000000 Binary files a/tests/testthat/_fixtures/guru_rapid.rds and /dev/null differ diff --git a/tests/testthat/_fixtures/trello-010-call.R b/tests/testthat/_fixtures/trello-010-call.R deleted file mode 100644 index 54ae1ec..0000000 --- a/tests/testthat/_fixtures/trello-010-call.R +++ /dev/null @@ -1,32 +0,0 @@ -# Set up the basic call once at package build. -trello_req_base <- nectar::req_setup( - "https://trello.com/1", - user_agent = "TESTPKG (https://example.com)" -) - -#' Call the Trello API -#' -#' Generate a request to an Trello endpoint. -#' -#' @inheritParams nectar::req_modify -#' @param key An API key provided by the API provider. This key is not clearly documented in the API description. Check the API documentation for details. -#' @param token An API key provided by the API provider. This key is not clearly documented in the API description. Check the API documentation for details. -#' -#' @return The response from the endpoint. -#' @export -trello_call_api <- function(path, - query = NULL, - body = NULL, - method = NULL, key = Sys.getenv("TRELLO_KEY"), - token = Sys.getenv("TRELLO_TOKEN")) { - req <- nectar::req_modify( - trello_req_base, - path = path, - query = query, - body = body, - method = method - ) - req <- .trello_req_auth(req, key = key, token = token) - resp <- nectar::req_perform_opinionated(req) - nectar::resp_parse(resp, response_parser = .trello_response_parser) -} diff --git a/tests/testthat/_fixtures/trello-paths-board.R b/tests/testthat/_fixtures/trello-paths-board.R deleted file mode 100644 index 1146ff3..0000000 --- a/tests/testthat/_fixtures/trello-paths-board.R +++ /dev/null @@ -1,21 +0,0 @@ -# These functions were generated by the {beekeeper} package, based on the paths -# element from the source API description. You should carefully review these -# functions. Missing documentation is tagged with "BKTODO" to make it easier for -# you to search for issues. - -#' addBoards() -#' -#' BKTODO: No description provided. -#' -#' @inheritParams trello_call_api -#' @return BKTODO: Return descriptions are not yet implemented in beekeeper -#' @export -trello_add_boards <- function( - key = Sys.getenv("TRELLO_KEY"), - token = Sys.getenv("TRELLO_TOKEN")) { - trello_call_api( - path = "/boards", - method = "post", - key = key, token = token - ) -} diff --git a/tests/testthat/_fixtures/trello/000-shared.R b/tests/testthat/_fixtures/trello/000-shared.R new file mode 100644 index 0000000..2364930 --- /dev/null +++ b/tests/testthat/_fixtures/trello/000-shared.R @@ -0,0 +1,16 @@ +#' Parameters used in multiple functions +#' +#' Reused parameter definitions are gathered here for easier editing. +#' +#' @param max_reqs (`integer`) The maximum number of separate requests to +#' perform. Passed on to [nectar::req_perform_opinionated()]. +#' @param max_tries_per_req (`integer`) The maximum number of times to attempt +#' each individual request. Passed on to [nectar::req_perform_opinionated()]. +#' @param req (`httr2_request`) The request object to modify. +#' @param ... These dots are for future extensions and must be empty. +#' @param key An API key provided by the API provider. This key is not clearly documented in the API description. Check the API documentation for details. +#' @param token An API key provided by the API provider. This key is not clearly documented in the API description. Check the API documentation for details. +#' +#' @name .shared-params +#' @keywords internal +NULL diff --git a/tests/testthat/_fixtures/trello/010-prepare.R b/tests/testthat/_fixtures/trello/010-prepare.R new file mode 100644 index 0000000..64954de --- /dev/null +++ b/tests/testthat/_fixtures/trello/010-prepare.R @@ -0,0 +1,34 @@ +#' Generate a request for the Trello API +#' +#' Prepare a request for the Trello API, using the opinionated framework +#' defined in [nectar::req_init()], [nectar::req_modify()], +#' [nectar::req_tidy_policy()], and [nectar::req_pagination_policy()]. +#' +#' You may wish to export this function (if the API changes often or you do not +#' fully implement the API, for example). +#' +#' @inheritParams .shared-params +#' @inheritParams nectar::req_prepare +#' @inherit nectar::req_prepare return +#' @keywords internal +trello_req_prepare <- function( + path, + query = list(), + body = NULL, + method = NULL, + tidy_policy = nectar::tidy_policy_unknown(), +key = Sys.getenv("TRELLO_KEY"), +token = Sys.getenv("TRELLO_TOKEN"), + call = rlang::caller_env()) { + req <- nectar::req_prepare( + "https://trello.com/1", + path = path, + query = query, + body = body, + method = method, + tidy_policy = tidy_policy, + call = call + ) + req <- .trello_req_auth(req, key = key, token = token) + return(req) +} diff --git a/tests/testthat/_fixtures/trello-020-auth.R b/tests/testthat/_fixtures/trello/020-auth.R similarity index 99% rename from tests/testthat/_fixtures/trello-020-auth.R rename to tests/testthat/_fixtures/trello/020-auth.R index ce22fd8..a606dad 100644 --- a/tests/testthat/_fixtures/trello-020-auth.R +++ b/tests/testthat/_fixtures/trello/020-auth.R @@ -35,3 +35,4 @@ api_key = token ) } + diff --git a/tests/testthat/_fixtures/trello/_beekeeper.yml b/tests/testthat/_fixtures/trello/_beekeeper.yml new file mode 100644 index 0000000..e7113dc --- /dev/null +++ b/tests/testthat/_fixtures/trello/_beekeeper.yml @@ -0,0 +1,5 @@ +api_title: Trello +api_abbr: trello +api_version: '1.0' +rapid_file: _beekeeper_rapid.rds +updated_on: 2026-05-12 07:57:43 diff --git a/tests/testthat/_fixtures/trello/_beekeeper_rapid.rds b/tests/testthat/_fixtures/trello/_beekeeper_rapid.rds new file mode 100644 index 0000000..a55737e Binary files /dev/null and b/tests/testthat/_fixtures/trello/_beekeeper_rapid.rds differ diff --git a/tests/testthat/_fixtures/trello/paths-board-add_boards.R b/tests/testthat/_fixtures/trello/paths-board-add_boards.R new file mode 100644 index 0000000..c87dbd8 --- /dev/null +++ b/tests/testthat/_fixtures/trello/paths-board-add_boards.R @@ -0,0 +1,35 @@ +# These functions were generated by the {beekeeper} package, based on the paths +# element from the source API description. You should carefully review these +# functions. + +#' addBoards() +#' +#' addBoards() +#' +#' @param key (length-1 [base::character()]) Generate your application key +#' @param token (length-1 [base::character()]) Getting a token from a user +#' @inheritParams .shared-params +#' +#' @returns `trello_add_boards()`: The API response. +#' @export +trello_add_boards <- function(key = Sys.getenv("TRELLO_KEY"), +token = Sys.getenv("TRELLO_TOKEN"), max_reqs = Inf, max_tries_per_req = 3) { + req <- req_trello_add_boards(key = key, token = token) + resps <- nectar::req_perform_opinionated( + req, + max_reqs = max_reqs, + max_tries_per_req = max_tries_per_req + ) + return(nectar::resp_parse(resps)) +} + +#' @rdname trello_add_boards +#' @returns `req_trello_add_boards()`: A `httr2_request` request object. +req_trello_add_boards <- function(key = Sys.getenv("TRELLO_KEY"), +token = Sys.getenv("TRELLO_TOKEN")) { + trello_req_prepare( + path = "/boards", + method = "post", + key = key, token = token + ) +} diff --git a/tests/testthat/_fixtures/trello/test-paths-board.R b/tests/testthat/_fixtures/trello/test-paths-board.R new file mode 100644 index 0000000..eabaa6b --- /dev/null +++ b/tests/testthat/_fixtures/trello/test-paths-board.R @@ -0,0 +1,13 @@ +# These tests were generated by the {beekeeper} package, based on the paths +# element from the source API description. You will likely need to supply +# arguments for the tests to succeed. We recommend expanding these tests to +# check for specific expectations, rather than simply using a snapshot. + +with_mock_dir("api/path/board", { + test_that("trello_add_boards() returns expected result", { + expect_snapshot({ + test_result <- trello_add_boards() + test_result + }) + }) +}) diff --git a/tests/testthat/_fixtures/trello_beekeeper.yml b/tests/testthat/_fixtures/trello_beekeeper.yml deleted file mode 100644 index 2627140..0000000 --- a/tests/testthat/_fixtures/trello_beekeeper.yml +++ /dev/null @@ -1,5 +0,0 @@ -api_title: Trello -api_abbr: trello -api_version: '1.0' -rapid_file: trello_rapid.rds -updated_on: 2024-03-29 21:06:50 diff --git a/tests/testthat/_fixtures/trello_rapid.rds b/tests/testthat/_fixtures/trello_rapid.rds deleted file mode 100644 index 55d2c57..0000000 Binary files a/tests/testthat/_fixtures/trello_rapid.rds and /dev/null differ diff --git a/tests/testthat/_snaps/aaa-conditions.md b/tests/testthat/_snaps/aaa-conditions.md new file mode 100644 index 0000000..df12762 --- /dev/null +++ b/tests/testthat/_snaps/aaa-conditions.md @@ -0,0 +1,10 @@ +# .pkg_abort works + + Code + (expect_pkg_error_classes(.pkg_abort("This is a test error", c("subclass", + "test_error")), "beekeeper", "subclass", "test_error")) + Output + + Error: + ! This is a test error + diff --git a/tests/testthat/_snaps/generate_pkg-paths.md b/tests/testthat/_snaps/generate_pkg-paths.md deleted file mode 100644 index 4315052..0000000 --- a/tests/testthat/_snaps/generate_pkg-paths.md +++ /dev/null @@ -1,11 +0,0 @@ -# generate_pkg() generates path functions for fec - - Code - scrub_path(changed_files) - Output - [1] "/R/010-call.R" "/tests/testthat/test-010-call.R" - [3] "/R/020-auth.R" "/R/paths-audit.R" - [5] "/tests/testthat/test-paths-audit.R" "/R/paths-legal.R" - [7] "/tests/testthat/test-paths-legal.R" "/R/paths-debts.R" - [9] "/tests/testthat/test-paths-debts.R" "/tests/testthat/setup.R" - diff --git a/tests/testthat/_snaps/generate_pkg-prepare.md b/tests/testthat/_snaps/generate_pkg-prepare.md deleted file mode 100644 index dd14438..0000000 --- a/tests/testthat/_snaps/generate_pkg-prepare.md +++ /dev/null @@ -1,9 +0,0 @@ -# .assert_is_pkg() errors informatively for non-packages - - Code - .assert_is_pkg(tempdir()) - Condition - Error in `.assert_is_pkg()`: - ! Can't generate package files outside of a package. - TMPDIR is not inside a package. - diff --git a/tests/testthat/_snaps/generate_pkg-setup.md b/tests/testthat/_snaps/generate_pkg-setup.md new file mode 100644 index 0000000..ad9a2b7 --- /dev/null +++ b/tests/testthat/_snaps/generate_pkg-setup.md @@ -0,0 +1,30 @@ +# .assert_is_pkg() errors informatively for non-packages + + Code + .assert_is_pkg(tempdir()) + Condition + Error in `.assert_is_pkg()`: + ! Can't generate package files outside of a package. + TMPDIR is not inside a package. + +# .read_config() reads configs + + Code + config + Output + $api_title + [1] "APIs.guru" + + $api_abbr + [1] "guru" + + $api_version + [1] "2.2.0" + + $rapid_file + [1] "_beekeeper_rapid.rds" + + $updated_on + [1] "2026-05-12 07:57:02 UTC" + + diff --git a/tests/testthat/helper.R b/tests/testthat/helper.R index ba2afa5..d9d338a 100644 --- a/tests/testthat/helper.R +++ b/tests/testthat/helper.R @@ -1,7 +1,6 @@ # Inspired by https://github.com/r-lib/usethis tests/testthat/helper.R -create_local_package <- function(pkgname = "testpkg", - env = parent.frame()) { +create_local_package <- function(pkgname = "testpkg", env = parent.frame()) { withr::local_options(usethis.quiet = TRUE, .local_envir = env) dir <- withr::local_tempdir(pattern = pkgname, .local_envir = env) @@ -9,9 +8,7 @@ create_local_package <- function(pkgname = "testpkg", usethis::create_package( dir, - # I need a url to check for it in user agent. fields = list( - "URL" = "https://example.com", "Config/testthat/edition" = "3" ), rstudio = TRUE, @@ -61,3 +58,48 @@ scrub_path <- function(input, keep_dirs = c("R", "tests")) { "\\1" ) } + +# Find all fixture files matching a regexp and read their contents. +# Returns a named list of character vectors, where names are the fixture +# filenames. +load_expected_files <- function(api_abbr, regexp) { + test_dir <- test_path("_fixtures", api_abbr) + files <- fs::dir_ls(test_dir, regexp = regexp) + names(files) <- fs::path_file(files) + purrr::map(files, readLines) +} + +# A mock for .bk_use_template_impl() that records calls and returns a +# predictable path, without writing any files or needing a usethis project. +make_spy_impl <- function() { + calls <- list() + list( + mock = function(template, data, target, dir) { + calls[[length(calls) + 1]] <<- list( + template = template, + data = data, + target = target, + dir = dir + ) + file.path(dir, target) + }, + calls = function() calls + ) +} + +# A mock that renders templates to a temp dir using whisker directly, so the +# output can be visually confirmed against fixture files. +make_writing_impl <- function(tmp) { + function(template, data, target, dir) { + template_path <- system.file("templates", template, package = "beekeeper") + rendered <- whisker::whisker.render( + readLines(template_path, warn = FALSE), + data + ) + out_dir <- file.path(tmp, dir) + fs::dir_create(out_dir) + out_path <- file.path(out_dir, target) + writeLines(strsplit(rendered, "\n", fixed = TRUE)[[1]], out_path) + out_path + } +} diff --git a/tests/testthat/test-aaa-conditions.R b/tests/testthat/test-aaa-conditions.R new file mode 100644 index 0000000..3772d0a --- /dev/null +++ b/tests/testthat/test-aaa-conditions.R @@ -0,0 +1,8 @@ +test_that(".pkg_abort works", { + stbl::expect_pkg_error_snapshot( + .pkg_abort("This is a test error", c("subclass", "test_error")), + "beekeeper", + "subclass", + "test_error" + ) +}) diff --git a/tests/testthat/test-as_bk_data.R b/tests/testthat/test-as_bk_data.R new file mode 100644 index 0000000..ad41182 --- /dev/null +++ b/tests/testthat/test-as_bk_data.R @@ -0,0 +1,10 @@ +test_that("as_bk_data warns for unknown classes", { + expect_warning( + { + test_result <- as_bk_data("a") + }, + "No method for as_bk_data" + ) + expect_type(test_result, "list") + expect_length(test_result, 0) +}) diff --git a/tests/testthat/test-generate_pkg-agent.R b/tests/testthat/test-generate_pkg-agent.R deleted file mode 100644 index 443c5d6..0000000 --- a/tests/testthat/test-generate_pkg-agent.R +++ /dev/null @@ -1,6 +0,0 @@ -test_that("generate_pkg_agent() generates package agents", { - expect_identical( - generate_pkg_agent(test_path("_fixtures", "DESCRIPTION")), - "beekeeper (https://beekeeper.api2r.org)" - ) -}) diff --git a/tests/testthat/test-generate_pkg-call.R b/tests/testthat/test-generate_pkg-call.R deleted file mode 100644 index 2295186..0000000 --- a/tests/testthat/test-generate_pkg-call.R +++ /dev/null @@ -1,31 +0,0 @@ -test_that("generate_pkg() generates call function.", { - skip_on_cran() - config <- readLines(test_path("_fixtures", "guru_beekeeper.yml")) - guru_rapid <- readRDS(test_path("_fixtures", "guru_rapid.rds")) - call_expected <- readLines(test_path("_fixtures", "guru-010-call.R")) - - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(guru_rapid, "guru_rapid.rds") - - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") - - call_result <- scrub_testpkg(readLines("R/010-call.R")) - expect_identical(call_result, call_expected) -}) - -test_that("generate_pkg() generates call function test.", { - skip_on_cran() - config <- readLines(test_path("_fixtures", "guru_beekeeper.yml")) - guru_rapid <- readRDS(test_path("_fixtures", "guru_rapid.rds")) - t_call_expected <- readLines(test_path("_fixtures", "guru-test-010-call.R")) - - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(guru_rapid, "guru_rapid.rds") - - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") - - t_call_result <- readLines("tests/testthat/test-010-call.R") - expect_identical(t_call_result, t_call_expected) -}) diff --git a/tests/testthat/test-generate_pkg-paths.R b/tests/testthat/test-generate_pkg-paths.R index 312d2fd..2d4782d 100644 --- a/tests/testthat/test-generate_pkg-paths.R +++ b/tests/testthat/test-generate_pkg-paths.R @@ -1,95 +1,242 @@ -test_that("generate_pkg() generates path functions for guru", { +test_that(".generate_paths() returns empty character for empty paths (#65)", { + skip_on_cran() + result <- .generate_paths( + paths = rapid::class_paths(), + api_abbr = "test", + security_data = list(), + base_url = "https://example.com" + ) + expect_identical(result, character()) +}) + +test_that(".generate_paths() calls correct templates for guru (#65)", { # 1 tag, no security skip_on_cran() - config <- readLines(test_path("_fixtures", "guru_beekeeper.yml")) - guru_rapid <- readRDS(test_path("_fixtures", "guru_rapid.rds")) - expected_file_content <- readLines( - test_path("_fixtures", "guru-paths-apis.R") + config <- .read_config(test_path("_fixtures", "guru", "_beekeeper.yml")) + api_definition <- readRDS(test_path( + "_fixtures", + "guru", + "_beekeeper_rapid.rds" + )) + spy <- make_spy_impl() + local_mocked_bindings(.bk_use_template_impl = spy$mock) + + result <- .generate_paths( + paths = api_definition@paths, + api_abbr = config$api_abbr, + security_data = list(), + base_url = api_definition@servers@url ) - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(guru_rapid, "guru_rapid.rds") + calls <- spy$calls() + + expect_identical( + basename(result), + c( + "paths-apis-list_apis.R", + "paths-apis-get_metrics.R", + "paths-apis-get_providers.R", + "paths-apis-get_api.R", + "paths-apis-get_service_api.R", + "paths-apis-get_provider.R", + "paths-apis-get_services.R", + "test-paths-apis.R", + "setup.R" + ) + ) - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") + # 7 paths.R calls + 1 test-paths.R call + 1 setup.R call + expect_length(calls, 9L) + expect_identical( + purrr::map_chr(calls, "template"), + c(rep("paths.R", 7L), "test-paths.R", "setup.R") + ) - generated_file_content <- readLines("R/paths-apis.R") - expect_identical(generated_file_content, expected_file_content) + # Spot-check data for the first (simplest) path call + first <- calls[[1]]$data + expect_identical(first$operation_id, "list_apis") + expect_identical(first$tag, "apis") + expect_identical(first$method, "get") + expect_false(first$has_security) + + # Spot-check a path call that has path parameters + get_api <- calls[[4]]$data + expect_identical(get_api$operation_id, "get_api") + expect_length(get_api$params, 2L) + + # Check test-paths.R data + test_call <- calls[[8]] + expect_identical(test_call$dir, "tests/testthat") + expect_identical(test_call$target, "test-paths-apis.R") + expect_length(test_call$data$paths, 7L) + + # Check setup.R data + setup_call <- calls[[9]] + expect_identical(setup_call$dir, "tests/testthat") + expect_identical(setup_call$data$base_url, api_definition@servers@url) }) -test_that("generate_pkg() generates path tests for guru", { - # 1 tag, no security +test_that(".generate_paths() writes correct templates for guru (#65)", { + # Visual confirmation that paths.R, test-paths.R, and setup.R render correctly skip_on_cran() - config <- readLines(test_path("_fixtures", "guru_beekeeper.yml")) - guru_rapid <- readRDS(test_path("_fixtures", "guru_rapid.rds")) - expected_file_content <- readLines( - test_path("_fixtures", "guru-test-paths-apis.R") + config <- .read_config(test_path("_fixtures", "guru", "_beekeeper.yml")) + api_definition <- readRDS(test_path( + "_fixtures", + "guru", + "_beekeeper_rapid.rds" + )) + expected_path_contents <- load_expected_files("guru", "/paths-.+\\.R$") + expected_test_contents <- load_expected_files("guru", "/test-paths-.+\\.R$") + expected_setup_content <- readLines(test_path("_fixtures", "guru", "setup.R")) + + tmp <- withr::local_tempdir() + local_mocked_bindings(.bk_use_template_impl = make_writing_impl(tmp)) + + .generate_paths( + paths = api_definition@paths, + api_abbr = config$api_abbr, + security_data = list(), + base_url = api_definition@servers@url ) - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(guru_rapid, "guru_rapid.rds") - - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") - - generated_file_content <- readLines("tests/testthat/test-paths-apis.R") - expect_identical(generated_file_content, expected_file_content) + purrr::iwalk(expected_path_contents, \(expected, name) { + expect_identical(readLines(file.path(tmp, "R", name)), expected) + }) + purrr::iwalk(expected_test_contents, \(expected, name) { + expect_identical( + readLines(file.path(tmp, "tests", "testthat", name)), + expected + ) + }) + expect_identical( + readLines(file.path(tmp, "tests", "testthat", "setup.R")), + expected_setup_content + ) }) -test_that("generate_pkg() generates test setup file for guru", { - # 1 tag, no security +test_that(".generate_paths() calls correct templates for fec (#65)", { + # 3 tags (audit, debts, legal), more complicated security skip_on_cran() - config <- readLines(test_path("_fixtures", "guru_beekeeper.yml")) - guru_rapid <- readRDS(test_path("_fixtures", "guru_rapid.rds")) - expected_file_content <- readLines( - test_path("_fixtures", "guru-setup.R") + config <- .read_config(test_path( + "_fixtures", + "fec", + "fec_subset_beekeeper.yml" + )) + api_definition <- readRDS(test_path( + "_fixtures", + "fec", + "fec_subset_rapid.rds" + )) + spy <- make_spy_impl() + local_mocked_bindings(.bk_use_template_impl = spy$mock) + + security_data <- .generate_security( + config$api_abbr, + api_definition@components@security_schemes + ) + result <- .generate_paths( + paths = api_definition@paths, + api_abbr = config$api_abbr, + security_data = security_data, + base_url = api_definition@servers@url ) - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(guru_rapid, "guru_rapid.rds") + calls <- spy$calls() + + expect_identical( + basename(result), + c( + "paths-audit-get_audit_case.R", + "paths-audit-get_audit_category.R", + "paths-audit-get_audit_primary_category.R", + "paths-legal-get_legal_search.R", + "paths-audit-get_names_audit_candidates.R", + "paths-audit-get_names_audit_committees.R", + "paths-debts-get_schedules_schedule_d.R", + "paths-debts-get_schedules_schedule_d_sub_id.R", + "test-paths-audit.R", + "test-paths-legal.R", + "test-paths-debts.R", + "setup.R" + ) + ) - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") + # 1 auth + 8 path R files + 3 test files + 1 setup = 13 calls + expect_length(calls, 13L) - generated_file_content <- readLines("tests/testthat/setup.R") - expect_identical(generated_file_content, expected_file_content) + # Security data should be threaded through to paths (.generate_security() + # wrote the auth file as calls[[1]], so paths start at calls[[2]]) + first_path <- calls[[2]]$data + expect_true(first_path$has_security) + expect_identical(first_path$api_abbr, "fec") }) -test_that("generate_pkg() generates path functions for fec", { - # 19 tags, more complicated security +test_that(".generate_paths() writes correct paths.R for fec (#65)", { + # Visual confirmation: 3 tags, complicated security skip_on_cran() - config <- readLines(test_path("_fixtures", "fec_subset_beekeeper.yml")) - fec_rapid <- readRDS(test_path("_fixtures", "fec_subset_rapid.rds")) + config <- .read_config(test_path( + "_fixtures", + "fec", + "fec_subset_beekeeper.yml" + )) + api_definition <- readRDS(test_path( + "_fixtures", + "fec", + "fec_subset_rapid.rds" + )) expected_file_content <- readLines( - test_path("_fixtures", "fec-paths-audit.R") + test_path("_fixtures", "fec", "paths-audit-get_names_audit_candidates.R") ) - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(fec_rapid, "fec_subset_rapid.rds") + tmp <- withr::local_tempdir() + local_mocked_bindings(.bk_use_template_impl = make_writing_impl(tmp)) - changed_files <- generate_pkg(pkg_agent = "TESTPKG (https://example.com)") - expect_snapshot(scrub_path(changed_files)) + security_data <- .generate_security( + config$api_abbr, + api_definition@components@security_schemes + ) + .generate_paths( + paths = api_definition@paths, + api_abbr = config$api_abbr, + security_data = security_data, + base_url = api_definition@servers@url + ) - generated_file_content <- readLines("R/paths-audit.R") - expect_identical(generated_file_content, expected_file_content) + expect_identical( + readLines(file.path(tmp, "R", "paths-audit-get_names_audit_candidates.R")), + expected_file_content + ) }) -test_that("generate_pkg() generates path functions for trello", { - # some tags failed before this, more complicated security +test_that(".generate_paths() writes correct paths.R for trello (#65)", { + # Visual confirmation: more complicated security skip_on_cran() - config <- readLines(test_path("_fixtures", "trello_beekeeper.yml")) - trello_rapid <- readRDS(test_path("_fixtures", "trello_rapid.rds")) + config <- .read_config(test_path("_fixtures", "trello", "_beekeeper.yml")) + api_definition <- readRDS(test_path( + "_fixtures", + "trello", + "_beekeeper_rapid.rds" + )) expected_file_content <- readLines( - test_path("_fixtures", "trello-paths-board.R") + test_path("_fixtures", "trello", "paths-board-add_boards.R") ) - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(trello_rapid, "trello_rapid.rds") + tmp <- withr::local_tempdir() + local_mocked_bindings(.bk_use_template_impl = make_writing_impl(tmp)) - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") + security_data <- .generate_security( + config$api_abbr, + api_definition@components@security_schemes + ) + .generate_paths( + paths = api_definition@paths, + api_abbr = config$api_abbr, + security_data = security_data, + base_url = api_definition@servers@url + ) - generated_file_content <- readLines("R/paths-board.R") - expect_identical(generated_file_content, expected_file_content) + expect_identical( + readLines(file.path(tmp, "R", "paths-board-add_boards.R")), + expected_file_content + ) }) diff --git a/tests/testthat/test-generate_pkg-prepare.R b/tests/testthat/test-generate_pkg-prepare.R index 06e3851..123037a 100644 --- a/tests/testthat/test-generate_pkg-prepare.R +++ b/tests/testthat/test-generate_pkg-prepare.R @@ -1,79 +1,35 @@ -test_that(".assert_is_pkg() errors informatively for non-packages", { - expect_snapshot( - .assert_is_pkg(tempdir()), - error = TRUE, - transform = scrub_tempdir - ) -}) - -test_that("generate_pkg() adds nectar to import.", { +test_that(".generate_prepare() generates prepare file.", { skip_on_cran() - config <- readLines(test_path("_fixtures", "guru_beekeeper.yml")) - guru_rapid <- readRDS(test_path("_fixtures", "guru_rapid.rds")) + config <- .read_config(test_path("_fixtures", "guru", "_beekeeper.yml")) + api_definition <- readRDS(test_path( + "_fixtures", + "guru", + "_beekeeper_rapid.rds" + )) + prepare_expected <- readLines(test_path( + "_fixtures", + "guru", + "010-prepare.R" + )) + t_prepare_expected <- readLines(test_path( + "_fixtures", + "guru", + "test-010-prepare.R" + )) create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(guru_rapid, "guru_rapid.rds") - - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") + usethis::use_testthat() + test_result <- .generate_prepare(config, api_definition, list()) - dependencies <- desc::desc()$get_deps() expect_identical( - dependencies$package[dependencies$type == "Imports"], - "nectar" - ) -}) - -test_that("generate_pkg() adds beekeeper to suggests.", { - skip_on_cran() - config <- readLines(test_path("_fixtures", "guru_beekeeper.yml")) - guru_rapid <- readRDS(test_path("_fixtures", "guru_rapid.rds")) - - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(guru_rapid, "guru_rapid.rds") - - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") - - dependencies <- desc::desc()$get_deps() - expect_contains( - dependencies$package[dependencies$type == "Suggests"], - "beekeeper" + basename(test_result), + c("010-prepare.R", "test-010-prepare.R") ) -}) -test_that("generate_pkg() adds httptest2 to suggests.", { - skip_on_cran() - config <- readLines(test_path("_fixtures", "guru_beekeeper.yml")) - guru_rapid <- readRDS(test_path("_fixtures", "guru_rapid.rds")) - - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(guru_rapid, "guru_rapid.rds") - - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") - - dependencies <- desc::desc()$get_deps() - expect_contains( - dependencies$package[dependencies$type == "Suggests"], - "httptest2" - ) -}) - -test_that("generate_pkg() adds testthat to suggests.", { - skip_on_cran() - config <- readLines(test_path("_fixtures", "guru_beekeeper.yml")) - guru_rapid <- readRDS(test_path("_fixtures", "guru_rapid.rds")) - - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(guru_rapid, "guru_rapid.rds") - - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") - - dependencies <- desc::desc()$get_deps() - expect_contains( - dependencies$package[dependencies$type == "Suggests"], - "testthat" - ) + prepare_result <- scrub_testpkg(readLines("R/010-prepare.R")) + expect_identical(prepare_result, prepare_expected) + t_prepare_result <- scrub_testpkg(readLines( + "tests/testthat/test-010-prepare.R" + )) + expect_identical(t_prepare_result, t_prepare_expected) }) diff --git a/tests/testthat/test-generate_pkg-security.R b/tests/testthat/test-generate_pkg-security.R index c3bff50..e8f8566 100644 --- a/tests/testthat/test-generate_pkg-security.R +++ b/tests/testthat/test-generate_pkg-security.R @@ -1,41 +1,56 @@ -test_that("generate_pkg() generates call function with API keys", { - skip_on_cran() - local_mocked_bindings( - .generate_paths = function(...) { - character() - } - ) - config <- readLines(test_path("_fixtures", "trello_beekeeper.yml")) - trello_rapid <- readRDS(test_path("_fixtures", "trello_rapid.rds")) - call_expected <- readLines(test_path("_fixtures", "trello-010-call.R")) - - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(trello_rapid, "trello_rapid.rds") +test_that(".generate_security() returns empty list for no security", { + result <- .generate_security("test", rapid::class_security_schemes()) + expect_identical(result, list()) +}) - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") +test_that("as_bk_data() dispatches correctly for security_scheme_details", { + trello_rapid <- readRDS(test_path( + "_fixtures", + "trello", + "_beekeeper_rapid.rds" + )) + details <- trello_rapid@components@security_schemes@details + result <- as_bk_data(details) + expect_length(result, 2L) + expect_identical(result[[1]]$type, "api_key") + expect_identical(result[[1]]$arg_name, "key") + expect_identical(result[[2]]$arg_name, "token") +}) - call_result <- scrub_testpkg(readLines("R/010-call.R")) - expect_identical(call_result, call_expected) +test_that("as_bk_data() returns empty list for empty api_key_security_scheme", { + expect_identical(as_bk_data(rapid::class_api_key_security_scheme()), list()) }) -test_that("generate_pkg() generates security functions", { +test_that(".generate_security() generates security file for trello", { skip_on_cran() - local_mocked_bindings( - .generate_paths = function(...) { - character() - } + config <- .read_config(test_path("_fixtures", "trello", "_beekeeper.yml")) + api_definition <- readRDS(test_path( + "_fixtures", + "trello", + "_beekeeper_rapid.rds" + )) + security_expected <- readLines(test_path("_fixtures", "trello", "020-auth.R")) + tmp <- withr::local_tempdir() + local_mocked_bindings(.bk_use_template_impl = make_writing_impl(tmp)) + test_result <- .generate_security( + config$api_abbr, + api_definition@components@security_schemes + ) + expect_named( + test_result, + c( + "has_security", + "security_schemes", + "security_arg_names", + "security_arg_list", + "security_arg_helps", + "security_arg_nulls", + "security_file_path", + "security_signature" + ) + ) + expect_identical( + readLines(file.path(tmp, "R", "020-auth.R")), + security_expected ) - config <- readLines(test_path("_fixtures", "trello_beekeeper.yml")) - trello_rapid <- readRDS(test_path("_fixtures", "trello_rapid.rds")) - security_expected <- readLines(test_path("_fixtures", "trello-020-auth.R")) - - create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(trello_rapid, "trello_rapid.rds") - - generate_pkg(pkg_agent = "TESTPKG (https://example.com)") - - security_result <- scrub_testpkg(readLines("R/020-auth.R")) - expect_identical(security_result, security_expected) }) diff --git a/tests/testthat/test-generate_pkg-setup.R b/tests/testthat/test-generate_pkg-setup.R new file mode 100644 index 0000000..58eefac --- /dev/null +++ b/tests/testthat/test-generate_pkg-setup.R @@ -0,0 +1,55 @@ +test_that(".assert_is_pkg() errors informatively for non-packages", { + expect_snapshot( + .assert_is_pkg(tempdir()), + error = TRUE, + transform = scrub_tempdir + ) +}) + +test_that(".assert_is_pkg() isn't obtrusive for packages", { + create_local_package() + expect_null({ + .assert_is_pkg() + }) +}) + +test_that(".read_config() reads configs", { + config <- .read_config(test_path("_fixtures", "guru", "_beekeeper.yml")) + expect_s3_class(config$updated_on, c("POSIXlt", "POSIXt")) + expect_snapshot({ + config + }) +}) + +test_that(".read_api_definition() reads api_definitions", { + api_definition <- .read_api_definition( + test_path("_fixtures", "guru"), + "_beekeeper_rapid.rds" + ) + expect_s7_class(api_definition, rapid::class_rapid) +}) + +test_that(".setup_r() sets up dependencies", { + skip_on_cran() + + create_local_package() + .setup_r(".") + + dependencies <- desc::desc()$get_deps() + expect_identical( + dependencies$package[dependencies$type == "Imports"], + "nectar" + ) + expect_contains( + dependencies$package[dependencies$type == "Suggests"], + "beekeeper" + ) + expect_contains( + dependencies$package[dependencies$type == "Suggests"], + "httptest2" + ) + expect_contains( + dependencies$package[dependencies$type == "Suggests"], + "testthat" + ) +}) diff --git a/tests/testthat/test-generate_pkg-shared.R b/tests/testthat/test-generate_pkg-shared.R new file mode 100644 index 0000000..d580bb8 --- /dev/null +++ b/tests/testthat/test-generate_pkg-shared.R @@ -0,0 +1,49 @@ +test_that(".generate_shared_params() returns file path for no-security API (#65)", { + skip_on_cran() + tmp <- withr::local_tempdir() + local_mocked_bindings(.bk_use_template_impl = make_writing_impl(tmp)) + + result <- .generate_shared_params(list()) + + expect_identical(result, file.path(tmp, "R", "000-shared.R")) +}) + +test_that(".generate_shared_params() writes correct content for no-security API (#65)", { + skip_on_cran() + shared_expected <- readLines(test_path("_fixtures", "guru", "000-shared.R")) + tmp <- withr::local_tempdir() + local_mocked_bindings(.bk_use_template_impl = make_writing_impl(tmp)) + + .generate_shared_params(list()) + + expect_identical( + readLines(file.path(tmp, "R", "000-shared.R")), + shared_expected + ) +}) + +test_that(".generate_shared_params() writes security params for API with security (#65)", { + skip_on_cran() + tmp <- withr::local_tempdir() + local_mocked_bindings(.bk_use_template_impl = make_writing_impl(tmp)) + trello_rapid <- readRDS(test_path( + "_fixtures", + "trello", + "_beekeeper_rapid.rds" + )) + trello_config <- .read_config(test_path( + "_fixtures", + "trello", + "_beekeeper.yml" + )) + security_data <- .generate_security( + trello_config$api_abbr, + trello_rapid@components@security_schemes + ) + shared_expected <- readLines(test_path("_fixtures", "trello", "000-shared.R")) + .generate_shared_params(security_data) + expect_identical( + readLines(file.path(tmp, "R", "000-shared.R")), + shared_expected + ) +}) diff --git a/tests/testthat/test-generate_pkg.R b/tests/testthat/test-generate_pkg.R new file mode 100644 index 0000000..1ab806c --- /dev/null +++ b/tests/testthat/test-generate_pkg.R @@ -0,0 +1,66 @@ +test_that("generate_pkg() returns a vector of created files", { + skip_on_cran() + config_text <- readLines(test_path("_fixtures", "guru", "_beekeeper.yml")) + api_definition <- readRDS(test_path( + "_fixtures", + "guru", + "_beekeeper_rapid.rds" + )) + + test_dir <- create_local_package() + writeLines(config_text, "_beekeeper.yml") + saveRDS(api_definition, "_beekeeper_rapid.rds") + + test_result <- generate_pkg() + test_result <- scrub_path(test_result) + # 7 guru operations all in "apis" tag: 7 R files + 1 test file + setup + expected_result <- c( + "/R/000-shared.R", + "/R/010-prepare.R", + "/tests/testthat/test-010-prepare.R", + "/R/paths-apis-list_apis.R", + "/R/paths-apis-get_metrics.R", + "/R/paths-apis-get_providers.R", + "/R/paths-apis-get_api.R", + "/R/paths-apis-get_service_api.R", + "/R/paths-apis-get_provider.R", + "/R/paths-apis-get_services.R", + "/tests/testthat/test-paths-apis.R", + "/tests/testthat/setup.R" + ) + + expect_identical(test_result, expected_result) +}) + +test_that("generate_pkg() generates call function with API keys", { + skip_on_cran() + local_mocked_bindings( + .generate_paths = function(...) { + character() + } + ) + config_text <- readLines(test_path( + "_fixtures", + "trello", + "_beekeeper.yml" + )) + api_definition <- readRDS(test_path( + "_fixtures", + "trello", + "_beekeeper_rapid.rds" + )) + prepare_expected <- readLines(test_path( + "_fixtures", + "trello", + "010-prepare.R" + )) + + create_local_package() + writeLines(config_text, "_beekeeper.yml") + saveRDS(api_definition, "_beekeeper_rapid.rds") + + generate_pkg() + + prepare_result <- scrub_testpkg(readLines("R/010-prepare.R")) + expect_identical(prepare_result, prepare_expected) +}) diff --git a/tests/testthat/test-generate_pkg_main.R b/tests/testthat/test-generate_pkg_main.R deleted file mode 100644 index 0c305a6..0000000 --- a/tests/testthat/test-generate_pkg_main.R +++ /dev/null @@ -1,21 +0,0 @@ -test_that("generate_pkg() returns a vector of created files", { - skip_on_cran() - config <- readLines(test_path("_fixtures", "guru_beekeeper.yml")) - guru_rapid <- readRDS(test_path("_fixtures", "guru_rapid.rds")) - - test_dir <- create_local_package() - writeLines(config, "_beekeeper.yml") - saveRDS(guru_rapid, "guru_rapid.rds") - - test_result <- generate_pkg(pkg_agent = "TESTPKG (https://example.com)") - test_result <- scrub_path(test_result) - expected_result <- c( - "/R/010-call.R", - "/tests/testthat/test-010-call.R", - "/R/paths-apis.R", - "/tests/testthat/test-paths-apis.R", - "/tests/testthat/setup.R" - ) - - expect_identical(test_result, expected_result) -}) diff --git a/tests/testthat/test-use_beekeeper.R b/tests/testthat/test-use_beekeeper.R index 49e8061..5718e95 100644 --- a/tests/testthat/test-use_beekeeper.R +++ b/tests/testthat/test-use_beekeeper.R @@ -4,7 +4,7 @@ test_that("config writes a yml", { invisible(TRUE) } ) - rapid_path <- test_path("_fixtures/guru_rapid.rds") + rapid_path <- test_path("_fixtures/guru/_beekeeper_rapid.rds") guru_rapid <- readRDS(rapid_path) config_path <- withr::local_tempfile(fileext = ".yml") rapid_write_path <- withr::local_tempfile(fileext = ".rds") @@ -19,7 +19,7 @@ test_that("config writes a yml", { expect_identical(guru_rapid, reread_rapid) test_result_file <- scrub_config(readLines(config_path)) expected_result_file <- scrub_config( - readLines(test_path("_fixtures", "guru_beekeeper.yml")) + readLines(test_path("_fixtures", "guru", "_beekeeper.yml")) ) expect_identical( test_result_file, diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index 385fbe9..df3f86d 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -1,10 +1,53 @@ -test_that("%|0|% works", { - expect_identical( - character() %|0|% "foo", - "foo" - ) - expect_identical( - "foo" %|0|% "bar", - "foo" - ) +test_that("%|0|% works (#noissue)", { + expect_identical(character() %|0|% "foo", "foo") + expect_identical("foo" %|0|% "bar", "foo") +}) + +test_that("%|\"|% works (#noissue)", { + expect_identical("" %|"|% "foo", "foo") + expect_identical("foo" %|"|% "bar", "foo") +}) + +test_that(".coalesce() works (#noissue)", { + expect_identical(.coalesce(c("a", NA), c("x", "y")), c("a", "y")) +}) + +test_that(".collapse_comma() works (#noissue)", { + expect_identical(.collapse_comma(c("a", "b", "c")), "a, b, c") +}) + +test_that(".collapse_comma_newline() works (#noissue)", { + expect_identical(.collapse_comma_newline(c("a", "b")), "a,\nb") +}) + +test_that(".collapse_quote_comma() works (#noissue)", { + expect_identical(.collapse_quote_comma(c("a", "b")), '"a", "b"') +}) + +test_that(".paste0_if() works (#noissue)", { + expect_identical(.paste0_if("x", TRUE, "!"), "x!") + expect_identical(.paste0_if("x", FALSE, "!"), "x") +}) + +test_that(".glue_pipe_brace() works (#noissue)", { + x <- "world" + expect_identical(.glue_pipe_brace("hello |{x}|"), "hello world") +}) + +test_that(".to_snake() works (#noissue)", { + expect_identical(.to_snake("camelCase"), "camel_case") +}) + +test_that(".flatten_df() returns data frame unchanged (#noissue)", { + df <- data.frame(x = 1:2) + expect_identical(.flatten_df(df), df) +}) + +test_that(".flatten_df() row-binds a list of data frames (#noissue)", { + result <- .flatten_df(list(data.frame(x = 1), data.frame(x = 2))) + expect_equal(result, data.frame(x = c(1, 2))) +}) + +test_that(".flatten_df() returns empty data frame for NULL (#noissue)", { + expect_equal(.flatten_df(NULL), data.frame()) })