diff --git a/.Rbuildignore b/.Rbuildignore index 0d2e883..ab61171 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -10,3 +10,7 @@ ^pkgdown$ ^principles\.md$ ^exploration$ +^data-raw$ +^\.positai$ +^\.claude$ +^AGENTS\.md$ 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..43af38b --- /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-04-28 13:47:49 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: "rapid") { id } }' + +# Available issue type IDs +gh api graphql -f query='{ repository(owner: "jonthegeek", name: "rapid") { 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_kgDOKA67Zw" \ + -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..826aa4c --- /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()), + "rapid", + "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(rapid.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 74d8c97..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 }}) @@ -20,29 +21,22 @@ jobs: config: - {os: macos-latest, r: 'release'} - {os: windows-latest, r: 'release'} - - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} - - {os: ubuntu-latest, r: '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@v4 - - - 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..611b08f --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,51 @@ +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 + local::. + + - name: Install air + uses: posit-dev/setup-air@v1 diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 57aba39..2e4ee7c 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,6 +14,8 @@ 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 }} @@ -21,27 +23,50 @@ jobs: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} permissions: contents: write + pull-requests: write steps: - - uses: actions/checkout@v4 + - 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::. - 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}/dev" + ) + 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 eea58c5..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@v4 + - 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@v4 - - - 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/rhub.yaml b/.github/workflows/rhub.yaml new file mode 100644 index 0000000..74ec7b0 --- /dev/null +++ b/.github/workflows/rhub.yaml @@ -0,0 +1,95 @@ +# R-hub's generic GitHub Actions workflow file. It's canonical location is at +# https://github.com/r-hub/actions/blob/v1/workflows/rhub.yaml +# You can update this file to a newer version using the rhub2 package: +# +# rhub::rhub_setup() +# +# It is unlikely that you need to modify this file manually. + +name: R-hub +run-name: "${{ github.event.inputs.id }}: ${{ github.event.inputs.name || format('Manually run by {0}', github.triggering_actor) }}" + +on: + workflow_dispatch: + inputs: + config: + description: 'A comma separated list of R-hub platforms to use.' + type: string + default: 'linux,windows,macos' + name: + description: 'Run name. You can leave this empty now.' + type: string + id: + description: 'Unique ID. You can leave this empty now.' + type: string + +jobs: + + setup: + runs-on: ubuntu-latest + outputs: + containers: ${{ steps.rhub-setup.outputs.containers }} + platforms: ${{ steps.rhub-setup.outputs.platforms }} + + steps: + # NO NEED TO CHECKOUT HERE + - uses: r-hub/actions/setup@v1 + with: + config: ${{ github.event.inputs.config }} + id: rhub-setup + + linux-containers: + needs: setup + if: ${{ needs.setup.outputs.containers != '[]' }} + runs-on: ubuntu-latest + name: ${{ matrix.config.label }} + strategy: + fail-fast: false + matrix: + config: ${{ fromJson(needs.setup.outputs.containers) }} + container: + image: ${{ matrix.config.container }} + + steps: + - uses: r-hub/actions/checkout@v1 + - uses: r-hub/actions/platform-info@v1 + with: + token: ${{ secrets.RHUB_TOKEN }} + job-config: ${{ matrix.config.job-config }} + - uses: r-hub/actions/setup-deps@v1 + with: + token: ${{ secrets.RHUB_TOKEN }} + job-config: ${{ matrix.config.job-config }} + - uses: r-hub/actions/run-check@v1 + with: + token: ${{ secrets.RHUB_TOKEN }} + job-config: ${{ matrix.config.job-config }} + + other-platforms: + needs: setup + if: ${{ needs.setup.outputs.platforms != '[]' }} + runs-on: ${{ matrix.config.os }} + name: ${{ matrix.config.label }} + strategy: + fail-fast: false + matrix: + config: ${{ fromJson(needs.setup.outputs.platforms) }} + + steps: + - uses: r-hub/actions/checkout@v1 + - uses: r-hub/actions/setup-r@v1 + with: + job-config: ${{ matrix.config.job-config }} + token: ${{ secrets.RHUB_TOKEN }} + - uses: r-hub/actions/platform-info@v1 + with: + token: ${{ secrets.RHUB_TOKEN }} + job-config: ${{ matrix.config.job-config }} + - uses: r-hub/actions/setup-deps@v1 + with: + job-config: ${{ matrix.config.job-config }} + token: ${{ secrets.RHUB_TOKEN }} + - uses: r-hub/actions/run-check@v1 + with: + job-config: ${{ matrix.config.job-config }} + token: ${{ secrets.RHUB_TOKEN }} diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index d3f65be..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@v4 - - - 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 9d4ee20..b1282cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .quarto docs exploration +.positai diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bdf463c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# AGENTS.md + +## Repository overview + +**rapid** — R 'API' Descriptions + +Convert an 'API' description ('APID'), such as one that + follows the 'OpenAPI Specification', to an R 'API' description object + (a "rapid"). The rapid object follows the 'OpenAPI Specification' to + make it easy to convert to and from 'API' documents. + +https://rapid.api2r.org/, https://github.com/jonthegeek/rapid + +### Overall structure + +The project follows standard R package conventions with these key directories: + +rapid/ +├── R/ # R source code +│ ├── rapid-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 59bd7fd..ba6fbcd 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -14,9 +14,9 @@ Description: Convert an 'API' description ('APID'), such as one that License: MIT + file LICENSE URL: https://rapid.api2r.org/, https://github.com/jonthegeek/rapid BugReports: https://github.com/jonthegeek/rapid/issues -Depends: +Depends: R (>= 3.5.0) -Imports: +Imports: cli, glue, jsonlite, @@ -24,22 +24,26 @@ Imports: rlang (>= 1.1.0), S7 (>= 0.2.0), snakecase, - stbl, + stbl (>= 0.3.0), tibble, tibblify (>= 0.3.1.9000), xml2, yaml -Suggests: +Suggests: + httr2, testthat (>= 3.0.0) Remotes: - tibblify=mgirlich/tibblify#193 + stbl=wranglezone/stbl, + tibblify=wranglezone/tibblify Config/testthat/edition: 3 Config/testthat/parallel: true Encoding: UTF-8 Language: en-US Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.2 -Collate: +RoxygenNote: 7.3.3 +Collate: + 'aaa-conditions.R' + 'aaa-shared_params.R' 'properties.R' 'security.R' 'paths.R' @@ -67,6 +71,7 @@ Collate: 'components-security_scheme-oauth2-token_flow.R' 'components-security_scheme-oauth2.R' 'rapid-package.R' + 'swagger_to_openapi.R' 'urls.R' 'utils.R' 'validate_in.R' diff --git a/NAMESPACE b/NAMESPACE index 6b72a85..91e3197 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -64,9 +64,5 @@ importFrom(rlang,"%||%") importFrom(rlang,caller_arg) importFrom(rlang,caller_env) importFrom(rlang,check_dots_empty) -importFrom(stbl,stabilize_chr) -importFrom(stbl,stabilize_chr_scalar) -importFrom(stbl,stabilize_fct) -importFrom(stbl,stabilize_lgl_scalar) importFrom(xml2,url_absolute) importFrom(yaml,read_yaml) diff --git a/NEWS.md b/NEWS.md new file mode 100644 index 0000000..f407637 --- /dev/null +++ b/NEWS.md @@ -0,0 +1,3 @@ +# rapid (development version) + +* Initial version. diff --git a/R/aaa-conditions.R b/R/aaa-conditions.R new file mode 100644 index 0000000..1def80a --- /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( + "rapid", + 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/absolute_paths.R b/R/absolute_paths.R index bf790a3..8d0a7d7 100644 --- a/R/absolute_paths.R +++ b/R/absolute_paths.R @@ -37,9 +37,11 @@ S7::method(expand_servers, class_rapid) <- function(x) { return(x) } -S7::method(expand_servers, class_any) <- function(x, - arg = caller_arg(x), - call = caller_env()) { +S7::method(expand_servers, class_any) <- function( + x, + arg = caller_arg(x), + call = caller_env() +) { cli::cli_abort( "{.arg {arg}} {.cls {class(x)}} must be a {.cls rapid}.", call = call diff --git a/R/as.R b/R/as.R index d2b9e39..c0be07b 100644 --- a/R/as.R +++ b/R/as.R @@ -19,12 +19,14 @@ as_api_object <- S7::new_generic( "as_api_object", "x", - function(x, - target_class, - ..., - alternate_names = NULL, - arg = caller_arg(x), - call = caller_env()) { + function( + x, + target_class, + ..., + alternate_names = NULL, + arg = caller_arg(x), + call = caller_env() + ) { if (missing(x)) { return(target_class()) } @@ -37,15 +39,14 @@ as_api_object <- S7::new_generic( } ) -S7::method( - as_api_object, - class_list | class_character -) <- function(x, - target_class, - ..., - alternate_names = NULL, - arg = caller_arg(x), - call = caller_env()) { +S7::method(as_api_object, class_list | class_character) <- function( + x, + target_class, + ..., + alternate_names = NULL, + arg = caller_arg(x), + call = caller_env() +) { force(arg) x <- .validate_for_as_class( x, @@ -59,24 +60,25 @@ S7::method( }) } -S7::method( - as_api_object, - NULL | S7::new_S3_class("S7_missing") -) <- function(x, - target_class, - ..., - alternate_names = NULL, - arg = caller_arg(x), - call = caller_env()) { +S7::method(as_api_object, NULL | S7::new_S3_class("S7_missing")) <- function( + x, + target_class, + ..., + alternate_names = NULL, + arg = caller_arg(x), + call = caller_env() +) { target_class() } -S7::method(as_api_object, class_any) <- function(x, - target_class, - ..., - alternate_names = NULL, - arg = caller_arg(x), - call = caller_env()) { +S7::method(as_api_object, class_any) <- function( + x, + target_class, + ..., + alternate_names = NULL, + arg = caller_arg(x), + call = caller_env() +) { target_class_nm <- class(target_class())[[1]] cli::cli_abort( "Can't coerce {.arg {arg}} {.cls {class(x)}} to {.cls {target_class_nm}}.", @@ -85,11 +87,13 @@ S7::method(as_api_object, class_any) <- function(x, ) } -.validate_for_as_class <- function(x, - target_class, - alternate_names = NULL, - x_arg = caller_arg(x), - call = caller_env()) { +.validate_for_as_class <- function( + x, + target_class, + alternate_names = NULL, + x_arg = caller_arg(x), + call = caller_env() +) { if (!length(x)) { return(NULL) } diff --git a/R/components-security_scheme-oauth2-scopes.R b/R/components-security_scheme-oauth2-scopes.R index 7d84cda..611bfca 100644 --- a/R/components-security_scheme-oauth2-scopes.R +++ b/R/components-security_scheme-oauth2-scopes.R @@ -39,8 +39,7 @@ class_scopes <- S7::new_class( name = class_character, description = class_character ), - constructor = function(name = character(), - description = character()) { + constructor = function(name = character(), description = character()) { S7::new_object( S7::S7_object(), name = name %|0|% character(), @@ -81,7 +80,7 @@ S7::method( ) <- function(x, ..., arg = caller_arg(x), call = caller_env()) { force(arg) x <- unlist(x) - x <- stabilize_chr(x, x_arg = arg) + x <- stbl::stabilize_chr(x, x_arg = arg) if (!rlang::is_named2(x)) { cli::cli_abort( "{.arg {arg}} must be a named character vector.", @@ -94,9 +93,11 @@ S7::method( ) } -S7::method(as_scopes, class_any) <- function(x, - ..., - arg = caller_arg(x), - call = caller_env()) { +S7::method(as_scopes, class_any) <- function( + x, + ..., + arg = caller_arg(x), + call = caller_env() +) { as_api_object(x, class_scopes, ..., arg = arg, call = call) } diff --git a/R/properties.R b/R/properties.R index 01e6804..ce48ca6 100644 --- a/R/properties.R +++ b/R/properties.R @@ -15,9 +15,10 @@ character_scalar_property <- function(x_arg, ...) { setter = function(self, value) { call <- caller_env(3) value <- value %||% character() - value <- stabilize_chr_scalar( + value <- stbl::stabilize_chr_scalar( value, allow_null = FALSE, + allow_zero_length = TRUE, x_arg = x_arg, call = call, ... @@ -35,9 +36,10 @@ logical_scalar_property <- function(x_arg, ...) { setter = function(self, value) { call <- caller_env(3) value <- value %||% logical() - value <- stabilize_lgl_scalar( + value <- stbl::stabilize_lgl_scalar( value, allow_null = FALSE, + allow_zero_length = TRUE, allow_na = FALSE, x_arg = x_arg, call = call, @@ -58,7 +60,7 @@ factor_property <- function(arg, levels, max_size = NULL) { setter = function(self, value) { call <- caller_env(3) value <- value %||% character() - value <- stabilize_fct( + value <- stbl::stabilize_fct( value, allow_null = FALSE, allow_na = FALSE, @@ -99,7 +101,7 @@ enum_property <- function(x_arg) { value <- purrr::map( value, function(enumerations) { - enumerations <- stabilize_chr( + enumerations <- stbl::stabilize_chr( enumerations, allow_na = FALSE, x_arg = x_arg, @@ -128,7 +130,7 @@ list_of_characters <- function(x_arg, ...) { value, function(x) { x <- x %|0|% character() - stabilize_chr( + stbl::stabilize_chr( x, allow_na = FALSE, x_arg = x_arg, diff --git a/R/rapid-package.R b/R/rapid-package.R index c4bd03f..64b898f 100644 --- a/R/rapid-package.R +++ b/R/rapid-package.R @@ -13,10 +13,6 @@ #' @importFrom S7 prop<- #' @importFrom S7 S7_inherits #' @importFrom S7 validate -#' @importFrom stbl stabilize_chr -#' @importFrom stbl stabilize_chr_scalar -#' @importFrom stbl stabilize_fct -#' @importFrom stbl stabilize_lgl_scalar #' @importFrom xml2 url_absolute #' @importFrom yaml read_yaml ## usethis namespace: end diff --git a/R/swagger_to_openapi.R b/R/swagger_to_openapi.R new file mode 100644 index 0000000..938503e --- /dev/null +++ b/R/swagger_to_openapi.R @@ -0,0 +1,62 @@ +swagger_to_openapi <- function(x) { + # This isn't right yet, I'm just logging some notes. + if (is.list(x)) { + # If it's already openapi 3+, return it. + if ( + length(x$openapi) && + numeric_version(as.character(x$openapi)) >= "3" + ) { + return(x) + } + # Try to find the spec url so we don't need httr2. + if (length(x$info$`x-origin`$url)) { + x <- x$info$`x-origin`$url + } else { + rlang::check_installed("httr2", "to convert older api spec to OpenAPI") + resp <- httr2::request("https://converter.swagger.io/api/convert") |> + httr2::req_body_json(data = x) |> + # httr2 will choose post automatically, but make it explicit since this + # version requires post. + httr2::req_method("post") |> + httr2::req_perform() |> + httr2::resp_body_json() + return(resp) + } + } + # If it's a URL, we can GET-convert it directly without requiring httr2. + yaml::read_yaml( + paste0( + "https://converter.swagger.io/api/convert?url=", + x + ), + readLines.warn = FALSE + ) +} + +.is_swagger_spec <- S7::new_generic(".is_swagger_spec", "x") + +S7::method(.is_swagger_spec, class_any) <- function(x, + ..., + min_version = character(), + arg = caller_arg(x), + call = caller_env()) { + return(FALSE) +} + +S7::method(.is_swagger_spec, class_list) <- function(x, + min_version = character(), + arg = caller_arg(x), + call = caller_env()) { + version <- x$openapi %||% x$swagger %||% x$swaggerVersion + + # TODO: Split this up. Don't be fancy. This will error if numeric_version + # fails; instead we should return FALSE if we can't parse the version. + return( + length(version) == 1 && + ( + !length(min_version) || + numeric_version(as.character(version)) >= + numeric_version(as.character(min_version)) + ) + ) +} diff --git a/R/validate_lengths.R b/R/validate_lengths.R index 9992d9b..0ca1aac 100644 --- a/R/validate_lengths.R +++ b/R/validate_lengths.R @@ -1,9 +1,11 @@ -validate_lengths <- function(obj, - key_name, - required_same = NULL, - required_any = NULL, - optional_same = NULL, - optional_any = NULL) { +validate_lengths <- function( + obj, + key_name, + required_same = NULL, + required_any = NULL, + optional_same = NULL, + optional_any = NULL +) { key_length <- .prop_lengths(obj, key_name) if (!key_length) { @@ -41,6 +43,7 @@ validate_lengths <- function(obj, } return(character()) } + .msg_must_have_same <- function(key_name, key_length, not_same, bad_lengths) { c( cli::format_inline( @@ -51,7 +54,8 @@ validate_lengths <- function(obj, ) } -.check_non_empty <- function(obj, key_name, prop_names) { # nocov start +.check_non_empty <- function(obj, key_name, prop_names) { + # nocov start prop_lengths <- .prop_lengths(obj, prop_names) empty <- prop_names[prop_lengths == 0] if (any(empty)) { @@ -78,10 +82,12 @@ validate_lengths <- function(obj, ) } } -.msg_not_same_or_empty <- function(key_name, - key_length, - bad_props, - bad_lengths) { +.msg_not_same_or_empty <- function( + key_name, + key_length, + bad_props, + bad_lengths +) { c( cli::format_inline( "{.arg {bad_props}} must be empty or have the same length as {.arg {key_name}}" diff --git a/R/zz-rapid.R b/R/zz-rapid.R index ea842a5..418f06c 100644 --- a/R/zz-rapid.R +++ b/R/zz-rapid.R @@ -5,6 +5,47 @@ #' @include security.R NULL +# Helper functions ---- + +# These must be defined *before* class_rapid right now. + +.construct_rapid <- function( + info = class_info(), + ..., + servers = class_servers(), + components = class_components(), + paths = class_paths(), + security = class_security() +) { + check_dots_empty() + S7::new_object( + S7::S7_object(), + info = as_info(info), + servers = as_servers(servers), + components = as_components(components), + paths = as_paths(paths), + security = as_security(security) + ) +} + +.validate_rapid <- function(self) { + c( + validate_lengths( + self, + key_name = "info", + optional_any = c("components", "paths", "security", "servers") + ), + validate_in_specific( + values = self@security@name, + enums = self@components@security_schemes@name, + value_name = "security", + enum_name = "the {.arg security_schemes} defined in {.arg components}" + ) + ) +} + +# class_rapid ---- + #' R API description object #' #' An object that represents an API. @@ -62,43 +103,16 @@ class_rapid <- S7::new_class( paths = class_paths, security = class_security ), - constructor = function(info = class_info(), - ..., - servers = class_servers(), - components = class_components(), - paths = class_paths(), - security = class_security()) { - check_dots_empty() - S7::new_object( - S7::S7_object(), - info = as_info(info), - servers = as_servers(servers), - components = as_components(components), - paths = as_paths(paths), - security = as_security(security) - ) - }, - validator = function(self) { - c( - validate_lengths( - self, - key_name = "info", - optional_any = c("components", "paths", "security", "servers") - ), - validate_in_specific( - values = self@security@name, - enums = self@components@security_schemes@name, - value_name = "security", - enum_name = "the {.arg security_schemes} defined in {.arg components}" - ) - ) - } + constructor = .construct_rapid, + validator = .validate_rapid ) S7::method(length, class_rapid) <- function(x) { length(x@info) } +# as_rapid ---- + #' Coerce lists and urls to rapid objects #' #' `as_rapid()` turns an existing object into a `rapid` object. This is in @@ -121,10 +135,12 @@ S7::method(length, class_rapid) <- function(x) { #' as_rapid() as_rapid <- S7::new_generic("as_rapid", "x") -S7::method(as_rapid, S7::new_S3_class("url")) <- function(x, - ..., - arg = caller_arg(x), - call = caller_env()) { +S7::method(as_rapid, S7::new_S3_class("url")) <- function( + x, + ..., + arg = caller_arg(x), + call = caller_env() +) { url_string <- .url_to_string(x) x <- .url_fetch(url_string) if (!length(x$info$`x-origin`)) { @@ -133,10 +149,12 @@ S7::method(as_rapid, S7::new_S3_class("url")) <- function(x, as_rapid(x, ..., arg = arg, call = call) } -S7::method(as_rapid, class_character) <- function(x, - ..., - arg = caller_arg(x), - call = caller_env()) { +S7::method(as_rapid, class_character) <- function( + x, + ..., + arg = caller_arg(x), + call = caller_env() +) { if (.is_url_string(x)) { return(as_rapid(url(x), ..., arg = arg, call = call)) } @@ -144,10 +162,12 @@ S7::method(as_rapid, class_character) <- function(x, as_rapid(x, ..., arg = arg, call = call) } -S7::method(as_rapid, class_list) <- function(x, - ..., - arg = caller_arg(x), - call = caller_env()) { +S7::method(as_rapid, class_list) <- function( + x, + ..., + arg = caller_arg(x), + call = caller_env() +) { x$paths <- .parse_paths(x$paths, x$openapi, x, call) rlang::try_fetch( { @@ -164,10 +184,12 @@ S7::method(as_rapid, class_list) <- function(x, ) } -S7::method(as_rapid, class_any) <- function(x, - ..., - arg = caller_arg(x), - call = caller_env()) { +S7::method(as_rapid, class_any) <- function( + x, + ..., + arg = caller_arg(x), + call = caller_env() +) { rlang::try_fetch( { x <- as_api_object(x, class_rapid, ..., arg = arg, call = call) diff --git a/data-raw/apis-guru.R b/data-raw/apis-guru.R new file mode 100644 index 0000000..364a0d3 --- /dev/null +++ b/data-raw/apis-guru.R @@ -0,0 +1,165 @@ +# Make sure we can parse all apis.guru APIs. + +stop("apisguru is broken and can't install, so none of this works") + +library(apisguru) +library(dplyr) +library(tidyr) +library(purrr) +library(httr2) +library(yaml) + +apisguru_apis <- apisguru::list_apis() |> + dplyr::select("name", "preferred", "versions") |> + tidyr::unnest("versions") |> + dplyr::filter( + .data$preferred == .data$version, + # Right now we only check specs that are OpenAPI 3.0+. + as.numeric_version(openapiVer) >= as.numeric_version("3.0.0") + ) |> + dplyr::select("name", "updated", "swaggerUrl") + +check_url_exists <- purrr::possibly( + function(url) { + url <- utils::URLencode(url) + httr2::request(url) |> + httr2::req_method("HEAD") |> + httr2::req_perform() |> + httr2::resp_status() |> + { + \(status) status < 400 + }() + }, + otherwise = FALSE +) + +as_int_or_int64 <- function(x) { + tryCatch( + { + as.integer(x) + }, + warning = function(w) { + if (grepl("integer range", conditionMessage(w))) { + bit64::as.integer64(x) + } else { + warning(w) # Re-throw other warnings + } + } + ) +} + +safe_read_yaml <- purrr::possibly( + function(url) { + url <- utils::URLencode(url) + yaml::read_yaml( + url, + readLines.warn = FALSE, + handlers = list( + int = as_int_or_int64 + ) + ) + }, + otherwise = list("bad spec") +) + +read_spec_if_valid <- function(url, url_is_valid) { + if (url_is_valid) { + return(safe_read_yaml(url)) + } + return(NULL) +} + +swagger_check_old <- readRDS(here::here("data-raw", "swagger_check.rds")) |> + dplyr::filter(.data$url_is_valid, lengths(.data$spec) > 0) + +to_recheck <- apisguru_apis |> + dplyr::anti_join(swagger_check_old, by = c("name", "updated")) + +swagger_check <- swagger_check_old + +if (nrow(to_recheck)) { + swagger_check <- to_recheck |> + dplyr::mutate( + url_is_valid = purrr::map_lgl( + .data$swaggerUrl, + check_url_exists, + .progress = TRUE + ), + spec = purrr::map2( + .data$swaggerUrl, + .data$url_is_valid, + read_spec_if_valid, + .progress = TRUE + ) + ) + + swagger_check <- dplyr::bind_rows( + swagger_check_old, + swagger_check + ) |> + dplyr::arrange(.data$name, dplyr::desc(.data$updated)) |> + dplyr::distinct(.data$name, .keep_all = TRUE) + + saveRDS(swagger_check, here::here("data-raw", "swagger_check.rds")) +} + +# Two of them have control characters and yaml can't parse them. + +safe_tibblify <- purrr::possibly( + tibblify::parse_openapi_spec, + otherwise = NULL +) + +check_can_tibblify <- function(spec) { + !is.null(safe_tibblify(spec)) +} + +tibblify_check_old <- readRDS(here::here("data-raw", "tibblify_check.rds")) + +swagger_check_subset <- swagger_check |> + dplyr::filter(lengths(spec) > 0) |> + # Skip Amazon apis for now to try to get past the error(s). + dplyr::filter(!stringr::str_starts(name, "amazon")) |> + # And others. + dplyr::filter(name != "api.video") + +n <- 10L + +to_tibblify <- swagger_check_subset |> + dplyr::anti_join(tibblify_check_old, by = c("name", "updated")) |> + head(n) + +while (nrow(to_tibblify)) { + cli::cli_inform("Another {n}!") + tibblify_check <- to_tibblify |> + dplyr::mutate( + can_tibblify = purrr::map_lgl( + .data$spec, + check_can_tibblify, + .progress = TRUE + ) + ) |> + dplyr::select("name", "updated", "spec", "can_tibblify") + tibblify_check <- dplyr::bind_rows( + tibblify_check_old, + tibblify_check + ) |> + dplyr::arrange(.data$name, dplyr::desc(.data$updated)) |> + dplyr::distinct(.data$name, .keep_all = TRUE) + + saveRDS(tibblify_check, here::here("data-raw", "tibblify_check.rds")) + + tibblify_check_old <- tibblify_check + to_tibblify <- swagger_check_subset |> + dplyr::anti_join(tibblify_check_old, by = c("name", "updated")) |> + head(n) +} + +tibblify_check |> + dplyr::filter(!can_tibblify) + +# TODO: +# * Run api.video manually and try to get rid of the "nested too deeply" errors in tibblify. +# * Intelligently try to tibblify ones that don't have tibblify data. +# * Retry things that have changed since save. +# * Try to rapid if tibblify succeeds. diff --git a/data-raw/slack.R b/data-raw/slack.R new file mode 100644 index 0000000..939e7ea --- /dev/null +++ b/data-raw/slack.R @@ -0,0 +1,15 @@ +swagger_check |> + dplyr::filter(name == "slack.com") |> + dplyr::pull(swaggerUrl) + +slack_spec <- swagger_check |> + dplyr::filter(name == "slack.com") |> + dplyr::pull(spec) +slack_spec <- slack_spec[[1]] + +slack_spec$components$securitySchemes$slackAuth |> names() +slack_spec$paths$`/admin.apps.approve`$post$security + +slack_paths <- tibblify::parse_openapi_spec(slack_spec) +slack_paths |> + tidyr::unnest(operations) diff --git a/data-raw/swagger_check.rds b/data-raw/swagger_check.rds new file mode 100644 index 0000000..59f6639 Binary files /dev/null and b/data-raw/swagger_check.rds differ diff --git a/data-raw/swagger_check_v2.rds b/data-raw/swagger_check_v2.rds new file mode 100644 index 0000000..76d424e Binary files /dev/null and b/data-raw/swagger_check_v2.rds differ diff --git a/data-raw/swagger_checker_v2.R b/data-raw/swagger_checker_v2.R new file mode 100644 index 0000000..a6220fa --- /dev/null +++ b/data-raw/swagger_checker_v2.R @@ -0,0 +1,25 @@ +apisguru_apis_v2 <- apisguru::list_apis() |> + dplyr::select("name", "preferred", "versions") |> + tidyr::unnest("versions") |> + dplyr::filter( + .data$preferred == .data$version, + openapiVer == "2.0" + ) |> + dplyr::select("name", "updated", "swaggerUrl") + +swagger_check_v2 <- apisguru_apis_v2 |> + dplyr::mutate( + url_is_valid = purrr::map_lgl( + .data$swaggerUrl, + check_url_exists, + .progress = TRUE + ), + spec = purrr::map2( + .data$swaggerUrl, + .data$url_is_valid, + read_spec_if_valid, + .progress = TRUE + ) + ) + +saveRDS(swagger_check_v2, here::here("data-raw", "swagger_check_v2.rds")) diff --git a/data-raw/tibblify_check.rds b/data-raw/tibblify_check.rds new file mode 100644 index 0000000..f3b20cb Binary files /dev/null and b/data-raw/tibblify_check.rds differ 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/rapid.Rproj b/rapid.Rproj index 69fafd4..54ac6bf 100644 --- a/rapid.Rproj +++ b/rapid.Rproj @@ -1,4 +1,5 @@ Version: 1.0 +ProjectId: 46b8e9a2-1dc7-4c4c-a451-2d3b5886d97d RestoreWorkspace: No SaveWorkspace: No diff --git a/tests/testthat/_snaps/info-contact.md b/tests/testthat/_snaps/info-contact.md index 78dba64..e04f478 100644 --- a/tests/testthat/_snaps/info-contact.md +++ b/tests/testthat/_snaps/info-contact.md @@ -38,9 +38,8 @@ class_contact(name = "A", url = "https://example.com", email = "not a real email") Condition Error: - ! `email` must match the provided regex pattern. - x Some values do not match. - * Locations: 1 + ! `email` must match the regex pattern "^[^@]+@[^@]+$" + x "not a real email" fails the check. # class_contact() returns a contact when everything is ok diff --git a/tests/testthat/test-components-reference.R b/tests/testthat/test-components-reference.R index 0ed90aa..fdbc888 100644 --- a/tests/testthat/test-components-reference.R +++ b/tests/testthat/test-components-reference.R @@ -1,7 +1,8 @@ test_that("class_reference() errors informatively for non-scalar ref_uri", { - expect_error( + stbl::expect_pkg_error_classes( class_reference(ref_uri = c("a", "b")), - class = "stbl_error_non_scalar" + "stbl", + "non_scalar" ) }) diff --git a/tests/testthat/test-components-schema.R b/tests/testthat/test-components-schema.R index b261c9a..62141ff 100644 --- a/tests/testthat/test-components-schema.R +++ b/tests/testthat/test-components-schema.R @@ -1,24 +1,28 @@ test_that("class_schema() errors informatively for bad type", { - expect_error( + stbl::expect_pkg_error_classes( class_schema(type = "bad"), - class = "stbl_error_fct_levels" + "stbl", + "fct_levels" ) test_result <- class_schema(type = "string") - expect_error( + stbl::expect_pkg_error_classes( test_result@type <- "bad", - class = "stbl_error_fct_levels" + "stbl", + "fct_levels" ) }) test_that("class_schema() errors informatively for non-scalar type", { - expect_error( + stbl::expect_pkg_error_classes( class_schema(type = c("string", "integer")), - class = "stbl_error_size_too_large" + "stbl", + "size_too_large" ) test_result <- class_schema(type = "string") - expect_error( + stbl::expect_pkg_error_classes( test_result@type <- c("string", "integer"), - class = "stbl_error_size_too_large" + "stbl", + "size_too_large" ) }) @@ -68,7 +72,9 @@ test_that("as_schema() returns expected objects", { list(type = "string", nullable = TRUE, description = "A nullable string.") ), class_schema( - type = "string", nullable = TRUE, description = "A nullable string." + type = "string", + nullable = TRUE, + description = "A nullable string." ) ) diff --git a/tests/testthat/test-components-security_scheme-oauth2-scopes.R b/tests/testthat/test-components-security_scheme-oauth2-scopes.R index 525bcc3..0e9eb1b 100644 --- a/tests/testthat/test-components-security_scheme-oauth2-scopes.R +++ b/tests/testthat/test-components-security_scheme-oauth2-scopes.R @@ -66,9 +66,11 @@ test_that("as_scopes() errors informatively for bad classes", { }) test_that("as_scopes() errors informatively for weird lists", { - expect_error( + stbl::expect_pkg_error_classes( as_scopes(list(a = mean)), - class = "stbl_error_coerce_character" + "stbl", + "coerce", + "character" ) })