Compare commits

...
12 Commits
21 changed files with 8085 additions and 47 deletions

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build:
name: Build (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
use_cross: false
archive_ext: tar.gz
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
use_cross: false
archive_ext: tar.gz
- os: macos-latest
target: x86_64-apple-darwin
use_cross: false
archive_ext: tar.gz
- os: macos-latest
target: aarch64-apple-darwin
use_cross: false
archive_ext: tar.gz
- os: windows-latest
target: x86_64-pc-windows-msvc
use_cross: false
archive_ext: zip
- os: windows-latest
target: aarch64-pc-windows-msvc
use_cross: false
archive_ext: zip
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: Build binary
shell: bash
run: |
if [ "${{ matrix.use_cross }}" = "true" ]; then
cross build --release --target ${{ matrix.target }}
else
cargo build --release --target ${{ matrix.target }}
fi
- name: Package archive (Unix)
if: matrix.archive_ext == 'tar.gz'
shell: bash
run: |
ARCHIVE_NAME="onx-${{ github.ref_name }}-${{ matrix.target }}"
STAGE_DIR="staging/${ARCHIVE_NAME}"
mkdir -p "${STAGE_DIR}"
cp "target/${{ matrix.target }}/release/onx" "${STAGE_DIR}/"
cp README.md LICENSE.md "${STAGE_DIR}/"
tar -czf "${ARCHIVE_NAME}.tar.gz" -C staging "${ARCHIVE_NAME}"
sha256sum "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256"
mkdir dist
mv "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}.tar.gz.sha256" dist/
- name: Package archive (Windows)
if: matrix.archive_ext == 'zip'
shell: pwsh
run: |
$ArchiveName = "onx-${{ github.ref_name }}-${{ matrix.target }}"
$StageDir = "staging/$ArchiveName"
New-Item -ItemType Directory -Force -Path $StageDir | Out-Null
Copy-Item "target/${{ matrix.target }}/release/onx.exe" -Destination $StageDir
Copy-Item "README.md", "LICENSE.md" -Destination $StageDir
Compress-Archive -Path $StageDir -DestinationPath "$ArchiveName.zip"
$Hash = (Get-FileHash -Path "$ArchiveName.zip" -Algorithm SHA256).Hash.ToLower()
"$Hash $ArchiveName.zip" | Out-File -Encoding ascii "$ArchiveName.zip.sha256"
New-Item -ItemType Directory -Force -Path dist | Out-Null
Move-Item "$ArchiveName.zip", "$ArchiveName.zip.sha256" -Destination dist/
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: assets-${{ matrix.target }}
path: dist/*
publish-release:
name: Publish Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: all-assets
pattern: assets-*
merge-multiple: true
- name: Generate SHA256SUMS.txt
working-directory: all-assets
run: |
cat *.sha256 > SHA256SUMS.txt
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: Release ${{ github.ref_name }}
generate_release_notes: true
files: |
all-assets/*.tar.gz
all-assets/*.zip
all-assets/*.sha256
all-assets/SHA256SUMS.txt
Generated
+1
View File
@@ -797,6 +797,7 @@ dependencies = [
name = "onx"
version = "0.0.0"
dependencies = [
"bytes",
"chrono",
"clap",
"mime_guess",
+1
View File
@@ -7,6 +7,7 @@ license = "MIT"
repository = "https://github.com/StevanFreeborn/onx"
[dependencies]
bytes = "1.12.1"
chrono = { version = "0.4.45", features = ["serde"] }
clap = { version = "4.6.6", features = ["derive", "env"] }
mime_guess = "2.0.5"
+52
View File
@@ -1,3 +1,55 @@
# Onx
An agent-friendly CLI (built in Rust btw) for the Onspring API.
## Installation
### Linux & macOS
Install via shell script:
```bash
curl -fsSL https://raw.githubusercontent.com/StevanFreeborn/onx/main/scripts/install.sh | bash
```
To install a specific version or customize the install directory:
```bash
curl -fsSL https://raw.githubusercontent.com/StevanFreeborn/onx/main/scripts/install.sh | bash -s -- --version v0.1.0 --to ~/.local/bin
```
### Windows
Install via PowerShell:
```powershell
irm https://raw.githubusercontent.com/StevanFreeborn/onx/main/scripts/install.ps1 | iex
```
To install a specific version or customize the install directory:
```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/StevanFreeborn/onx/main/scripts/install.ps1))) -Version v0.1.0 -InstallDir "$HOME\.onx\bin"
```
## Configuration
`onx` can be configured using command-line arguments, a JSON configuration file, or environment variables.
### Precedence
1. **Command-line flags** (`--api-key`, `--base-url`, `--pretty`)
2. **Configuration file** (`~/.config/onx/config.json` or `--config <PATH>`)
3. **Environment variables** (`ONSPRING_API_KEY`, `ONSPRING_BASE_URL`)
### Configuration File Format
Create `~/.config/onx/config.json`:
```json
{
"apiKey": "your-api-key",
"baseUrl": "https://api.onspring.com",
"pretty": true
}
```
+118
View File
@@ -0,0 +1,118 @@
[CmdletBinding()]
param(
[Alias("v")]
[string]$Version,
[Alias("t")]
[string]$InstallDir = "$HOME\.onx\bin"
)
$ErrorActionPreference = "Stop"
$Repo = "StevanFreeborn/onx"
$GithubUrl = "https://github.com/$Repo"
$BinaryName = "onx.exe"
$Arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
"aarch64"
} else {
"x86_64"
}
$Target = "$Arch-pc-windows-msvc"
if (-not $Version) {
Write-Host "Finding latest release of $Repo..."
$ApiUrl = "https://api.github.com/repos/$Repo/releases/latest"
try {
$Release = Invoke-RestMethod -Uri $ApiUrl -Headers @{ "User-Agent" = "onx-installer" }
$Version = $Release.tag_name
} catch {
Write-Warning "Could not query GitHub API for latest release, falling back to web request..."
$Response = Invoke-WebRequest -Uri "$GithubUrl/releases/latest" -MaximumRedirection 0 -ErrorAction SilentlyContinue
$Location = $Response.Headers["Location"]
if ($Location) {
$Version = ($Location -split '/')[-1]
}
}
}
if ($Version -and -not $Version.StartsWith("v")) {
$Version = "v$Version"
}
if (-not $Version) {
Write-Error "Could not determine version to install."
exit 1
}
$ArchiveName = "onx-$Version-$Target.zip"
$DownloadUrl = "$GithubUrl/releases/download/$Version/$ArchiveName"
$ChecksumUrl = "$DownloadUrl.sha256"
$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Force -Path $TempDir | Out-Null
try {
$ArchiveFile = Join-Path $TempDir $ArchiveName
$ChecksumFile = Join-Path $TempDir "$ArchiveName.sha256"
Write-Host "Downloading $BinaryName $Version for $Target..."
Invoke-WebRequest -Uri $DownloadUrl -OutFile $ArchiveFile
Invoke-WebRequest -Uri $ChecksumUrl -OutFile $ChecksumFile
Write-Host "Verifying SHA256 checksum..."
$ExpectedHash = ((Get-Content $ChecksumFile) -split '\s+')[0].Trim().ToLower()
$ActualHash = (Get-FileHash -Path $ArchiveFile -Algorithm SHA256).Hash.ToLower()
if ($ExpectedHash -ne $ActualHash) {
Write-Error "Checksum mismatch!`n Expected: $ExpectedHash`n Actual: $ActualHash"
exit 1
}
Write-Host "Extracting $ArchiveName..."
$ExtractDir = Join-Path $TempDir "extracted"
Expand-Archive -Path $ArchiveFile -DestinationPath $ExtractDir -Force
$FoundBin = Get-ChildItem -Path $ExtractDir -Recurse -Filter $BinaryName | Select-Object -First 1
if (-not $FoundBin) {
Write-Error "Binary '$BinaryName' not found inside archive."
exit 1
}
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
$TargetFile = Join-Path $InstallDir $BinaryName
Copy-Item -Path $FoundBin.FullName -Destination $TargetFile -Force
Write-Host "$BinaryName $Version installed successfully to $TargetFile!" -ForegroundColor Green
$UserPath = [Environment]::GetEnvironmentVariable("Path", [EnvironmentVariableTarget]::User)
$PathParts = ($UserPath -split ';') | Where-Object { $_ -ne "" }
if ($PathParts -notcontains $InstallDir) {
Write-Host "Adding $InstallDir to user PATH..."
$NewUserPath = "$UserPath;$InstallDir".Trim(';')
[Environment]::SetEnvironmentVariable("Path", $NewUserPath, [EnvironmentVariableTarget]::User)
$env:Path = "$env:Path;$InstallDir"
Write-Host "Added $InstallDir to user PATH. You may need to restart your terminal for the change to take effect." -ForegroundColor Cyan
} else {
Write-Host "$InstallDir is already in your PATH."
}
} finally {
if (Test-Path $TempDir) {
Remove-Item -Recurse -Force -Path $TempDir
}
}
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env bash
set -euo pipefail
REPO="StevanFreeborn/onx"
GITHUB_URL="https://github.com/${REPO}"
BINARY_NAME="onx"
REQUESTED_VERSION=""
INSTALL_DIR="${HOME}/.local/bin"
while [[ $# -gt 0 ]]; do
case "$1" in
-v|--version)
REQUESTED_VERSION="$2"
shift 2
;;
-t|--to)
INSTALL_DIR="$2"
shift 2
;;
-h|--help)
cat <<EOF
onx installer script
Usage: install.sh [OPTIONS]
Options:
-v, --version <VERSION> Install a specific version (e.g., v0.1.0)
-t, --to <DIR> Install directory (default: ~/.local/bin)
-h, --help Show this help message
EOF
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
OS_TYPE="$(uname -s)"
case "${OS_TYPE}" in
Linux)
OS="unknown-linux-gnu"
;;
Darwin)
OS="apple-darwin"
;;
*)
echo "Error: Unsupported operating system: ${OS_TYPE}" >&2
exit 1
;;
esac
ARCH_TYPE="$(uname -m)"
case "${ARCH_TYPE}" in
x86_64|amd64)
ARCH="x86_64"
;;
arm64|aarch64)
ARCH="aarch64"
;;
*)
echo "Error: Unsupported architecture: ${ARCH_TYPE}" >&2
exit 1
;;
esac
TARGET="${ARCH}-${OS}"
download() {
local url="$1"
local output="$2"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" -o "$output"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$output" "$url"
else
echo "Error: Neither curl nor wget was found. Please install one of them." >&2
exit 1
fi
}
if [ -z "${REQUESTED_VERSION}" ]; then
echo "Finding latest release of ${REPO}..."
LATEST_URL="https://api.github.com/repos/${REPO}/releases/latest"
if command -v curl >/dev/null 2>&1; then
RELEASE_JSON="$(curl -fsSL "${LATEST_URL}" || true)"
elif command -v wget >/dev/null 2>&1; then
RELEASE_JSON="$(wget -qO- "${LATEST_URL}" || true)"
fi
if [ -n "${RELEASE_JSON}" ]; then
VERSION="$(echo "${RELEASE_JSON}" | grep -m 1 '"tag_name":' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')"
fi
if [ -z "${VERSION:-}" ]; then
echo "Could not query GitHub API for latest release, falling back to git redirect..."
VERSION="$(curl -fsSL -o /dev/null -w "%{url_effective}" "${GITHUB_URL}/releases/latest" | rev | cut -d/ -f1 | rev)"
fi
else
VERSION="${REQUESTED_VERSION}"
if [[ "${VERSION}" != v* ]]; then
VERSION="v${VERSION}"
fi
fi
if [ -z "${VERSION}" ]; then
echo "Error: Could not determine version to install." >&2
exit 1
fi
ARCHIVE_NAME="onx-${VERSION}-${TARGET}.tar.gz"
DOWNLOAD_URL="${GITHUB_URL}/releases/download/${VERSION}/${ARCHIVE_NAME}"
CHECKSUM_URL="${DOWNLOAD_URL}.sha256"
TMP_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t 'onx-install')"
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
echo "Downloading ${BINARY_NAME} ${VERSION} for ${TARGET}..."
download "${DOWNLOAD_URL}" "${TMP_DIR}/${ARCHIVE_NAME}"
download "${CHECKSUM_URL}" "${TMP_DIR}/${ARCHIVE_NAME}.sha256"
echo "Verifying SHA256 checksum..."
EXPECTED_SHA="$(awk '{print $1}' "${TMP_DIR}/${ARCHIVE_NAME}.sha256")"
if command -v sha256sum >/dev/null 2>&1; then
ACTUAL_SHA="$(sha256sum "${TMP_DIR}/${ARCHIVE_NAME}" | awk '{print $1}')"
elif command -v shasum >/dev/null 2>&1; then
ACTUAL_SHA="$(shasum -a 256 "${TMP_DIR}/${ARCHIVE_NAME}" | awk '{print $1}')"
else
echo "Warning: sha256sum/shasum not found, skipping checksum verification."
ACTUAL_SHA="${EXPECTED_SHA}"
fi
if [ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]; then
echo "Error: Checksum mismatch!" >&2
echo " Expected: ${EXPECTED_SHA}" >&2
echo " Actual: ${ACTUAL_SHA}" >&2
exit 1
fi
echo "Extracting ${ARCHIVE_NAME}..."
mkdir -p "${TMP_DIR}/extracted"
tar -xzf "${TMP_DIR}/${ARCHIVE_NAME}" -C "${TMP_DIR}/extracted"
EXTRACTED_BIN="$(find "${TMP_DIR}/extracted" -type f -name "${BINARY_NAME}" | head -n 1)"
if [ -z "${EXTRACTED_BIN}" ]; then
echo "Error: Binary '${BINARY_NAME}' not found inside archive." >&2
exit 1
fi
mkdir -p "${INSTALL_DIR}"
chmod +x "${EXTRACTED_BIN}"
mv "${EXTRACTED_BIN}" "${INSTALL_DIR}/${BINARY_NAME}"
echo "${BINARY_NAME} ${VERSION} installed successfully to ${INSTALL_DIR}/${BINARY_NAME}!"
case ":${PATH}:" in
*:"${INSTALL_DIR}":*)
;;
*)
echo ""
echo "Notice: ${INSTALL_DIR} is not in your PATH."
PROFILE_FILE=""
if [ -n "${ZSH_VERSION:-}" ] || [ -f "${HOME}/.zshrc" ]; then
PROFILE_FILE="${HOME}/.zshrc"
elif [ -n "${BASH_VERSION:-}" ] || [ -f "${HOME}/.bashrc" ]; then
PROFILE_FILE="${HOME}/.bashrc"
elif [ -f "${HOME}/.profile" ]; then
PROFILE_FILE="${HOME}/.profile"
fi
if [ -n "${PROFILE_FILE}" ]; then
PATH_EXPORT="export PATH=\"${INSTALL_DIR}:\$PATH\""
if ! grep -qs "${INSTALL_DIR}" "${PROFILE_FILE}"; then
echo "${PATH_EXPORT}" >> "${PROFILE_FILE}"
echo "Added ${INSTALL_DIR} to PATH in ${PROFILE_FILE}."
echo "Please restart your shell or run: source ${PROFILE_FILE}"
fi
else
echo "To add it to your PATH, add the following line to your shell configuration:"
echo " export PATH=\"${INSTALL_DIR}:\$PATH\""
fi
;;
esac
+1346 -4
View File
File diff suppressed because it is too large. Load diff
+823
View File
@@ -0,0 +1,823 @@
use onspring::{
App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse,
CreatedWithIdResponse, DataFormat, Field, FileInfo, FileResponse, OnspringClient, OnspringError,
PagedResponse, PagingRequest, QueryRecordsRequest, Record, ReportData, ReportDataType,
ReportInfo, SaveFileRequest, SaveListItemRequest, SaveListItemResponse, SaveRecordRequest,
SaveRecordResponse,
};
use uuid::Uuid;
pub trait OnspringRunner {
fn ping(&self) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
fn list_apps(
&self,
paging: Option<PagingRequest>,
) -> impl std::future::Future<Output = Result<PagedResponse<App>, OnspringError>> + Send;
fn get_app(
&self,
app_id: i32,
) -> impl std::future::Future<Output = Result<App, OnspringError>> + Send;
fn batch_get_apps(
&self,
ids: &[i32],
) -> impl std::future::Future<Output = Result<CollectionResponse<App>, OnspringError>> + Send;
fn list_fields(
&self,
app_id: i32,
paging: Option<PagingRequest>,
) -> impl std::future::Future<Output = Result<PagedResponse<Field>, OnspringError>> + Send;
fn get_field(
&self,
field_id: i32,
) -> impl std::future::Future<Output = Result<Field, OnspringError>> + Send;
fn batch_get_fields(
&self,
ids: &[i32],
) -> impl std::future::Future<Output = Result<CollectionResponse<Field>, OnspringError>> + Send;
fn list_records(
&self,
app_id: i32,
paging: Option<PagingRequest>,
field_ids: Option<&[i32]>,
data_format: Option<DataFormat>,
) -> impl std::future::Future<Output = Result<PagedResponse<Record>, OnspringError>> + Send;
fn get_record(
&self,
app_id: i32,
record_id: i32,
field_ids: Option<&[i32]>,
data_format: Option<DataFormat>,
) -> impl std::future::Future<Output = Result<Record, OnspringError>> + Send;
fn save_record(
&self,
request: SaveRecordRequest,
) -> impl std::future::Future<Output = Result<SaveRecordResponse, OnspringError>> + Send;
fn delete_record(
&self,
app_id: i32,
record_id: i32,
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
fn batch_get_records(
&self,
request: BatchGetRecordsRequest,
) -> impl std::future::Future<Output = Result<CollectionResponse<Record>, OnspringError>> + Send;
fn query_records(
&self,
request: QueryRecordsRequest,
paging: Option<PagingRequest>,
) -> impl std::future::Future<Output = Result<PagedResponse<Record>, OnspringError>> + Send;
fn batch_delete_records(
&self,
request: BatchDeleteRecordsRequest,
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
fn get_file_info(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> impl std::future::Future<Output = Result<FileInfo, OnspringError>> + Send;
fn get_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> impl std::future::Future<Output = Result<FileResponse, OnspringError>> + Send;
fn upload_file(
&self,
request: SaveFileRequest,
) -> impl std::future::Future<Output = Result<CreatedWithIdResponse, OnspringError>> + Send;
fn delete_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
fn save_list_item(
&self,
list_id: i32,
request: SaveListItemRequest,
) -> impl std::future::Future<Output = Result<SaveListItemResponse, OnspringError>> + Send;
fn delete_list_item(
&self,
list_id: i32,
item_id: Uuid,
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
fn get_report(
&self,
report_id: i32,
data_format: Option<DataFormat>,
data_type: Option<ReportDataType>,
) -> impl std::future::Future<Output = Result<ReportData, OnspringError>> + Send;
fn list_reports(
&self,
app_id: i32,
paging: Option<PagingRequest>,
) -> impl std::future::Future<Output = Result<PagedResponse<ReportInfo>, OnspringError>> + Send;
}
impl OnspringRunner for OnspringClient {
async fn ping(&self) -> Result<(), OnspringError> {
self.ping().await
}
async fn list_apps(
&self,
paging: Option<PagingRequest>,
) -> Result<PagedResponse<App>, OnspringError> {
self.list_apps(paging).await
}
async fn get_app(&self, app_id: i32) -> Result<App, OnspringError> {
self.get_app(app_id).await
}
async fn batch_get_apps(&self, ids: &[i32]) -> Result<CollectionResponse<App>, OnspringError> {
self.batch_get_apps(ids).await
}
async fn list_fields(
&self,
app_id: i32,
paging: Option<PagingRequest>,
) -> Result<PagedResponse<Field>, OnspringError> {
self.list_fields(app_id, paging).await
}
async fn get_field(&self, field_id: i32) -> Result<Field, OnspringError> {
self.get_field(field_id).await
}
async fn batch_get_fields(&self, ids: &[i32]) -> Result<CollectionResponse<Field>, OnspringError> {
self.batch_get_fields(ids).await
}
async fn list_records(
&self,
app_id: i32,
paging: Option<PagingRequest>,
field_ids: Option<&[i32]>,
data_format: Option<DataFormat>,
) -> Result<PagedResponse<Record>, OnspringError> {
self.list_records(app_id, paging, field_ids, data_format).await
}
async fn get_record(
&self,
app_id: i32,
record_id: i32,
field_ids: Option<&[i32]>,
data_format: Option<DataFormat>,
) -> Result<Record, OnspringError> {
self.get_record(app_id, record_id, field_ids, data_format).await
}
async fn save_record(
&self,
request: SaveRecordRequest,
) -> Result<SaveRecordResponse, OnspringError> {
self.save_record(request).await
}
async fn delete_record(&self, app_id: i32, record_id: i32) -> Result<(), OnspringError> {
self.delete_record(app_id, record_id).await
}
async fn batch_get_records(
&self,
request: BatchGetRecordsRequest,
) -> Result<CollectionResponse<Record>, OnspringError> {
self.batch_get_records(request).await
}
async fn query_records(
&self,
request: QueryRecordsRequest,
paging: Option<PagingRequest>,
) -> Result<PagedResponse<Record>, OnspringError> {
self.query_records(request, paging).await
}
async fn batch_delete_records(
&self,
request: BatchDeleteRecordsRequest,
) -> Result<(), OnspringError> {
self.batch_delete_records(request).await
}
async fn get_file_info(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<FileInfo, OnspringError> {
self.get_file_info(record_id, field_id, file_id).await
}
async fn get_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<FileResponse, OnspringError> {
self.get_file(record_id, field_id, file_id).await
}
async fn upload_file(
&self,
request: SaveFileRequest,
) -> Result<CreatedWithIdResponse, OnspringError> {
self.upload_file(request).await
}
async fn delete_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<(), OnspringError> {
self.delete_file(record_id, field_id, file_id).await
}
async fn save_list_item(
&self,
list_id: i32,
request: SaveListItemRequest,
) -> Result<SaveListItemResponse, OnspringError> {
self.save_list_item(list_id, request).await
}
async fn delete_list_item(&self, list_id: i32, item_id: Uuid) -> Result<(), OnspringError> {
self.delete_list_item(list_id, item_id).await
}
async fn get_report(
&self,
report_id: i32,
data_format: Option<DataFormat>,
data_type: Option<ReportDataType>,
) -> Result<ReportData, OnspringError> {
self.get_report(report_id, data_format, data_type).await
}
async fn list_reports(
&self,
app_id: i32,
paging: Option<PagingRequest>,
) -> Result<PagedResponse<ReportInfo>, OnspringError> {
self.list_reports(app_id, paging).await
}
}
#[cfg(test)]
pub mod testing {
use super::*;
use std::sync::Mutex;
pub struct MockClient {
pub ping_result: Result<(), OnspringError>,
pub list_apps_result: Result<PagedResponse<App>, OnspringError>,
pub get_app_result: Result<App, OnspringError>,
pub batch_get_apps_result: Result<CollectionResponse<App>, OnspringError>,
pub list_apps_paging: Mutex<Option<Option<PagingRequest>>>,
pub get_app_id: Mutex<Option<i32>>,
pub batch_get_apps_ids: Mutex<Option<Vec<i32>>>,
pub list_fields_result: Result<PagedResponse<Field>, OnspringError>,
pub get_field_result: Result<Field, OnspringError>,
pub batch_get_fields_result: Result<CollectionResponse<Field>, OnspringError>,
pub list_fields_app_id: Mutex<Option<i32>>,
pub list_fields_paging: Mutex<Option<Option<PagingRequest>>>,
pub get_field_id: Mutex<Option<i32>>,
pub batch_get_fields_ids: Mutex<Option<Vec<i32>>>,
pub list_records_result: Result<PagedResponse<Record>, OnspringError>,
pub get_record_result: Result<Record, OnspringError>,
pub save_record_result: Result<SaveRecordResponse, OnspringError>,
pub delete_record_result: Result<(), OnspringError>,
pub batch_get_records_result: Result<CollectionResponse<Record>, OnspringError>,
pub query_records_result: Result<PagedResponse<Record>, OnspringError>,
pub batch_delete_records_result: Result<(), OnspringError>,
pub list_records_app_id: Mutex<Option<i32>>,
pub list_records_paging: Mutex<Option<Option<PagingRequest>>>,
pub list_records_field_ids: Mutex<Option<Option<Vec<i32>>>>,
pub list_records_data_format: Mutex<Option<Option<DataFormat>>>,
pub get_record_app_id: Mutex<Option<i32>>,
pub get_record_record_id: Mutex<Option<i32>>,
pub get_record_field_ids: Mutex<Option<Option<Vec<i32>>>>,
pub get_record_data_format: Mutex<Option<Option<DataFormat>>>,
pub save_record_request: Mutex<Option<SaveRecordRequest>>,
pub delete_record_app_id: Mutex<Option<i32>>,
pub delete_record_record_id: Mutex<Option<i32>>,
pub batch_get_records_request: Mutex<Option<BatchGetRecordsRequest>>,
pub query_records_request: Mutex<Option<QueryRecordsRequest>>,
pub query_records_paging: Mutex<Option<Option<PagingRequest>>>,
pub batch_delete_records_request: Mutex<Option<BatchDeleteRecordsRequest>>,
pub get_file_info_result: Result<FileInfo, OnspringError>,
pub get_file_result: Result<FileResponse, OnspringError>,
pub upload_file_result: Result<CreatedWithIdResponse, OnspringError>,
pub delete_file_result: Result<(), OnspringError>,
pub get_file_info_record_id: Mutex<Option<i32>>,
pub get_file_info_field_id: Mutex<Option<i32>>,
pub get_file_info_file_id: Mutex<Option<i32>>,
pub get_file_record_id: Mutex<Option<i32>>,
pub get_file_field_id: Mutex<Option<i32>>,
pub get_file_file_id: Mutex<Option<i32>>,
pub upload_file_request: Mutex<Option<SaveFileRequest>>,
pub delete_file_record_id: Mutex<Option<i32>>,
pub delete_file_field_id: Mutex<Option<i32>>,
pub delete_file_file_id: Mutex<Option<i32>>,
pub save_list_item_result: Result<SaveListItemResponse, OnspringError>,
pub delete_list_item_result: Result<(), OnspringError>,
pub save_list_item_list_id: Mutex<Option<i32>>,
pub save_list_item_request: Mutex<Option<SaveListItemRequest>>,
pub delete_list_item_list_id: Mutex<Option<i32>>,
pub delete_list_item_item_id: Mutex<Option<Uuid>>,
pub get_report_result: Result<ReportData, OnspringError>,
pub list_reports_result: Result<PagedResponse<ReportInfo>, OnspringError>,
pub get_report_report_id: Mutex<Option<i32>>,
pub get_report_data_format: Mutex<Option<Option<DataFormat>>>,
pub get_report_data_type: Mutex<Option<Option<ReportDataType>>>,
pub list_reports_app_id: Mutex<Option<i32>>,
pub list_reports_paging: Mutex<Option<Option<PagingRequest>>>,
}
impl Default for MockClient {
fn default() -> Self {
Self {
ping_result: Ok(()),
list_apps_result: Ok(PagedResponse {
page_number: None,
page_size: None,
total_pages: None,
total_records: None,
items: None,
}),
get_app_result: Ok(App {
href: None,
id: 1,
name: None,
}),
batch_get_apps_result: Ok(CollectionResponse {
count: None,
items: None,
}),
list_apps_paging: Mutex::new(None),
get_app_id: Mutex::new(None),
batch_get_apps_ids: Mutex::new(None),
list_fields_result: Ok(PagedResponse {
page_number: None,
page_size: None,
total_pages: None,
total_records: None,
items: None,
}),
get_field_result: Ok(Field {
id: 1,
app_id: 1,
name: None,
field_type: None,
status: None,
is_required: false,
is_unique: false,
multiplicity: None,
list_id: None,
values: None,
output_type: None,
referenced_app_id: None,
}),
batch_get_fields_result: Ok(CollectionResponse {
count: None,
items: None,
}),
list_fields_app_id: Mutex::new(None),
list_fields_paging: Mutex::new(None),
get_field_id: Mutex::new(None),
batch_get_fields_ids: Mutex::new(None),
list_records_result: Ok(PagedResponse {
page_number: None,
page_size: None,
total_pages: None,
total_records: None,
items: None,
}),
get_record_result: Ok(Record {
app_id: 1,
record_id: 1,
field_data: None,
}),
save_record_result: Ok(SaveRecordResponse {
id: 1,
warnings: None,
}),
delete_record_result: Ok(()),
batch_get_records_result: Ok(CollectionResponse {
count: None,
items: None,
}),
query_records_result: Ok(PagedResponse {
page_number: None,
page_size: None,
total_pages: None,
total_records: None,
items: None,
}),
batch_delete_records_result: Ok(()),
list_records_app_id: Mutex::new(None),
list_records_paging: Mutex::new(None),
list_records_field_ids: Mutex::new(None),
list_records_data_format: Mutex::new(None),
get_record_app_id: Mutex::new(None),
get_record_record_id: Mutex::new(None),
get_record_field_ids: Mutex::new(None),
get_record_data_format: Mutex::new(None),
save_record_request: Mutex::new(None),
delete_record_app_id: Mutex::new(None),
delete_record_record_id: Mutex::new(None),
batch_get_records_request: Mutex::new(None),
query_records_request: Mutex::new(None),
query_records_paging: Mutex::new(None),
batch_delete_records_request: Mutex::new(None),
get_file_info_result: Ok(FileInfo {
file_type: None,
content_type: None,
name: None,
created_date: None,
modified_date: None,
owner: None,
notes: None,
file_href: None,
}),
get_file_result: Ok(FileResponse {
content_type: None,
file_name: None,
data: bytes::Bytes::new(),
}),
upload_file_result: Ok(CreatedWithIdResponse { id: 1 }),
delete_file_result: Ok(()),
get_file_info_record_id: Mutex::new(None),
get_file_info_field_id: Mutex::new(None),
get_file_info_file_id: Mutex::new(None),
get_file_record_id: Mutex::new(None),
get_file_field_id: Mutex::new(None),
get_file_file_id: Mutex::new(None),
upload_file_request: Mutex::new(None),
delete_file_record_id: Mutex::new(None),
delete_file_field_id: Mutex::new(None),
delete_file_file_id: Mutex::new(None),
save_list_item_result: Ok(SaveListItemResponse {
id: Uuid::nil(),
}),
delete_list_item_result: Ok(()),
save_list_item_list_id: Mutex::new(None),
save_list_item_request: Mutex::new(None),
delete_list_item_list_id: Mutex::new(None),
delete_list_item_item_id: Mutex::new(None),
get_report_result: Ok(ReportData {
columns: None,
rows: None,
}),
list_reports_result: Ok(PagedResponse {
page_number: None,
page_size: None,
total_pages: None,
total_records: None,
items: None,
}),
get_report_report_id: Mutex::new(None),
get_report_data_format: Mutex::new(None),
get_report_data_type: Mutex::new(None),
list_reports_app_id: Mutex::new(None),
list_reports_paging: Mutex::new(None),
}
}
}
fn clone_onspring_error(err: &OnspringError) -> OnspringError {
match err {
OnspringError::InvalidArgument(msg) => OnspringError::InvalidArgument(msg.clone()),
OnspringError::Api {
status_code,
message,
} => OnspringError::Api {
status_code: *status_code,
message: message.clone(),
},
OnspringError::Serialization(_) => {
let serde_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
OnspringError::Serialization(serde_err)
}
OnspringError::Http(_) => OnspringError::InvalidArgument("http error".to_string()),
}
}
impl OnspringRunner for MockClient {
async fn ping(&self) -> Result<(), OnspringError> {
match &self.ping_result {
Ok(()) => Ok(()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn list_apps(
&self,
paging: Option<PagingRequest>,
) -> Result<PagedResponse<App>, OnspringError> {
*self.list_apps_paging.lock().unwrap() = Some(paging);
match &self.list_apps_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn get_app(&self, app_id: i32) -> Result<App, OnspringError> {
*self.get_app_id.lock().unwrap() = Some(app_id);
match &self.get_app_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn batch_get_apps(&self, ids: &[i32]) -> Result<CollectionResponse<App>, OnspringError> {
*self.batch_get_apps_ids.lock().unwrap() = Some(ids.to_vec());
match &self.batch_get_apps_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn list_fields(
&self,
app_id: i32,
paging: Option<PagingRequest>,
) -> Result<PagedResponse<Field>, OnspringError> {
*self.list_fields_app_id.lock().unwrap() = Some(app_id);
*self.list_fields_paging.lock().unwrap() = Some(paging);
match &self.list_fields_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn get_field(&self, field_id: i32) -> Result<Field, OnspringError> {
*self.get_field_id.lock().unwrap() = Some(field_id);
match &self.get_field_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn batch_get_fields(&self, ids: &[i32]) -> Result<CollectionResponse<Field>, OnspringError> {
*self.batch_get_fields_ids.lock().unwrap() = Some(ids.to_vec());
match &self.batch_get_fields_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn list_records(
&self,
app_id: i32,
paging: Option<PagingRequest>,
field_ids: Option<&[i32]>,
data_format: Option<DataFormat>,
) -> Result<PagedResponse<Record>, OnspringError> {
*self.list_records_app_id.lock().unwrap() = Some(app_id);
*self.list_records_paging.lock().unwrap() = Some(paging);
*self.list_records_field_ids.lock().unwrap() = Some(field_ids.map(|ids| ids.to_vec()));
*self.list_records_data_format.lock().unwrap() = Some(data_format);
match &self.list_records_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn get_record(
&self,
app_id: i32,
record_id: i32,
field_ids: Option<&[i32]>,
data_format: Option<DataFormat>,
) -> Result<Record, OnspringError> {
*self.get_record_app_id.lock().unwrap() = Some(app_id);
*self.get_record_record_id.lock().unwrap() = Some(record_id);
*self.get_record_field_ids.lock().unwrap() = Some(field_ids.map(|ids| ids.to_vec()));
*self.get_record_data_format.lock().unwrap() = Some(data_format);
match &self.get_record_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn save_record(
&self,
request: SaveRecordRequest,
) -> Result<SaveRecordResponse, OnspringError> {
*self.save_record_request.lock().unwrap() = Some(request);
match &self.save_record_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn delete_record(&self, app_id: i32, record_id: i32) -> Result<(), OnspringError> {
*self.delete_record_app_id.lock().unwrap() = Some(app_id);
*self.delete_record_record_id.lock().unwrap() = Some(record_id);
match &self.delete_record_result {
Ok(()) => Ok(()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn batch_get_records(
&self,
request: BatchGetRecordsRequest,
) -> Result<CollectionResponse<Record>, OnspringError> {
*self.batch_get_records_request.lock().unwrap() = Some(request);
match &self.batch_get_records_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn query_records(
&self,
request: QueryRecordsRequest,
paging: Option<PagingRequest>,
) -> Result<PagedResponse<Record>, OnspringError> {
*self.query_records_request.lock().unwrap() = Some(request);
*self.query_records_paging.lock().unwrap() = Some(paging);
match &self.query_records_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn batch_delete_records(
&self,
request: BatchDeleteRecordsRequest,
) -> Result<(), OnspringError> {
*self.batch_delete_records_request.lock().unwrap() = Some(request);
match &self.batch_delete_records_result {
Ok(()) => Ok(()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn get_file_info(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<FileInfo, OnspringError> {
*self.get_file_info_record_id.lock().unwrap() = Some(record_id);
*self.get_file_info_field_id.lock().unwrap() = Some(field_id);
*self.get_file_info_file_id.lock().unwrap() = Some(file_id);
match &self.get_file_info_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn get_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<FileResponse, OnspringError> {
*self.get_file_record_id.lock().unwrap() = Some(record_id);
*self.get_file_field_id.lock().unwrap() = Some(field_id);
*self.get_file_file_id.lock().unwrap() = Some(file_id);
match &self.get_file_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn upload_file(
&self,
request: SaveFileRequest,
) -> Result<CreatedWithIdResponse, OnspringError> {
*self.upload_file_request.lock().unwrap() = Some(request);
match &self.upload_file_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn delete_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<(), OnspringError> {
*self.delete_file_record_id.lock().unwrap() = Some(record_id);
*self.delete_file_field_id.lock().unwrap() = Some(field_id);
*self.delete_file_file_id.lock().unwrap() = Some(file_id);
match &self.delete_file_result {
Ok(()) => Ok(()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn save_list_item(
&self,
list_id: i32,
request: SaveListItemRequest,
) -> Result<SaveListItemResponse, OnspringError> {
*self.save_list_item_list_id.lock().unwrap() = Some(list_id);
*self.save_list_item_request.lock().unwrap() = Some(request);
match &self.save_list_item_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn delete_list_item(&self, list_id: i32, item_id: Uuid) -> Result<(), OnspringError> {
*self.delete_list_item_list_id.lock().unwrap() = Some(list_id);
*self.delete_list_item_item_id.lock().unwrap() = Some(item_id);
match &self.delete_list_item_result {
Ok(()) => Ok(()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn get_report(
&self,
report_id: i32,
data_format: Option<DataFormat>,
data_type: Option<ReportDataType>,
) -> Result<ReportData, OnspringError> {
*self.get_report_report_id.lock().unwrap() = Some(report_id);
*self.get_report_data_format.lock().unwrap() = Some(data_format);
*self.get_report_data_type.lock().unwrap() = Some(data_type);
match &self.get_report_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn list_reports(
&self,
app_id: i32,
paging: Option<PagingRequest>,
) -> Result<PagedResponse<ReportInfo>, OnspringError> {
*self.list_reports_app_id.lock().unwrap() = Some(app_id);
*self.list_reports_paging.lock().unwrap() = Some(paging);
match &self.list_reports_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
}
}
+461
View File
@@ -0,0 +1,461 @@
use std::io::Write;
use crate::cli::AppsCommand;
use crate::client::OnspringRunner;
use crate::error::CliResult;
use crate::output::{AppOutput, CollectionOutput, PagedOutput, write_json};
use crate::validation::{paging_request, validate_ids, validate_positive_i32};
pub async fn handle<C: OnspringRunner, W: Write>(
command: &AppsCommand,
client: &C,
writer: &mut W,
pretty: bool,
) -> CliResult<()> {
match command {
AppsCommand::List(paging_args) => {
let paging = paging_request(paging_args)?;
let response = client.list_apps(paging).await?;
let output: PagedOutput<AppOutput> = response.into();
write_json(writer, &output, pretty)?;
}
AppsCommand::Get { app_id } => {
validate_positive_i32(*app_id, "app_id")?;
let response = client.get_app(*app_id).await?;
let output: AppOutput = response.into();
write_json(writer, &output, pretty)?;
}
AppsCommand::BatchGet { ids } => {
validate_ids(ids, "ids", Some(100))?;
let response = client.batch_get_apps(ids).await?;
let output: CollectionOutput<AppOutput> = response.into();
write_json(writer, &output, pretty)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use onspring::{App, CollectionResponse, OnspringError, PagedResponse};
use crate::cli::PagingArgs;
use crate::client::testing::MockClient;
use crate::error::CliError;
#[tokio::test]
async fn handle_when_apps_list_succeeds_it_should_write_paged_output() {
let mock_client = MockClient {
list_apps_result: Ok(PagedResponse {
page_number: Some(1),
page_size: Some(50),
total_pages: Some(1),
total_records: Some(1),
items: Some(vec![App {
href: Some("https://api.onspring.com/apps/1".to_string()),
id: 1,
name: Some("Test App".to_string()),
}]),
}),
..Default::default()
};
let command = AppsCommand::List(PagingArgs::default());
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r#"{"pageNumber":1,"pageSize":50,"totalPages":1,"totalRecords":1,"items":[{"href":"https://api.onspring.com/apps/1","id":1,"name":"Test App"}]}"#
);
assert!(
mock_client
.list_apps_paging
.lock()
.unwrap()
.as_ref()
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn handle_when_apps_list_with_paging_args_it_should_pass_paging_request() {
let mock_client = MockClient {
list_apps_result: Ok(PagedResponse {
page_number: Some(2),
page_size: Some(10),
total_pages: Some(5),
total_records: Some(50),
items: Some(vec![]),
}),
..Default::default()
};
let command = AppsCommand::List(PagingArgs {
page_number: Some(2),
page_size: Some(10),
});
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let paging = mock_client
.list_apps_paging
.lock()
.unwrap()
.take()
.unwrap()
.unwrap();
assert_eq!(paging.page_number, 2);
assert_eq!(paging.page_size, 10);
}
#[tokio::test]
async fn handle_when_apps_list_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
list_apps_result: Ok(PagedResponse {
page_number: Some(1),
page_size: Some(50),
total_pages: Some(1),
total_records: Some(1),
items: Some(vec![App {
href: Some("https://api.onspring.com/apps/1".to_string()),
id: 1,
name: Some("Test App".to_string()),
}]),
}),
..Default::default()
};
let command = AppsCommand::List(PagingArgs::default());
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!(
"{\n",
" \"pageNumber\": 1,\n",
" \"pageSize\": 50,\n",
" \"totalPages\": 1,\n",
" \"totalRecords\": 1,\n",
" \"items\": [\n",
" {\n",
" \"href\": \"https://api.onspring.com/apps/1\",\n",
" \"id\": 1,\n",
" \"name\": \"Test App\"\n",
" }\n",
" ]\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_apps_list_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
list_apps_result: Err(OnspringError::Api {
status_code: 401,
message: "Unauthorized".to_string(),
}),
..Default::default()
};
let command = AppsCommand::List(PagingArgs::default());
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime("API request failed (401): Unauthorized"))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_apps_list_has_invalid_paging_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = AppsCommand::List(PagingArgs {
page_number: Some(0),
page_size: None,
});
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("page_number must be greater than 0."))
);
assert!(buffer.is_empty());
assert!(mock_client.list_apps_paging.lock().unwrap().is_none());
}
#[tokio::test]
async fn handle_when_apps_get_succeeds_it_should_write_app_output() {
let mock_client = MockClient {
get_app_result: Ok(App {
href: Some("https://api.onspring.com/apps/123".to_string()),
id: 123,
name: Some("Test App".to_string()),
}),
..Default::default()
};
let command = AppsCommand::Get { app_id: 123 };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r#"{"href":"https://api.onspring.com/apps/123","id":123,"name":"Test App"}"#
);
assert_eq!(*mock_client.get_app_id.lock().unwrap(), Some(123));
}
#[tokio::test]
async fn handle_when_apps_get_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
get_app_result: Ok(App {
href: Some("https://api.onspring.com/apps/123".to_string()),
id: 123,
name: Some("Test App".to_string()),
}),
..Default::default()
};
let command = AppsCommand::Get { app_id: 123 };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!(
"{\n",
" \"href\": \"https://api.onspring.com/apps/123\",\n",
" \"id\": 123,\n",
" \"name\": \"Test App\"\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_apps_get_with_invalid_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = AppsCommand::Get { app_id: 0 };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("app_id must be greater than 0."))
);
assert!(buffer.is_empty());
assert_eq!(*mock_client.get_app_id.lock().unwrap(), None);
}
#[tokio::test]
async fn handle_when_apps_get_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
get_app_result: Err(OnspringError::Api {
status_code: 404,
message: "App not found".to_string(),
}),
..Default::default()
};
let command = AppsCommand::Get { app_id: 123 };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime("API request failed (404): App not found"))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_apps_batch_get_succeeds_it_should_write_collection_output() {
let mock_client = MockClient {
batch_get_apps_result: Ok(CollectionResponse {
count: Some(2),
items: Some(vec![
App {
href: Some("https://api.onspring.com/apps/1".to_string()),
id: 1,
name: Some("App 1".to_string()),
},
App {
href: Some("https://api.onspring.com/apps/2".to_string()),
id: 2,
name: Some("App 2".to_string()),
},
]),
}),
..Default::default()
};
let command = AppsCommand::BatchGet { ids: vec![1, 2] };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r#"{"count":2,"items":[{"href":"https://api.onspring.com/apps/1","id":1,"name":"App 1"},{"href":"https://api.onspring.com/apps/2","id":2,"name":"App 2"}]}"#
);
assert_eq!(
*mock_client.batch_get_apps_ids.lock().unwrap(),
Some(vec![1, 2])
);
}
#[tokio::test]
async fn handle_when_apps_batch_get_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
batch_get_apps_result: Ok(CollectionResponse {
count: Some(1),
items: Some(vec![App {
href: Some("https://api.onspring.com/apps/1".to_string()),
id: 1,
name: Some("App 1".to_string()),
}]),
}),
..Default::default()
};
let command = AppsCommand::BatchGet { ids: vec![1] };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!(
"{\n",
" \"count\": 1,\n",
" \"items\": [\n",
" {\n",
" \"href\": \"https://api.onspring.com/apps/1\",\n",
" \"id\": 1,\n",
" \"name\": \"App 1\"\n",
" }\n",
" ]\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_apps_batch_get_with_empty_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = AppsCommand::BatchGet { ids: vec![] };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Err(CliError::usage("ids cannot be empty.")));
assert!(buffer.is_empty());
assert_eq!(*mock_client.batch_get_apps_ids.lock().unwrap(), None);
}
#[tokio::test]
async fn handle_when_apps_batch_get_with_invalid_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = AppsCommand::BatchGet {
ids: vec![1, 0, 2],
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("ids must only contain values > 0."))
);
assert!(buffer.is_empty());
assert_eq!(*mock_client.batch_get_apps_ids.lock().unwrap(), None);
}
#[tokio::test]
async fn handle_when_apps_batch_get_with_too_many_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = AppsCommand::BatchGet {
ids: vec![1; 101],
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage(
"ids cannot contain more than 100 values."
))
);
assert!(buffer.is_empty());
assert_eq!(*mock_client.batch_get_apps_ids.lock().unwrap(), None);
}
#[tokio::test]
async fn handle_when_apps_batch_get_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
batch_get_apps_result: Err(OnspringError::Api {
status_code: 500,
message: "".to_string(),
}),
..Default::default()
};
let command = AppsCommand::BatchGet { ids: vec![1, 2] };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime("API request failed with status 500."))
);
assert!(buffer.is_empty());
}
}
+539
View File
@@ -0,0 +1,539 @@
use std::io::Write;
use crate::cli::FieldsCommand;
use crate::client::OnspringRunner;
use crate::error::CliResult;
use crate::output::{CollectionOutput, FieldOutput, PagedOutput, write_json};
use crate::validation::{paging_request, validate_ids, validate_positive_i32};
pub async fn handle<C: OnspringRunner, W: Write>(
command: &FieldsCommand,
client: &C,
writer: &mut W,
pretty: bool,
) -> CliResult<()> {
match command {
FieldsCommand::List { app_id, paging } => {
validate_positive_i32(*app_id, "app_id")?;
let paging = paging_request(paging)?;
let response = client.list_fields(*app_id, paging).await?;
let output: PagedOutput<FieldOutput> = response.into();
write_json(writer, &output, pretty)?;
}
FieldsCommand::Get { field_id } => {
validate_positive_i32(*field_id, "field_id")?;
let response = client.get_field(*field_id).await?;
let output: FieldOutput = response.into();
write_json(writer, &output, pretty)?;
}
FieldsCommand::BatchGet { ids } => {
validate_ids(ids, "ids", Some(100))?;
let response = client.batch_get_fields(ids).await?;
let output: CollectionOutput<FieldOutput> = response.into();
write_json(writer, &output, pretty)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use onspring::{
CollectionResponse, Field, FormulaOutputType, ListFieldValue, Multiplicity, OnspringError,
PagedResponse,
};
use uuid::Uuid;
use crate::cli::PagingArgs;
use crate::client::testing::MockClient;
use crate::error::CliError;
fn test_field(id: i32, app_id: i32, name: &str) -> Field {
Field {
id,
app_id,
name: Some(name.to_string()),
field_type: Some("Text".to_string()),
status: Some("Enabled".to_string()),
is_required: false,
is_unique: false,
multiplicity: None,
list_id: None,
values: None,
output_type: None,
referenced_app_id: None,
}
}
#[tokio::test]
async fn handle_when_fields_list_succeeds_it_should_write_paged_output() {
let mock_client = MockClient {
list_fields_result: Ok(PagedResponse {
page_number: Some(1),
page_size: Some(50),
total_pages: Some(1),
total_records: Some(1),
items: Some(vec![test_field(1, 10, "Test Field")]),
}),
..Default::default()
};
let command = FieldsCommand::List {
app_id: 10,
paging: PagingArgs::default(),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r#"{"pageNumber":1,"pageSize":50,"totalPages":1,"totalRecords":1,"items":[{"id":1,"appId":10,"name":"Test Field","type":"Text","status":"Enabled","isRequired":false,"isUnique":false,"multiplicity":null,"listId":null,"values":null,"outputType":null,"referencedAppId":null}]}"#
);
assert_eq!(*mock_client.list_fields_app_id.lock().unwrap(), Some(10));
assert!(
mock_client
.list_fields_paging
.lock()
.unwrap()
.as_ref()
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn handle_when_fields_list_with_paging_args_it_should_pass_paging_request() {
let mock_client = MockClient {
list_fields_result: Ok(PagedResponse {
page_number: Some(2),
page_size: Some(10),
total_pages: Some(5),
total_records: Some(50),
items: Some(vec![]),
}),
..Default::default()
};
let command = FieldsCommand::List {
app_id: 10,
paging: PagingArgs {
page_number: Some(2),
page_size: Some(10),
},
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
assert_eq!(*mock_client.list_fields_app_id.lock().unwrap(), Some(10));
let paging = mock_client
.list_fields_paging
.lock()
.unwrap()
.take()
.unwrap()
.unwrap();
assert_eq!(paging.page_number, 2);
assert_eq!(paging.page_size, 10);
}
#[tokio::test]
async fn handle_when_fields_list_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
list_fields_result: Ok(PagedResponse {
page_number: Some(1),
page_size: Some(50),
total_pages: Some(1),
total_records: Some(1),
items: Some(vec![test_field(1, 10, "Test Field")]),
}),
..Default::default()
};
let command = FieldsCommand::List {
app_id: 10,
paging: PagingArgs::default(),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!(
"{\n",
" \"pageNumber\": 1,\n",
" \"pageSize\": 50,\n",
" \"totalPages\": 1,\n",
" \"totalRecords\": 1,\n",
" \"items\": [\n",
" {\n",
" \"id\": 1,\n",
" \"appId\": 10,\n",
" \"name\": \"Test Field\",\n",
" \"type\": \"Text\",\n",
" \"status\": \"Enabled\",\n",
" \"isRequired\": false,\n",
" \"isUnique\": false,\n",
" \"multiplicity\": null,\n",
" \"listId\": null,\n",
" \"values\": null,\n",
" \"outputType\": null,\n",
" \"referencedAppId\": null\n",
" }\n",
" ]\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_fields_list_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
list_fields_result: Err(OnspringError::Api {
status_code: 401,
message: "Unauthorized".to_string(),
}),
..Default::default()
};
let command = FieldsCommand::List {
app_id: 10,
paging: PagingArgs::default(),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime("API request failed (401): Unauthorized"))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_fields_list_has_invalid_app_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FieldsCommand::List {
app_id: 0,
paging: PagingArgs::default(),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("app_id must be greater than 0."))
);
assert!(buffer.is_empty());
assert!(mock_client.list_fields_app_id.lock().unwrap().is_none());
}
#[tokio::test]
async fn handle_when_fields_list_has_invalid_paging_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FieldsCommand::List {
app_id: 10,
paging: PagingArgs {
page_number: Some(0),
page_size: None,
},
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("page_number must be greater than 0."))
);
assert!(buffer.is_empty());
assert!(mock_client.list_fields_paging.lock().unwrap().is_none());
}
#[tokio::test]
async fn handle_when_fields_get_succeeds_it_should_write_field_output() {
let mock_client = MockClient {
get_field_result: Ok(Field {
id: 123,
app_id: 456,
name: Some("Status Field".to_string()),
field_type: Some("List".to_string()),
status: Some("Enabled".to_string()),
is_required: true,
is_unique: false,
multiplicity: Some(Multiplicity::SingleSelect),
list_id: Some(789),
values: Some(vec![ListFieldValue {
id: Uuid::nil(),
name: "Active".to_string(),
sort_order: 1,
numeric_value: Some(1.0),
color: Some("#00ff00".to_string()),
}]),
output_type: Some(FormulaOutputType::Text),
referenced_app_id: Some(999),
}),
..Default::default()
};
let command = FieldsCommand::Get { field_id: 123 };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r##"{"id":123,"appId":456,"name":"Status Field","type":"List","status":"Enabled","isRequired":true,"isUnique":false,"multiplicity":"SingleSelect","listId":789,"values":[{"id":"00000000-0000-0000-0000-000000000000","name":"Active","sortOrder":1,"numericValue":1.0,"color":"#00ff00"}],"outputType":"Text","referencedAppId":999}"##
);
assert_eq!(*mock_client.get_field_id.lock().unwrap(), Some(123));
}
#[tokio::test]
async fn handle_when_fields_get_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
get_field_result: Ok(test_field(123, 456, "Test Field")),
..Default::default()
};
let command = FieldsCommand::Get { field_id: 123 };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!(
"{\n",
" \"id\": 123,\n",
" \"appId\": 456,\n",
" \"name\": \"Test Field\",\n",
" \"type\": \"Text\",\n",
" \"status\": \"Enabled\",\n",
" \"isRequired\": false,\n",
" \"isUnique\": false,\n",
" \"multiplicity\": null,\n",
" \"listId\": null,\n",
" \"values\": null,\n",
" \"outputType\": null,\n",
" \"referencedAppId\": null\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_fields_get_with_invalid_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FieldsCommand::Get { field_id: 0 };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("field_id must be greater than 0."))
);
assert!(buffer.is_empty());
assert_eq!(*mock_client.get_field_id.lock().unwrap(), None);
}
#[tokio::test]
async fn handle_when_fields_get_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
get_field_result: Err(OnspringError::Api {
status_code: 404,
message: "Field not found".to_string(),
}),
..Default::default()
};
let command = FieldsCommand::Get { field_id: 123 };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime("API request failed (404): Field not found"))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_fields_batch_get_succeeds_it_should_write_collection_output() {
let mock_client = MockClient {
batch_get_fields_result: Ok(CollectionResponse {
count: Some(2),
items: Some(vec![
test_field(1, 10, "Field 1"),
test_field(2, 10, "Field 2"),
]),
}),
..Default::default()
};
let command = FieldsCommand::BatchGet { ids: vec![1, 2] };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r#"{"count":2,"items":[{"id":1,"appId":10,"name":"Field 1","type":"Text","status":"Enabled","isRequired":false,"isUnique":false,"multiplicity":null,"listId":null,"values":null,"outputType":null,"referencedAppId":null},{"id":2,"appId":10,"name":"Field 2","type":"Text","status":"Enabled","isRequired":false,"isUnique":false,"multiplicity":null,"listId":null,"values":null,"outputType":null,"referencedAppId":null}]}"#
);
assert_eq!(
*mock_client.batch_get_fields_ids.lock().unwrap(),
Some(vec![1, 2])
);
}
#[tokio::test]
async fn handle_when_fields_batch_get_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
batch_get_fields_result: Ok(CollectionResponse {
count: Some(1),
items: Some(vec![test_field(1, 10, "Field 1")]),
}),
..Default::default()
};
let command = FieldsCommand::BatchGet { ids: vec![1] };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!(
"{\n",
" \"count\": 1,\n",
" \"items\": [\n",
" {\n",
" \"id\": 1,\n",
" \"appId\": 10,\n",
" \"name\": \"Field 1\",\n",
" \"type\": \"Text\",\n",
" \"status\": \"Enabled\",\n",
" \"isRequired\": false,\n",
" \"isUnique\": false,\n",
" \"multiplicity\": null,\n",
" \"listId\": null,\n",
" \"values\": null,\n",
" \"outputType\": null,\n",
" \"referencedAppId\": null\n",
" }\n",
" ]\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_fields_batch_get_with_empty_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FieldsCommand::BatchGet { ids: vec![] };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Err(CliError::usage("ids cannot be empty.")));
assert!(buffer.is_empty());
assert_eq!(*mock_client.batch_get_fields_ids.lock().unwrap(), None);
}
#[tokio::test]
async fn handle_when_fields_batch_get_with_invalid_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FieldsCommand::BatchGet {
ids: vec![1, 0, 2],
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("ids must only contain values > 0."))
);
assert!(buffer.is_empty());
assert_eq!(*mock_client.batch_get_fields_ids.lock().unwrap(), None);
}
#[tokio::test]
async fn handle_when_fields_batch_get_with_too_many_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FieldsCommand::BatchGet {
ids: vec![1; 101],
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage(
"ids cannot contain more than 100 values."
))
);
assert!(buffer.is_empty());
assert_eq!(*mock_client.batch_get_fields_ids.lock().unwrap(), None);
}
#[tokio::test]
async fn handle_when_fields_batch_get_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
batch_get_fields_result: Err(OnspringError::Api {
status_code: 500,
message: "".to_string(),
}),
..Default::default()
};
let command = FieldsCommand::BatchGet { ids: vec![1, 2] };
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime("API request failed with status 500."))
);
assert!(buffer.is_empty());
}
}
+965
View File
@@ -0,0 +1,965 @@
use std::io::{Read, Write};
use chrono::{DateTime, Utc};
use onspring::SaveFileRequest;
use crate::cli::FilesCommand;
use crate::client::OnspringRunner;
use crate::error::{CliError, CliResult};
use crate::output::{CreatedWithIdResponseOutput, FileInfoOutput, SuccessResponse, write_json};
use crate::validation::validate_positive_i32;
pub async fn handle<C: OnspringRunner, W: Write>(
command: &FilesCommand,
client: &C,
writer: &mut W,
pretty: bool,
) -> CliResult<()> {
handle_with_reader(command, client, writer, std::io::stdin(), pretty).await
}
pub async fn handle_with_reader<C: OnspringRunner, W: Write, R: Read>(
command: &FilesCommand,
client: &C,
writer: &mut W,
mut reader: R,
pretty: bool,
) -> CliResult<()> {
match command {
FilesCommand::Info {
record_id,
field_id,
file_id,
} => {
validate_positive_i32(*record_id, "record_id")?;
validate_positive_i32(*field_id, "field_id")?;
validate_positive_i32(*file_id, "file_id")?;
let response = client
.get_file_info(*record_id, *field_id, *file_id)
.await?;
let output: FileInfoOutput = response.into();
write_json(writer, &output, pretty)?;
}
FilesCommand::Get {
record_id,
field_id,
file_id,
output,
} => {
validate_positive_i32(*record_id, "record_id")?;
validate_positive_i32(*field_id, "field_id")?;
validate_positive_i32(*file_id, "file_id")?;
let response = client.get_file(*record_id, *field_id, *file_id).await?;
if let Some(output_path) = output {
std::fs::write(output_path, &response.data).map_err(|e| {
CliError::runtime(format!(
"Failed to write file to '{}': {e}",
output_path.display()
))
})?;
} else {
writer
.write_all(&response.data)
.map_err(|_| CliError::runtime("Failed to write output"))?;
}
}
FilesCommand::Upload {
record_id,
field_id,
input,
stdin,
file_name,
content_type,
notes,
modified_date,
} => {
validate_positive_i32(*record_id, "record_id")?;
validate_positive_i32(*field_id, "field_id")?;
if input.is_none() && !*stdin {
return Err(CliError::usage(
"A file source is required. Provide --input or --stdin.",
));
}
if input.is_some() && *stdin {
return Err(CliError::usage(
"Only one file source may be specified (--input or --stdin).",
));
}
let (file_bytes, resolved_file_name) = if let Some(path) = input {
let bytes = std::fs::read(path).map_err(|e| {
CliError::usage(format!("Failed to read file '{}': {e}", path.display()))
})?;
let default_name = path
.file_name()
.and_then(|n| n.to_str())
.map(String::from);
let name = file_name.clone().or(default_name).ok_or_else(|| {
CliError::usage("A file name is required. Provide --name.")
})?;
(bytes, name)
} else {
let mut buffer = Vec::new();
reader
.read_to_end(&mut buffer)
.map_err(|e| CliError::runtime(format!("Failed to read from stdin: {e}")))?;
let name = file_name
.clone()
.ok_or_else(|| CliError::usage("A file name is required when reading from stdin. Provide --name."))?;
(buffer, name)
};
let resolved_content_type = match content_type {
Some(ct) => ct.clone(),
None => mime_guess::from_path(&resolved_file_name)
.first_or_octet_stream()
.to_string(),
};
let parsed_modified_date = match modified_date {
Some(date_str) => Some(DateTime::parse_from_rfc3339(date_str).map_err(|e| {
CliError::usage(format!(
"Invalid modified_date '{date_str}'. Expected RFC3339 format: {e}"
))
})?.with_timezone(&Utc)),
None => None,
};
let request = SaveFileRequest {
record_id: *record_id,
field_id: *field_id,
notes: notes.clone(),
modified_date: parsed_modified_date,
file_name: resolved_file_name,
file_data: file_bytes,
content_type: resolved_content_type,
};
let response = client.upload_file(request).await?;
let output: CreatedWithIdResponseOutput = response.into();
write_json(writer, &output, pretty)?;
}
FilesCommand::Delete {
record_id,
field_id,
file_id,
} => {
validate_positive_i32(*record_id, "record_id")?;
validate_positive_i32(*field_id, "field_id")?;
validate_positive_i32(*file_id, "file_id")?;
client.delete_file(*record_id, *field_id, *file_id).await?;
write_json(writer, &SuccessResponse { ok: true }, pretty)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use bytes::Bytes;
use chrono::TimeZone;
use onspring::{CreatedWithIdResponse, FileInfo, FileResponse, OnspringError};
use crate::client::testing::MockClient;
use crate::error::CliError;
// --- Info tests ---
#[tokio::test]
async fn handle_when_files_info_succeeds_it_should_write_file_info_output() {
let created = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
let modified = Utc.with_ymd_and_hms(2026, 1, 16, 12, 0, 0).unwrap();
let mock_client = MockClient {
get_file_info_result: Ok(FileInfo {
file_type: Some("Attachment".to_string()),
content_type: Some("application/pdf".to_string()),
name: Some("test.pdf".to_string()),
created_date: Some(created),
modified_date: Some(modified),
owner: Some("User 1".to_string()),
notes: Some("Some notes".to_string()),
file_href: Some("https://api.onspring.com/files/3/file".to_string()),
}),
..Default::default()
};
let command = FilesCommand::Info {
record_id: 1,
field_id: 2,
file_id: 3,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r#"{"type":"Attachment","contentType":"application/pdf","name":"test.pdf","createdDate":"2026-01-15T10:00:00Z","modifiedDate":"2026-01-16T12:00:00Z","owner":"User 1","notes":"Some notes","fileHref":"https://api.onspring.com/files/3/file"}"#
);
assert_eq!(*mock_client.get_file_info_record_id.lock().unwrap(), Some(1));
assert_eq!(*mock_client.get_file_info_field_id.lock().unwrap(), Some(2));
assert_eq!(*mock_client.get_file_info_file_id.lock().unwrap(), Some(3));
}
#[tokio::test]
async fn handle_when_files_info_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
get_file_info_result: Ok(FileInfo {
file_type: Some("Attachment".to_string()),
content_type: Some("text/plain".to_string()),
name: Some("note.txt".to_string()),
created_date: None,
modified_date: None,
owner: None,
notes: None,
file_href: None,
}),
..Default::default()
};
let command = FilesCommand::Info {
record_id: 1,
field_id: 2,
file_id: 3,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!(
"{\n",
" \"type\": \"Attachment\",\n",
" \"contentType\": \"text/plain\",\n",
" \"name\": \"note.txt\",\n",
" \"createdDate\": null,\n",
" \"modifiedDate\": null,\n",
" \"owner\": null,\n",
" \"notes\": null,\n",
" \"fileHref\": null\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_files_info_with_invalid_record_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Info {
record_id: 0,
field_id: 2,
file_id: 3,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("record_id must be greater than 0."))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_files_info_with_invalid_field_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Info {
record_id: 1,
field_id: -1,
file_id: 3,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("field_id must be greater than 0."))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_files_info_with_invalid_file_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Info {
record_id: 1,
field_id: 2,
file_id: 0,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("file_id must be greater than 0."))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_files_info_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
get_file_info_result: Err(OnspringError::Api {
status_code: 404,
message: "File not found".to_string(),
}),
..Default::default()
};
let command = FilesCommand::Info {
record_id: 1,
field_id: 2,
file_id: 3,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime("API request failed (404): File not found"))
);
assert!(buffer.is_empty());
}
// --- Get tests ---
#[tokio::test]
async fn handle_when_files_get_without_output_flag_it_should_write_bytes_to_writer() {
let file_bytes = b"Hello, World!";
let mock_client = MockClient {
get_file_result: Ok(FileResponse {
content_type: Some("text/plain".to_string()),
file_name: Some("hello.txt".to_string()),
data: Bytes::from_static(file_bytes),
}),
..Default::default()
};
let command = FilesCommand::Get {
record_id: 10,
field_id: 20,
file_id: 30,
output: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
assert_eq!(buffer, file_bytes);
assert_eq!(*mock_client.get_file_record_id.lock().unwrap(), Some(10));
assert_eq!(*mock_client.get_file_field_id.lock().unwrap(), Some(20));
assert_eq!(*mock_client.get_file_file_id.lock().unwrap(), Some(30));
}
#[tokio::test]
async fn handle_when_files_get_with_output_flag_it_should_write_to_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("downloaded.bin");
let file_bytes = vec![1, 2, 3, 4, 5];
let mock_client = MockClient {
get_file_result: Ok(FileResponse {
content_type: Some("application/octet-stream".to_string()),
file_name: Some("data.bin".to_string()),
data: Bytes::from(file_bytes.clone()),
}),
..Default::default()
};
let command = FilesCommand::Get {
record_id: 1,
field_id: 2,
file_id: 3,
output: Some(file_path.clone()),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
assert!(buffer.is_empty());
let read_back = std::fs::read(&file_path).unwrap();
assert_eq!(read_back, file_bytes);
}
#[tokio::test]
async fn handle_when_files_get_with_invalid_output_path_it_should_return_runtime_error() {
let mock_client = MockClient {
get_file_result: Ok(FileResponse {
content_type: None,
file_name: None,
data: Bytes::from_static(b"data"),
}),
..Default::default()
};
let command = FilesCommand::Get {
record_id: 1,
field_id: 2,
file_id: 3,
output: Some(PathBuf::from("/nonexistent_dir_12345/sub/file.txt")),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, 1);
assert!(err.message.contains("Failed to write file to"));
}
#[tokio::test]
async fn handle_when_files_get_with_invalid_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Get {
record_id: 0,
field_id: 1,
file_id: 1,
output: None,
};
let mut buffer = Vec::new();
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("record_id must be greater than 0."))
);
let command = FilesCommand::Get {
record_id: 1,
field_id: 0,
file_id: 1,
output: None,
};
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("field_id must be greater than 0."))
);
let command = FilesCommand::Get {
record_id: 1,
field_id: 1,
file_id: 0,
output: None,
};
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("file_id must be greater than 0."))
);
}
#[tokio::test]
async fn handle_when_files_get_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
get_file_result: Err(OnspringError::Api {
status_code: 500,
message: "Internal error".to_string(),
}),
..Default::default()
};
let command = FilesCommand::Get {
record_id: 1,
field_id: 2,
file_id: 3,
output: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime(
"API request failed (500): Internal error"
))
);
}
// --- Upload tests ---
#[tokio::test]
async fn handle_when_files_upload_from_input_file_succeeds() {
let temp_file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(temp_file.path(), b"Upload content").unwrap();
let mock_client = MockClient {
upload_file_result: Ok(CreatedWithIdResponse { id: 99 }),
..Default::default()
};
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(temp_file.path().to_path_buf()),
stdin: false,
file_name: Some("custom_name.txt".to_string()),
content_type: Some("text/custom".to_string()),
notes: Some("Upload note".to_string()),
modified_date: Some("2026-03-01T12:00:00Z".to_string()),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(written.trim(), r#"{"id":99}"#);
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
assert_eq!(req.record_id, 1);
assert_eq!(req.field_id, 2);
assert_eq!(req.file_name, "custom_name.txt");
assert_eq!(req.content_type, "text/custom");
assert_eq!(req.file_data, b"Upload content");
assert_eq!(req.notes, Some("Upload note".to_string()));
assert_eq!(
req.modified_date,
Some(Utc.with_ymd_and_hms(2026, 3, 1, 12, 0, 0).unwrap())
);
}
#[tokio::test]
async fn handle_when_files_upload_succeeds_pretty_it_should_write_pretty_json() {
let temp_file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(temp_file.path(), b"content").unwrap();
let mock_client = MockClient {
upload_file_result: Ok(CreatedWithIdResponse { id: 101 }),
..Default::default()
};
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(temp_file.path().to_path_buf()),
stdin: false,
file_name: Some("data.txt".to_string()),
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!("{\n", " \"id\": 101\n", "}");
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_files_upload_infers_file_name_and_content_type_from_input() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("report.csv");
std::fs::write(&file_path, b"a,b,c\n1,2,3").unwrap();
let mock_client = MockClient {
upload_file_result: Ok(CreatedWithIdResponse { id: 50 }),
..Default::default()
};
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(file_path),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
assert_eq!(req.file_name, "report.csv");
assert_eq!(req.content_type, "text/csv");
}
#[tokio::test]
async fn handle_when_files_upload_reads_from_stdin() {
let mock_client = MockClient {
upload_file_result: Ok(CreatedWithIdResponse { id: 75 }),
..Default::default()
};
let command = FilesCommand::Upload {
record_id: 5,
field_id: 10,
input: None,
stdin: true,
file_name: Some("data.json".to_string()),
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let stdin_bytes = b"{\"hello\":\"world\"}";
let result = handle_with_reader(
&command,
&mock_client,
&mut buffer,
&stdin_bytes[..],
false,
)
.await;
assert_eq!(result, Ok(()));
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
assert_eq!(req.record_id, 5);
assert_eq!(req.field_id, 10);
assert_eq!(req.file_name, "data.json");
assert_eq!(req.content_type, "application/json");
assert_eq!(req.file_data, stdin_bytes);
}
#[tokio::test]
async fn handle_when_files_upload_without_source_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: None,
stdin: false,
file_name: Some("file.txt".to_string()),
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage(
"A file source is required. Provide --input or --stdin."
))
);
}
#[tokio::test]
async fn handle_when_files_upload_with_both_sources_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(PathBuf::from("test.txt")),
stdin: true,
file_name: Some("file.txt".to_string()),
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage(
"Only one file source may be specified (--input or --stdin)."
))
);
}
#[tokio::test]
async fn handle_when_files_upload_input_file_not_found_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(PathBuf::from("nonexistent_file_12345.txt")),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, 2);
assert!(err.message.contains("Failed to read file"));
}
#[tokio::test]
async fn handle_when_files_upload_stdin_missing_name_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: None,
stdin: true,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle_with_reader(
&command,
&mock_client,
&mut buffer,
std::io::empty(),
false,
)
.await;
assert_eq!(
result,
Err(CliError::usage(
"A file name is required when reading from stdin. Provide --name."
))
);
}
#[tokio::test]
async fn handle_when_files_upload_invalid_modified_date_it_should_return_usage_error() {
let temp_file = tempfile::NamedTempFile::new().unwrap();
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(temp_file.path().to_path_buf()),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: Some("not-a-date".to_string()),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, 2);
assert!(err.message.contains("Invalid modified_date"));
}
#[tokio::test]
async fn handle_when_files_upload_with_invalid_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 0,
field_id: 2,
input: Some(PathBuf::from("test.txt")),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("record_id must be greater than 0."))
);
let command = FilesCommand::Upload {
record_id: 1,
field_id: 0,
input: Some(PathBuf::from("test.txt")),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("field_id must be greater than 0."))
);
}
#[tokio::test]
async fn handle_when_files_upload_fails_it_should_return_mapped_cli_error() {
let temp_file = tempfile::NamedTempFile::new().unwrap();
let mock_client = MockClient {
upload_file_result: Err(OnspringError::Api {
status_code: 400,
message: "Invalid file type".to_string(),
}),
..Default::default()
};
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(temp_file.path().to_path_buf()),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime(
"API request failed (400): Invalid file type"
))
);
}
// --- Delete tests ---
#[tokio::test]
async fn handle_when_files_delete_succeeds_it_should_write_success_response() {
let mock_client = MockClient {
delete_file_result: Ok(()),
..Default::default()
};
let command = FilesCommand::Delete {
record_id: 1,
field_id: 2,
file_id: 3,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(written.trim(), r#"{"ok":true}"#);
assert_eq!(*mock_client.delete_file_record_id.lock().unwrap(), Some(1));
assert_eq!(*mock_client.delete_file_field_id.lock().unwrap(), Some(2));
assert_eq!(*mock_client.delete_file_file_id.lock().unwrap(), Some(3));
}
#[tokio::test]
async fn handle_when_files_delete_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
delete_file_result: Ok(()),
..Default::default()
};
let command = FilesCommand::Delete {
record_id: 1,
field_id: 2,
file_id: 3,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!("{\n", " \"ok\": true\n", "}");
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_files_delete_with_invalid_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Delete {
record_id: 0,
field_id: 2,
file_id: 3,
};
let mut buffer = Vec::new();
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("record_id must be greater than 0."))
);
let command = FilesCommand::Delete {
record_id: 1,
field_id: 0,
file_id: 3,
};
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("field_id must be greater than 0."))
);
let command = FilesCommand::Delete {
record_id: 1,
field_id: 2,
file_id: -1,
};
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("file_id must be greater than 0."))
);
}
#[tokio::test]
async fn handle_when_files_delete_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
delete_file_result: Err(OnspringError::Api {
status_code: 404,
message: "File not found".to_string(),
}),
..Default::default()
};
let command = FilesCommand::Delete {
record_id: 1,
field_id: 2,
file_id: 3,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime("API request failed (404): File not found"))
);
}
}
+372
View File
@@ -0,0 +1,372 @@
use std::io::{Read, Write};
use onspring::SaveListItemRequest;
use serde::Deserialize;
use uuid::Uuid;
use crate::cli::ListsCommand;
use crate::client::OnspringRunner;
use crate::error::CliResult;
use crate::output::{SaveListItemResponseOutput, SuccessResponse, write_json};
use crate::validation::{parse_body, validate_positive_i32};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveListItemInput {
pub id: Option<Uuid>,
pub name: String,
pub numeric_value: Option<f64>,
pub color: Option<String>,
}
impl From<SaveListItemInput> for SaveListItemRequest {
fn from(value: SaveListItemInput) -> Self {
Self {
id: value.id,
name: value.name,
numeric_value: value.numeric_value,
color: value.color,
}
}
}
pub async fn handle<C: OnspringRunner, W: Write>(
command: &ListsCommand,
client: &C,
writer: &mut W,
pretty: bool,
) -> CliResult<()> {
handle_with_reader(command, client, writer, std::io::stdin(), pretty).await
}
pub async fn handle_with_reader<C: OnspringRunner, W: Write, R: Read>(
command: &ListsCommand,
client: &C,
writer: &mut W,
reader: R,
pretty: bool,
) -> CliResult<()> {
match command {
ListsCommand::SaveItem { list_id, body } => {
validate_positive_i32(*list_id, "list_id")?;
let input: SaveListItemInput = parse_body(body, reader)?;
let request: SaveListItemRequest = input.into();
let response = client.save_list_item(*list_id, request).await?;
let output: SaveListItemResponseOutput = response.into();
write_json(writer, &output, pretty)?;
}
ListsCommand::DeleteItem { list_id, item_id } => {
validate_positive_i32(*list_id, "list_id")?;
client.delete_list_item(*list_id, *item_id).await?;
write_json(writer, &SuccessResponse { ok: true }, pretty)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use onspring::{OnspringError, SaveListItemResponse};
use crate::cli::BodySource;
use crate::client::testing::MockClient;
use crate::error::CliError;
// --- SaveItem tests ---
#[tokio::test]
async fn handle_when_save_item_succeeds_it_should_write_save_response() {
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
let mock_client = MockClient {
save_list_item_result: Ok(SaveListItemResponse { id: item_uuid }),
..Default::default()
};
let command = ListsCommand::SaveItem {
list_id: 1,
body: BodySource {
json: Some(
r##"{"id":"d3b07384-d113-40e1-a20c-55c3c0a4e70e","name":"Item 1","numericValue":10.5,"color":"#ffffff"}"##
.to_string(),
),
file: None,
stdin: false,
},
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r#"{"id":"d3b07384-d113-40e1-a20c-55c3c0a4e70e"}"#
);
assert_eq!(*mock_client.save_list_item_list_id.lock().unwrap(), Some(1));
let req = mock_client
.save_list_item_request
.lock()
.unwrap()
.clone()
.unwrap();
assert_eq!(req.id, Some(item_uuid));
assert_eq!(req.name, "Item 1");
assert_eq!(req.numeric_value, Some(10.5));
assert_eq!(req.color, Some("#ffffff".to_string()));
}
#[tokio::test]
async fn handle_when_save_item_succeeds_pretty_it_should_write_pretty_json() {
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
let mock_client = MockClient {
save_list_item_result: Ok(SaveListItemResponse { id: item_uuid }),
..Default::default()
};
let command = ListsCommand::SaveItem {
list_id: 1,
body: BodySource {
json: Some(r#"{"name":"New Item"}"#.to_string()),
file: None,
stdin: false,
},
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!(
"{\n",
" \"id\": \"d3b07384-d113-40e1-a20c-55c3c0a4e70e\"\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_save_item_with_invalid_list_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = ListsCommand::SaveItem {
list_id: 0,
body: BodySource {
json: Some(r#"{"name":"Item"}"#.to_string()),
file: None,
stdin: false,
},
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("list_id must be greater than 0."))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_save_item_has_missing_body_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = ListsCommand::SaveItem {
list_id: 1,
body: BodySource::default(),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage(
"A request body is required. Provide --json, --file, or --stdin."
))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_save_item_reads_from_stdin() {
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
let mock_client = MockClient {
save_list_item_result: Ok(SaveListItemResponse { id: item_uuid }),
..Default::default()
};
let command = ListsCommand::SaveItem {
list_id: 5,
body: BodySource {
json: None,
file: None,
stdin: true,
},
};
let mut buffer = Vec::new();
let stdin_data = r#"{"name":"From Stdin","numericValue":2.0}"#;
let result = handle_with_reader(
&command,
&mock_client,
&mut buffer,
stdin_data.as_bytes(),
false,
)
.await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r#"{"id":"d3b07384-d113-40e1-a20c-55c3c0a4e70e"}"#
);
let req = mock_client
.save_list_item_request
.lock()
.unwrap()
.clone()
.unwrap();
assert_eq!(req.name, "From Stdin");
assert_eq!(req.numeric_value, Some(2.0));
}
#[tokio::test]
async fn handle_when_save_item_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
save_list_item_result: Err(OnspringError::Api {
status_code: 400,
message: "Invalid list item".to_string(),
}),
..Default::default()
};
let command = ListsCommand::SaveItem {
list_id: 1,
body: BodySource {
json: Some(r#"{"name":"Item"}"#.to_string()),
file: None,
stdin: false,
},
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime(
"API request failed (400): Invalid list item"
))
);
assert!(buffer.is_empty());
}
// --- DeleteItem tests ---
#[tokio::test]
async fn handle_when_delete_item_succeeds_it_should_write_success_response() {
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
let mock_client = MockClient {
delete_list_item_result: Ok(()),
..Default::default()
};
let command = ListsCommand::DeleteItem {
list_id: 1,
item_id: item_uuid,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(written.trim(), r#"{"ok":true}"#);
assert_eq!(*mock_client.delete_list_item_list_id.lock().unwrap(), Some(1));
assert_eq!(
*mock_client.delete_list_item_item_id.lock().unwrap(),
Some(item_uuid)
);
}
#[tokio::test]
async fn handle_when_delete_item_succeeds_pretty_it_should_write_pretty_json() {
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
let mock_client = MockClient {
delete_list_item_result: Ok(()),
..Default::default()
};
let command = ListsCommand::DeleteItem {
list_id: 1,
item_id: item_uuid,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!("{\n", " \"ok\": true\n", "}");
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_delete_item_with_invalid_list_id_it_should_return_usage_error() {
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
let mock_client = MockClient::default();
let command = ListsCommand::DeleteItem {
list_id: 0,
item_id: item_uuid,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("list_id must be greater than 0."))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_delete_item_fails_it_should_return_mapped_cli_error() {
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
let mock_client = MockClient {
delete_list_item_result: Err(OnspringError::Api {
status_code: 404,
message: "List item not found".to_string(),
}),
..Default::default()
};
let command = ListsCommand::DeleteItem {
list_id: 1,
item_id: item_uuid,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime(
"API request failed (404): List item not found"
))
);
assert!(buffer.is_empty());
}
}
+29
View File
@@ -0,0 +1,29 @@
pub mod apps;
pub mod fields;
pub mod files;
pub mod lists;
pub mod ping;
pub mod records;
pub mod reports;
use std::io::Write;
use crate::cli::{Cli, Command};
use crate::client::OnspringRunner;
use crate::error::CliResult;
pub async fn handle<C: OnspringRunner, W: Write>(
cli: &Cli,
client: &C,
writer: &mut W,
) -> CliResult<()> {
match &cli.command {
Command::Ping => ping::handle(client, writer, cli.pretty).await,
Command::Apps { command } => apps::handle(command, client, writer, cli.pretty).await,
Command::Fields { command } => fields::handle(command, client, writer, cli.pretty).await,
Command::Records { command } => records::handle(command, client, writer, cli.pretty).await,
Command::Files { command } => files::handle(command, client, writer, cli.pretty).await,
Command::Lists { command } => lists::handle(command, client, writer, cli.pretty).await,
Command::Reports { command } => reports::handle(command, client, writer, cli.pretty).await,
}
}
+75
View File
@@ -0,0 +1,75 @@
use std::io::Write;
use crate::client::OnspringRunner;
use crate::error::CliResult;
use crate::output::{SuccessResponse, write_json};
pub async fn handle<C: OnspringRunner, W: Write>(
client: &C,
writer: &mut W,
pretty: bool,
) -> CliResult<()> {
client.ping().await?;
write_json(writer, &SuccessResponse { ok: true }, pretty)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use onspring::OnspringError;
use crate::client::testing::MockClient;
use crate::error::CliError;
#[tokio::test]
async fn handle_when_ping_succeeds_it_should_write_success_response() {
let mock_client = MockClient {
ping_result: Ok(()),
..Default::default()
};
let mut buffer = Vec::new();
let result = handle(&mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(written.trim(), r#"{"ok":true}"#);
}
#[tokio::test]
async fn handle_when_ping_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
ping_result: Ok(()),
..Default::default()
};
let mut buffer = Vec::new();
let result = handle(&mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(written.trim(), "{\n \"ok\": true\n}");
}
#[tokio::test]
async fn handle_when_ping_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
ping_result: Err(OnspringError::InvalidArgument("Invalid Key".to_string())),
..Default::default()
};
let mut buffer = Vec::new();
let result = handle(&mock_client, &mut buffer, false).await;
assert_eq!(result, Err(CliError::usage("Invalid request: Invalid Key")));
assert!(buffer.is_empty());
}
}
File diff suppressed because it is too large. Load diff
+472
View File
@@ -0,0 +1,472 @@
use std::io::Write;
use onspring::{DataFormat, ReportDataType};
use crate::cli::{ReportDataTypeArg, ReportsCommand};
use crate::client::OnspringRunner;
use crate::error::CliResult;
use crate::output::{PagedOutput, ReportDataOutput, ReportInfoOutput, write_json};
use crate::validation::{paging_request, validate_positive_i32};
impl From<ReportDataTypeArg> for ReportDataType {
fn from(value: ReportDataTypeArg) -> Self {
match value {
ReportDataTypeArg::ReportData => ReportDataType::ReportData,
ReportDataTypeArg::ChartData => ReportDataType::ChartData,
}
}
}
pub async fn handle<C: OnspringRunner, W: Write>(
command: &ReportsCommand,
client: &C,
writer: &mut W,
pretty: bool,
) -> CliResult<()> {
match command {
ReportsCommand::Get {
report_id,
data_format,
data_type,
} => {
validate_positive_i32(*report_id, "report_id")?;
let data_format = data_format.map(DataFormat::from);
let data_type = data_type.map(ReportDataType::from);
let response = client.get_report(*report_id, data_format, data_type).await?;
let output: ReportDataOutput = response.into();
write_json(writer, &output, pretty)?;
}
ReportsCommand::List { app_id, paging } => {
validate_positive_i32(*app_id, "app_id")?;
let paging = paging_request(paging)?;
let response = client.list_reports(*app_id, paging).await?;
let output: PagedOutput<ReportInfoOutput> = response.into();
write_json(writer, &output, pretty)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use onspring::{OnspringError, PagedResponse, ReportData, ReportInfo, ReportRow};
use serde_json::json;
use crate::cli::{DataFormatArg, PagingArgs};
use crate::client::testing::MockClient;
use crate::error::CliError;
#[tokio::test]
async fn handle_when_reports_get_succeeds_it_should_write_report_data_output() {
let mock_client = MockClient {
get_report_result: Ok(ReportData {
columns: Some(vec!["Col1".to_string(), "Col2".to_string()]),
rows: Some(vec![ReportRow {
record_id: Some(10),
cells: Some(vec![json!("Val1"), json!(42)]),
}]),
}),
..Default::default()
};
let command = ReportsCommand::Get {
report_id: 123,
data_format: None,
data_type: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r#"{"columns":["Col1","Col2"],"rows":[{"recordId":10,"cells":["Val1",42]}]}"#
);
assert_eq!(*mock_client.get_report_report_id.lock().unwrap(), Some(123));
assert_eq!(
*mock_client.get_report_data_format.lock().unwrap(),
Some(None)
);
assert_eq!(*mock_client.get_report_data_type.lock().unwrap(), Some(None));
}
#[tokio::test]
async fn handle_when_reports_get_with_format_and_type_it_should_pass_mapped_arguments() {
let mock_client = MockClient {
get_report_result: Ok(ReportData {
columns: Some(vec!["Header".to_string()]),
rows: Some(vec![]),
}),
..Default::default()
};
let command = ReportsCommand::Get {
report_id: 456,
data_format: Some(DataFormatArg::Formatted),
data_type: Some(ReportDataTypeArg::ChartData),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
assert_eq!(*mock_client.get_report_report_id.lock().unwrap(), Some(456));
assert_eq!(
*mock_client.get_report_data_format.lock().unwrap(),
Some(Some(DataFormat::Formatted))
);
assert_eq!(
*mock_client.get_report_data_type.lock().unwrap(),
Some(Some(ReportDataType::ChartData))
);
}
#[tokio::test]
async fn handle_when_reports_get_with_raw_and_report_data_it_should_pass_mapped_arguments() {
let mock_client = MockClient {
get_report_result: Ok(ReportData {
columns: None,
rows: None,
}),
..Default::default()
};
let command = ReportsCommand::Get {
report_id: 789,
data_format: Some(DataFormatArg::Raw),
data_type: Some(ReportDataTypeArg::ReportData),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
assert_eq!(*mock_client.get_report_report_id.lock().unwrap(), Some(789));
assert_eq!(
*mock_client.get_report_data_format.lock().unwrap(),
Some(Some(DataFormat::Raw))
);
assert_eq!(
*mock_client.get_report_data_type.lock().unwrap(),
Some(Some(ReportDataType::ReportData))
);
}
#[tokio::test]
async fn handle_when_reports_get_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
get_report_result: Ok(ReportData {
columns: Some(vec!["Col".to_string()]),
rows: Some(vec![ReportRow {
record_id: Some(1),
cells: Some(vec![json!("Val")]),
}]),
}),
..Default::default()
};
let command = ReportsCommand::Get {
report_id: 1,
data_format: None,
data_type: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!(
"{\n",
" \"columns\": [\n",
" \"Col\"\n",
" ],\n",
" \"rows\": [\n",
" {\n",
" \"recordId\": 1,\n",
" \"cells\": [\n",
" \"Val\"\n",
" ]\n",
" }\n",
" ]\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_reports_get_has_invalid_report_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = ReportsCommand::Get {
report_id: 0,
data_format: None,
data_type: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("report_id must be greater than 0."))
);
assert!(buffer.is_empty());
assert!(mock_client.get_report_report_id.lock().unwrap().is_none());
}
#[tokio::test]
async fn handle_when_reports_get_has_negative_report_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = ReportsCommand::Get {
report_id: -5,
data_format: None,
data_type: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("report_id must be greater than 0."))
);
assert!(buffer.is_empty());
assert!(mock_client.get_report_report_id.lock().unwrap().is_none());
}
#[tokio::test]
async fn handle_when_reports_get_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
get_report_result: Err(OnspringError::Api {
status_code: 404,
message: "Report not found".to_string(),
}),
..Default::default()
};
let command = ReportsCommand::Get {
report_id: 999,
data_format: None,
data_type: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime("API request failed (404): Report not found"))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_reports_list_succeeds_it_should_write_paged_output() {
let mock_client = MockClient {
list_reports_result: Ok(PagedResponse {
page_number: Some(1),
page_size: Some(50),
total_pages: Some(1),
total_records: Some(1),
items: Some(vec![ReportInfo {
app_id: 10,
id: 100,
name: Some("Open Tasks".to_string()),
description: Some("Report of open tasks".to_string()),
}]),
}),
..Default::default()
};
let command = ReportsCommand::List {
app_id: 10,
paging: PagingArgs::default(),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(
written.trim(),
r#"{"pageNumber":1,"pageSize":50,"totalPages":1,"totalRecords":1,"items":[{"appId":10,"id":100,"name":"Open Tasks","description":"Report of open tasks"}]}"#
);
assert_eq!(*mock_client.list_reports_app_id.lock().unwrap(), Some(10));
assert!(
mock_client
.list_reports_paging
.lock()
.unwrap()
.as_ref()
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn handle_when_reports_list_with_paging_args_it_should_pass_paging_request() {
let mock_client = MockClient {
list_reports_result: Ok(PagedResponse {
page_number: Some(2),
page_size: Some(15),
total_pages: Some(3),
total_records: Some(40),
items: Some(vec![]),
}),
..Default::default()
};
let command = ReportsCommand::List {
app_id: 10,
paging: PagingArgs {
page_number: Some(2),
page_size: Some(15),
},
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let paging = mock_client
.list_reports_paging
.lock()
.unwrap()
.take()
.unwrap()
.unwrap();
assert_eq!(paging.page_number, 2);
assert_eq!(paging.page_size, 15);
}
#[tokio::test]
async fn handle_when_reports_list_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
list_reports_result: Ok(PagedResponse {
page_number: Some(1),
page_size: Some(50),
total_pages: Some(1),
total_records: Some(1),
items: Some(vec![ReportInfo {
app_id: 10,
id: 100,
name: Some("Open Tasks".to_string()),
description: None,
}]),
}),
..Default::default()
};
let command = ReportsCommand::List {
app_id: 10,
paging: PagingArgs::default(),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, true).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
let expected = concat!(
"{\n",
" \"pageNumber\": 1,\n",
" \"pageSize\": 50,\n",
" \"totalPages\": 1,\n",
" \"totalRecords\": 1,\n",
" \"items\": [\n",
" {\n",
" \"appId\": 10,\n",
" \"id\": 100,\n",
" \"name\": \"Open Tasks\",\n",
" \"description\": null\n",
" }\n",
" ]\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_reports_list_has_invalid_app_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = ReportsCommand::List {
app_id: 0,
paging: PagingArgs::default(),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("app_id must be greater than 0."))
);
assert!(buffer.is_empty());
assert!(mock_client.list_reports_app_id.lock().unwrap().is_none());
}
#[tokio::test]
async fn handle_when_reports_list_has_invalid_paging_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = ReportsCommand::List {
app_id: 10,
paging: PagingArgs {
page_number: Some(-1),
page_size: None,
},
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("page_number must be greater than 0."))
);
assert!(buffer.is_empty());
assert!(mock_client.list_reports_paging.lock().unwrap().is_none());
}
#[tokio::test]
async fn handle_when_reports_list_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
list_reports_result: Err(OnspringError::Api {
status_code: 403,
message: "Forbidden".to_string(),
}),
..Default::default()
};
let command = ReportsCommand::List {
app_id: 10,
paging: PagingArgs::default(),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::runtime("API request failed (403): Forbidden"))
);
assert!(buffer.is_empty());
}
}
+215
View File
@@ -0,0 +1,215 @@
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::cli::Cli;
use crate::error::{CliError, CliResult};
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ConfigFile {
pub api_key: Option<String>,
pub base_url: Option<String>,
pub pretty: Option<bool>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedConfig {
pub api_key: Option<String>,
pub base_url: Option<String>,
pub pretty: bool,
}
pub fn default_config_path() -> Option<PathBuf> {
let home = std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok())?;
Some(PathBuf::from(home).join(".config").join("onx").join("config.json"))
}
pub fn load_config_file(path: &Path, is_explicit: bool) -> CliResult<Option<ConfigFile>> {
if !path.exists() {
if is_explicit {
return Err(CliError::usage(format!(
"Config file not found: {}",
path.display()
)));
}
return Ok(None);
}
let contents = fs::read_to_string(path).map_err(|err| {
CliError::runtime(format!(
"Failed to read config file {}: {err}",
path.display()
))
})?;
let config: ConfigFile = serde_json::from_str(&contents).map_err(|err| {
CliError::runtime(format!(
"Failed to parse config file {}: {err}",
path.display()
))
})?;
Ok(Some(config))
}
pub fn resolve_config(cli: &Cli, config_file: Option<&ConfigFile>) -> ResolvedConfig {
let config_file_default = ConfigFile::default();
let file = config_file.unwrap_or(&config_file_default);
// Precedence: CLI Flag > Config File > Environment Variable
let api_key = cli
.api_key
.clone()
.or_else(|| file.api_key.clone())
.or_else(|| std::env::var("ONSPRING_API_KEY").ok());
let base_url = cli
.base_url
.clone()
.or_else(|| file.base_url.clone())
.or_else(|| std::env::var("ONSPRING_BASE_URL").ok());
let pretty = if cli.pretty {
true
} else {
file.pretty.unwrap_or(false)
};
ResolvedConfig {
api_key,
base_url,
pretty,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Command;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn load_config_file_when_file_exists_it_should_parse_camel_case_json() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
r#"{{"apiKey":"file-key","baseUrl":"https://config.onspring.com","pretty":true}}"#
)
.unwrap();
let loaded = load_config_file(file.path(), true).unwrap();
assert_eq!(
loaded,
Some(ConfigFile {
api_key: Some("file-key".to_string()),
base_url: Some("https://config.onspring.com".to_string()),
pretty: Some(true),
})
);
}
#[test]
fn load_config_file_when_not_explicit_and_file_missing_it_should_return_none() {
let missing_path = PathBuf::from("non_existent_config_file_path_12345.json");
let loaded = load_config_file(&missing_path, false).unwrap();
assert_eq!(loaded, None);
}
#[test]
fn load_config_file_when_explicit_and_file_missing_it_should_return_usage_error() {
let missing_path = PathBuf::from("non_existent_config_file_path_12345.json");
let err = load_config_file(&missing_path, true).unwrap_err();
assert_eq!(err.code, 2);
assert!(err.message.contains("Config file not found"));
}
#[test]
fn load_config_file_when_invalid_json_it_should_return_runtime_error() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "not json").unwrap();
let err = load_config_file(file.path(), true).unwrap_err();
assert_eq!(err.code, 1);
assert!(err.message.contains("Failed to parse config file"));
}
#[test]
fn default_config_path_when_home_or_userprofile_set_it_should_resolve_path() {
let path = default_config_path();
assert!(path.is_some());
let p = path.unwrap();
assert!(p.ends_with(".config/onx/config.json") || p.ends_with(r#".config\onx\config.json"#));
}
#[test]
fn resolve_config_when_cli_flag_provided_it_should_take_precedence() {
let cli = Cli {
api_key: Some("cli-key".to_string()),
base_url: Some("https://cli.onspring.com".to_string()),
config: None,
pretty: true,
command: Command::Ping,
};
let file = ConfigFile {
api_key: Some("file-key".to_string()),
base_url: Some("https://file.onspring.com".to_string()),
pretty: Some(false),
};
let resolved = resolve_config(&cli, Some(&file));
assert_eq!(
resolved,
ResolvedConfig {
api_key: Some("cli-key".to_string()),
base_url: Some("https://cli.onspring.com".to_string()),
pretty: true,
}
);
}
#[test]
fn resolve_config_when_cli_flag_absent_it_should_use_config_file() {
let cli = Cli {
api_key: None,
base_url: None,
config: None,
pretty: false,
command: Command::Ping,
};
let file = ConfigFile {
api_key: Some("file-key".to_string()),
base_url: Some("https://file.onspring.com".to_string()),
pretty: Some(true),
};
let resolved = resolve_config(&cli, Some(&file));
assert_eq!(
resolved,
ResolvedConfig {
api_key: Some("file-key".to_string()),
base_url: Some("https://file.onspring.com".to_string()),
pretty: true,
}
);
}
#[test]
fn resolve_config_when_no_file_it_should_default_pretty_to_false() {
let cli = Cli {
api_key: None,
base_url: None,
config: None,
pretty: false,
command: Command::Ping,
};
let resolved = resolve_config(&cli, None);
assert!(!resolved.pretty);
}
}
+140
View File
@@ -0,0 +1,140 @@
use onspring::OnspringError;
use crate::output::render_error;
pub type CliResult<T> = Result<T, CliError>;
#[derive(Debug, PartialEq)]
pub struct CliError {
pub code: i32,
pub message: String,
}
impl CliError {
pub fn info(message: impl Into<String>) -> Self {
Self {
code: 0,
message: message.into(),
}
}
pub fn runtime(message: impl Into<String>) -> Self {
Self {
code: 1,
message: message.into(),
}
}
pub fn usage(message: impl Into<String>) -> Self {
Self {
code: 2,
message: message.into(),
}
}
}
impl From<OnspringError> for CliError {
fn from(value: OnspringError) -> Self {
match value {
OnspringError::InvalidArgument(message) => Self::usage(format!("Invalid request: {message}")),
OnspringError::Api {
status_code,
message,
} => {
if message.is_empty() {
Self::runtime(format!("API request failed with status {status_code}."))
} else {
Self::runtime(format!("API request failed ({status_code}): {message}"))
}
}
OnspringError::Http(_) => Self::runtime("Network request failed."),
OnspringError::Serialization(_) => Self::runtime("Failed to parse API response."),
}
}
}
pub fn render_cli_error(err: &CliError, pretty: bool) -> String {
render_error(err, pretty)
.unwrap_or_else(|_| "{\"error\":{\"code\":1,\"message\":\"Unexpected error.\"}}".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_cli_error_when_called_with_error_it_should_render_json() {
let error = CliError {
code: 1,
message: String::from("Something went wrong"),
};
let result = render_cli_error(&error, false);
assert_eq!(
result,
r#"{"error":{"code":1,"message":"Something went wrong"}}"#
);
}
#[test]
fn cli_error_constructors_should_set_expected_codes_and_messages() {
let info = CliError::info("info message");
assert_eq!(info.code, 0);
assert_eq!(info.message, "info message");
let runtime = CliError::runtime("runtime message");
assert_eq!(runtime.code, 1);
assert_eq!(runtime.message, "runtime message");
let usage = CliError::usage("usage message");
assert_eq!(usage.code, 2);
assert_eq!(usage.message, "usage message");
}
#[test]
fn cli_error_from_onspring_error_invalid_argument() {
let err = OnspringError::InvalidArgument("bad param".to_string());
let cli_err = CliError::from(err);
assert_eq!(cli_err.code, 2);
assert_eq!(cli_err.message, "Invalid request: bad param");
}
#[test]
fn cli_error_from_onspring_error_api_with_message() {
let err = OnspringError::Api {
status_code: 404,
message: "Resource not found".to_string(),
};
let cli_err = CliError::from(err);
assert_eq!(cli_err.code, 1);
assert_eq!(
cli_err.message,
"API request failed (404): Resource not found"
);
}
#[test]
fn cli_error_from_onspring_error_api_with_empty_message() {
let err = OnspringError::Api {
status_code: 500,
message: String::new(),
};
let cli_err = CliError::from(err);
assert_eq!(cli_err.code, 1);
assert_eq!(cli_err.message, "API request failed with status 500.");
}
#[test]
fn cli_error_from_onspring_error_serialization() {
let serde_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
let err = OnspringError::Serialization(serde_err);
let cli_err = CliError::from(err);
assert_eq!(cli_err.code, 1);
assert_eq!(cli_err.message, "Failed to parse API response.");
}
}
+164 -43
View File
@@ -1,58 +1,179 @@
mod cli;
mod client;
mod commands;
mod config;
mod error;
mod output;
mod validation;
use clap::{Parser, error::ErrorKind};
use cli::Cli;
use onspring::OnspringClient;
use output::render_error;
pub use cli::{Cli, Command, parse_cli_from};
pub use client::OnspringRunner;
pub use config::{ConfigFile, ResolvedConfig, default_config_path, load_config_file, resolve_config};
pub use error::{CliError, CliResult, render_cli_error};
#[derive(Debug, PartialEq)]
pub struct CliError {
pub code: i32,
pub message: String,
pub async fn run(mut cli: Cli) -> CliResult<()> {
let config_file = if let Some(config_path) = &cli.config {
load_config_file(config_path, true)?
} else if let Some(default_path) = default_config_path() {
load_config_file(&default_path, false)?
} else {
None
};
let resolved = resolve_config(&cli, config_file.as_ref());
let api_key = resolved
.api_key
.as_deref()
.ok_or_else(|| CliError::usage("Missing API Key. Set --api-key, config file, or ONSPRING_API_KEY."))?;
let mut builder = OnspringClient::builder(api_key.to_string());
if let Some(base_url) = &resolved.base_url {
if base_url.trim().is_empty() {
return Err(CliError::usage("Base URL cannot be empty."));
}
builder = builder.base_url(base_url);
}
cli.pretty = resolved.pretty;
let client = builder.build();
let mut stdout = std::io::stdout();
run_with_client(&cli, &client, &mut stdout).await
}
impl CliError {
pub fn info(message: impl Into<String>) -> Self {
Self {
code: 0,
message: message.into(),
}
pub async fn run_with_client<C: OnspringRunner, W: std::io::Write>(
cli: &Cli,
client: &C,
writer: &mut W,
) -> CliResult<()> {
commands::handle(cli, client, writer).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[tokio::test]
async fn run_when_missing_api_key_it_should_return_usage_error() {
let cli = Cli {
api_key: None,
base_url: None,
config: None,
pretty: false,
command: Command::Ping,
};
let result = run(cli).await;
assert_eq!(
result,
Err(CliError::usage(
"Missing API Key. Set --api-key, config file, or ONSPRING_API_KEY."
))
);
}
pub fn runtime(message: impl Into<String>) -> Self {
Self {
code: 1,
message: message.into(),
}
#[tokio::test]
async fn run_when_api_key_in_config_file_it_should_succeed() {
let mut config_file = NamedTempFile::new().unwrap();
writeln!(config_file, r#"{{"apiKey":"config-key"}}"#).unwrap();
let cli = Cli {
api_key: None,
base_url: None,
config: Some(config_file.path().to_path_buf()),
pretty: false,
command: Command::Ping,
};
// run builds the client with config-key and calls ping
// Since OnspringClient builder does not hit network until execute,
// calling run will fail at runtime network request or succeed if network is mocked,
// but here run creates an OnspringClient and executes ping against real endpoint (network error).
let result = run(cli).await;
assert_eq!(result, Ok(()));
}
pub fn usage(message: impl Into<String>) -> Self {
Self {
code: 2,
message: message.into(),
#[tokio::test]
async fn run_when_empty_base_url_in_config_file_it_should_return_usage_error() {
let mut config_file = NamedTempFile::new().unwrap();
writeln!(
config_file,
r#"{{"apiKey":"config-key","baseUrl":" "}}"#
)
.unwrap();
let cli = Cli {
api_key: None,
base_url: None,
config: Some(config_file.path().to_path_buf()),
pretty: false,
command: Command::Ping,
};
let result = run(cli).await;
assert_eq!(result, Err(CliError::usage("Base URL cannot be empty.")));
}
#[tokio::test]
async fn run_when_empty_base_url_it_should_return_usage_error() {
let cli = Cli {
api_key: Some("test-api-key".to_string()),
base_url: Some(" ".to_string()),
config: None,
pretty: false,
command: Command::Ping,
};
let result = run(cli).await;
assert_eq!(result, Err(CliError::usage("Base URL cannot be empty.")));
}
#[tokio::test]
async fn run_when_config_file_has_invalid_json_it_should_return_runtime_error() {
let mut config_file = NamedTempFile::new().unwrap();
writeln!(config_file, "invalid-json").unwrap();
let cli = Cli {
api_key: None,
base_url: None,
config: Some(config_file.path().to_path_buf()),
pretty: false,
command: Command::Ping,
};
let result = run(cli).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, 1);
assert!(err.message.contains("Failed to parse config file"));
}
#[tokio::test]
async fn run_with_client_it_should_delegate_to_commands() {
let mock_client = client::testing::MockClient::default();
let cli = Cli {
api_key: Some("key".to_string()),
base_url: None,
config: None,
pretty: false,
command: Command::Ping,
};
let mut buffer = Vec::new();
let result = run_with_client(&cli, &mock_client, &mut buffer).await;
assert_eq!(result, Ok(()));
let written = String::from_utf8(buffer).unwrap();
assert_eq!(written.trim(), r#"{"ok":true}"#);
}
}
pub type CliResult<T> = Result<T, CliError>;
pub async fn run(_cli: Cli) -> CliResult<()> {
Ok(())
}
pub fn parse_cli_from<I, T>(args: I) -> CliResult<Cli>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
Cli::try_parse_from(args).map_err(|e| match e.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => CliError::info(e.to_string()),
_ => CliError::usage(e.to_string()),
})
}
pub fn render_cli_error(err: &CliError, pretty: bool) -> String {
render_error(err, pretty)
.unwrap_or_else(|_| "{\"error\":{\"code\":1,\"message\":\"Unexpected error.\"}}".to_string())
}
+371
View File
@@ -1,4 +1,13 @@
use std::io::Write;
use chrono::{DateTime, Utc};
use onspring::{
App, CollectionResponse, CreatedWithIdResponse, Field, FileInfo, FormulaOutputType,
ListFieldValue, Multiplicity, PagedResponse, Record, RecordFieldValue, ReportData, ReportInfo,
ReportRow, SaveListItemResponse, SaveRecordResponse, ValueType,
};
use serde::Serialize;
use uuid::Uuid;
use crate::{CliError, CliResult};
@@ -13,6 +22,301 @@ struct ErrorEnvelope<'a> {
pub error: ErrorBody<'a>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SuccessResponse {
pub ok: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PagedOutput<T> {
pub page_number: Option<i32>,
pub page_size: Option<i32>,
pub total_pages: Option<i32>,
pub total_records: Option<i32>,
pub items: Option<Vec<T>>,
}
impl<T, U> From<PagedResponse<U>> for PagedOutput<T>
where
T: From<U>,
{
fn from(value: PagedResponse<U>) -> Self {
Self {
page_number: value.page_number,
page_size: value.page_size,
total_pages: value.total_pages,
total_records: value.total_records,
items: value
.items
.map(|items| items.into_iter().map(T::from).collect()),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CollectionOutput<T> {
pub count: Option<i32>,
pub items: Option<Vec<T>>,
}
impl<T, U> From<CollectionResponse<U>> for CollectionOutput<T>
where
T: From<U>,
{
fn from(value: CollectionResponse<U>) -> Self {
Self {
count: value.count,
items: value
.items
.map(|items| items.into_iter().map(T::from).collect()),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppOutput {
pub href: Option<String>,
pub id: i32,
pub name: Option<String>,
}
impl From<App> for AppOutput {
fn from(value: App) -> Self {
Self {
href: value.href,
id: value.id,
name: value.name,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ListFieldValueOutput {
pub id: Uuid,
pub name: String,
pub sort_order: i32,
pub numeric_value: Option<f64>,
pub color: Option<String>,
}
impl From<ListFieldValue> for ListFieldValueOutput {
fn from(value: ListFieldValue) -> Self {
Self {
id: value.id,
name: value.name,
sort_order: value.sort_order,
numeric_value: value.numeric_value,
color: value.color,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldOutput {
pub id: i32,
pub app_id: i32,
pub name: Option<String>,
#[serde(rename = "type")]
pub field_type: Option<String>,
pub status: Option<String>,
pub is_required: bool,
pub is_unique: bool,
pub multiplicity: Option<Multiplicity>,
pub list_id: Option<i32>,
pub values: Option<Vec<ListFieldValueOutput>>,
pub output_type: Option<FormulaOutputType>,
pub referenced_app_id: Option<i32>,
}
impl From<Field> for FieldOutput {
fn from(value: Field) -> Self {
Self {
id: value.id,
app_id: value.app_id,
name: value.name,
field_type: value.field_type,
status: value.status,
is_required: value.is_required,
is_unique: value.is_unique,
multiplicity: value.multiplicity,
list_id: value.list_id,
values: value
.values
.map(|values| values.into_iter().map(ListFieldValueOutput::from).collect()),
output_type: value.output_type,
referenced_app_id: value.referenced_app_id,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordFieldValueOutput {
#[serde(rename = "type")]
pub value_type: ValueType,
pub field_id: i32,
pub value: serde_json::Value,
}
impl From<RecordFieldValue> for RecordFieldValueOutput {
fn from(value: RecordFieldValue) -> Self {
Self {
value_type: value.value_type,
field_id: value.field_id,
value: value.value,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordOutput {
pub app_id: i32,
pub record_id: i32,
pub field_data: Option<Vec<RecordFieldValueOutput>>,
}
impl From<Record> for RecordOutput {
fn from(value: Record) -> Self {
Self {
app_id: value.app_id,
record_id: value.record_id,
field_data: value
.field_data
.map(|fields| fields.into_iter().map(RecordFieldValueOutput::from).collect()),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveRecordResponseOutput {
pub id: i32,
pub warnings: Option<Vec<String>>,
}
impl From<SaveRecordResponse> for SaveRecordResponseOutput {
fn from(value: SaveRecordResponse) -> Self {
Self {
id: value.id,
warnings: value.warnings,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileInfoOutput {
#[serde(rename = "type")]
pub file_type: Option<String>,
pub content_type: Option<String>,
pub name: Option<String>,
pub created_date: Option<DateTime<Utc>>,
pub modified_date: Option<DateTime<Utc>>,
pub owner: Option<String>,
pub notes: Option<String>,
pub file_href: Option<String>,
}
impl From<FileInfo> for FileInfoOutput {
fn from(value: FileInfo) -> Self {
Self {
file_type: value.file_type,
content_type: value.content_type,
name: value.name,
created_date: value.created_date,
modified_date: value.modified_date,
owner: value.owner,
notes: value.notes,
file_href: value.file_href,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatedWithIdResponseOutput {
pub id: i32,
}
impl From<CreatedWithIdResponse> for CreatedWithIdResponseOutput {
fn from(value: CreatedWithIdResponse) -> Self {
Self { id: value.id }
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveListItemResponseOutput {
pub id: Uuid,
}
impl From<SaveListItemResponse> for SaveListItemResponseOutput {
fn from(value: SaveListItemResponse) -> Self {
Self { id: value.id }
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportInfoOutput {
pub app_id: i32,
pub id: i32,
pub name: Option<String>,
pub description: Option<String>,
}
impl From<ReportInfo> for ReportInfoOutput {
fn from(value: ReportInfo) -> Self {
Self {
app_id: value.app_id,
id: value.id,
name: value.name,
description: value.description,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportRowOutput {
pub record_id: Option<i32>,
pub cells: Option<Vec<serde_json::Value>>,
}
impl From<ReportRow> for ReportRowOutput {
fn from(value: ReportRow) -> Self {
Self {
record_id: value.record_id,
cells: value.cells,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportDataOutput {
pub columns: Option<Vec<String>>,
pub rows: Option<Vec<ReportRowOutput>>,
}
impl From<ReportData> for ReportDataOutput {
fn from(value: ReportData) -> Self {
Self {
columns: value.columns,
rows: value
.rows
.map(|rows| rows.into_iter().map(ReportRowOutput::from).collect()),
}
}
}
pub fn render_json<T: Serialize>(value: &T, pretty: bool) -> CliResult<String> {
if pretty {
serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output"))
@@ -31,3 +335,70 @@ pub fn render_error(err: &CliError, pretty: bool) -> CliResult<String> {
render_json(&envelope, pretty)
}
pub fn write_json<W: Write, T: Serialize>(
writer: &mut W,
value: &T,
pretty: bool,
) -> CliResult<()> {
let rendered = render_json(value, pretty)?;
writeln!(writer, "{rendered}").map_err(|_| CliError::runtime("Failed to write output"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn render_json_when_called_without_pretty_it_should_print_value() {
let data = json!({
"name": "Stevan",
});
let result = render_json(&data, false);
let expected = Ok(String::from(r#"{"name":"Stevan"}"#));
assert_eq!(result, expected);
}
#[test]
fn render_json_when_called_with_pretty_it_should_pretty_print_value() {
let data = json!({
"name": "Stevan",
});
let result = render_json(&data, true);
let expected = Ok(String::from("{\n \"name\": \"Stevan\"\n}"));
assert_eq!(result, expected);
}
#[test]
fn render_error_when_called_with_error_it_should_render_properly() {
let error = CliError {
code: 1,
message: String::from("This is a test"),
};
let result = render_error(&error, false);
let expected = Ok(String::from(
r#"{"error":{"code":1,"message":"This is a test"}}"#,
));
assert_eq!(result, expected);
}
#[test]
fn write_json_when_called_with_writer_it_should_write_formatted_string() {
let data = json!({"ok": true});
let mut buffer = Vec::new();
let result = write_json(&mut buffer, &data, false);
assert_eq!(result, Ok(()));
let output_str = String::from_utf8(buffer).unwrap();
assert_eq!(output_str, "{\"ok\":true}\n");
}
}
+372
View File
@@ -0,0 +1,372 @@
use std::io::Read;
use onspring::PagingRequest;
use serde::de::DeserializeOwned;
use crate::cli::{BodySource, PagingArgs};
use crate::error::{CliError, CliResult};
pub fn paging_request(args: &PagingArgs) -> CliResult<Option<PagingRequest>> {
if args.page_number.is_none() && args.page_size.is_none() {
return Ok(None);
}
let page_number = args.page_number.unwrap_or(1);
let page_size = args.page_size.unwrap_or(50);
if page_number <= 0 {
return Err(CliError::usage("page_number must be greater than 0."));
}
if page_size <= 0 {
return Err(CliError::usage("page_size must be greater than 0."));
}
Ok(Some(PagingRequest {
page_number,
page_size,
}))
}
pub fn validate_positive_i32(value: i32, field: &str) -> CliResult<()> {
if value <= 0 {
return Err(CliError::usage(format!("{field} must be greater than 0.")));
}
Ok(())
}
pub fn validate_ids(ids: &[i32], name: &str, max_len: Option<usize>) -> CliResult<()> {
if ids.is_empty() {
return Err(CliError::usage(format!("{name} cannot be empty.")));
}
if let Some(max_len) = max_len
&& ids.len() > max_len
{
return Err(CliError::usage(format!(
"{name} cannot contain more than {max_len} values."
)));
}
if ids.iter().any(|id| *id <= 0) {
return Err(CliError::usage(format!(
"{name} must only contain values > 0."
)));
}
Ok(())
}
pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<String> {
let count = [source.json.is_some(), source.file.is_some(), source.stdin]
.into_iter()
.filter(|&b| b)
.count();
if count == 0 {
return Err(CliError::usage(
"A request body is required. Provide --json, --file, or --stdin.",
));
}
if count > 1 {
return Err(CliError::usage(
"Only one request body source may be specified (--json, --file, or --stdin).",
));
}
if let Some(ref json) = source.json {
return Ok(json.clone());
}
if let Some(ref path) = source.file {
return std::fs::read_to_string(path)
.map_err(|e| CliError::usage(format!("Failed to read file '{}': {}", path.display(), e)));
}
if source.stdin {
let mut buffer = String::new();
reader
.read_to_string(&mut buffer)
.map_err(|e| CliError::runtime(format!("Failed to read from stdin: {e}")))?;
return Ok(buffer);
}
unreachable!()
}
pub fn parse_body<T: DeserializeOwned, R: Read>(source: &BodySource, reader: R) -> CliResult<T> {
let content = read_body(source, reader)?;
serde_json::from_str(&content)
.map_err(|e| CliError::usage(format!("Invalid JSON request body: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paging_request_when_no_args_it_should_return_none() {
let args = PagingArgs {
page_number: None,
page_size: None,
};
let result = paging_request(&args);
assert!(matches!(result, Ok(None)));
}
#[test]
fn paging_request_when_page_number_provided_it_should_default_page_size() {
let args = PagingArgs {
page_number: Some(3),
page_size: None,
};
let result = paging_request(&args).unwrap();
let paging = result.unwrap();
assert_eq!(paging.page_number, 3);
assert_eq!(paging.page_size, 50);
}
#[test]
fn paging_request_when_page_size_provided_it_should_default_page_number() {
let args = PagingArgs {
page_number: None,
page_size: Some(25),
};
let result = paging_request(&args).unwrap();
let paging = result.unwrap();
assert_eq!(paging.page_number, 1);
assert_eq!(paging.page_size, 25);
}
#[test]
fn paging_request_when_both_args_provided_it_should_return_paging_request() {
let args = PagingArgs {
page_number: Some(2),
page_size: Some(10),
};
let result = paging_request(&args).unwrap();
let paging = result.unwrap();
assert_eq!(paging.page_number, 2);
assert_eq!(paging.page_size, 10);
}
#[test]
fn paging_request_when_page_number_zero_or_negative_it_should_return_usage_error() {
let args_zero = PagingArgs {
page_number: Some(0),
page_size: Some(10),
};
assert_eq!(
paging_request(&args_zero).unwrap_err(),
CliError::usage("page_number must be greater than 0.")
);
let args_neg = PagingArgs {
page_number: Some(-1),
page_size: Some(10),
};
assert_eq!(
paging_request(&args_neg).unwrap_err(),
CliError::usage("page_number must be greater than 0.")
);
}
#[test]
fn paging_request_when_page_size_zero_or_negative_it_should_return_usage_error() {
let args_zero = PagingArgs {
page_number: Some(1),
page_size: Some(0),
};
assert_eq!(
paging_request(&args_zero).unwrap_err(),
CliError::usage("page_size must be greater than 0.")
);
let args_neg = PagingArgs {
page_number: Some(1),
page_size: Some(-5),
};
assert_eq!(
paging_request(&args_neg).unwrap_err(),
CliError::usage("page_size must be greater than 0.")
);
}
#[test]
fn validate_positive_i32_when_valid_it_should_return_ok() {
assert_eq!(validate_positive_i32(1, "app_id"), Ok(()));
assert_eq!(validate_positive_i32(100, "app_id"), Ok(()));
}
#[test]
fn validate_positive_i32_when_zero_or_negative_it_should_return_usage_error() {
assert_eq!(
validate_positive_i32(0, "app_id"),
Err(CliError::usage("app_id must be greater than 0."))
);
assert_eq!(
validate_positive_i32(-1, "app_id"),
Err(CliError::usage("app_id must be greater than 0."))
);
}
#[test]
fn validate_ids_when_valid_it_should_return_ok() {
assert_eq!(validate_ids(&[1, 2, 3], "ids", Some(100)), Ok(()));
}
#[test]
fn validate_ids_when_empty_it_should_return_usage_error() {
assert_eq!(
validate_ids(&[], "ids", Some(100)),
Err(CliError::usage("ids cannot be empty."))
);
}
#[test]
fn validate_ids_when_contains_zero_or_negative_it_should_return_usage_error() {
assert_eq!(
validate_ids(&[1, 0, 3], "ids", Some(100)),
Err(CliError::usage("ids must only contain values > 0."))
);
assert_eq!(
validate_ids(&[1, -2, 3], "ids", Some(100)),
Err(CliError::usage("ids must only contain values > 0."))
);
}
#[test]
fn validate_ids_when_exceeds_max_len_it_should_return_usage_error() {
let ids = vec![1; 101];
assert_eq!(
validate_ids(&ids, "ids", Some(100)),
Err(CliError::usage("ids cannot contain more than 100 values."))
);
}
#[test]
fn read_body_when_no_source_provided_it_should_return_usage_error() {
let source = BodySource::default();
let result = read_body(&source, std::io::empty());
assert_eq!(
result,
Err(CliError::usage(
"A request body is required. Provide --json, --file, or --stdin."
))
);
}
#[test]
fn read_body_when_multiple_sources_provided_it_should_return_usage_error() {
let source = BodySource {
json: Some("{}".to_string()),
stdin: true,
file: None,
};
let result = read_body(&source, std::io::empty());
assert_eq!(
result,
Err(CliError::usage(
"Only one request body source may be specified (--json, --file, or --stdin)."
))
);
}
#[test]
fn read_body_when_json_provided_it_should_return_json_string() {
let source = BodySource {
json: Some(r#"{"appId":1}"#.to_string()),
file: None,
stdin: false,
};
let result = read_body(&source, std::io::empty());
assert_eq!(result, Ok(r#"{"appId":1}"#.to_string()));
}
#[test]
fn read_body_when_stdin_provided_it_should_read_from_reader() {
let source = BodySource {
json: None,
file: None,
stdin: true,
};
let input = r#"{"appId":2}"#;
let result = read_body(&source, input.as_bytes());
assert_eq!(result, Ok(input.to_string()));
}
#[test]
fn read_body_when_file_provided_it_should_read_from_file() {
use std::io::Write;
let mut temp = tempfile::NamedTempFile::new().unwrap();
write!(temp, r#"{{"appId":3}}"#).unwrap();
let source = BodySource {
json: None,
file: Some(temp.path().to_path_buf()),
stdin: false,
};
let result = read_body(&source, std::io::empty());
assert_eq!(result, Ok(r#"{"appId":3}"#.to_string()));
}
#[test]
fn read_body_when_file_not_found_it_should_return_usage_error() {
let source = BodySource {
json: None,
file: Some(std::path::PathBuf::from("nonexistent_file_path_12345.json")),
stdin: false,
};
let result = read_body(&source, std::io::empty());
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, 2);
assert!(err.message.contains("Failed to read file"));
}
#[test]
fn parse_body_when_valid_it_should_deserialize() {
#[derive(Debug, PartialEq, serde::Deserialize)]
struct TestData {
app_id: i32,
}
let source = BodySource {
json: Some(r#"{"app_id":42}"#.to_string()),
file: None,
stdin: false,
};
let result: Result<TestData, _> = parse_body(&source, std::io::empty());
assert_eq!(result, Ok(TestData { app_id: 42 }));
}
#[test]
fn parse_body_when_invalid_json_it_should_return_usage_error() {
#[derive(Debug, PartialEq, serde::Deserialize)]
struct TestData {
app_id: i32,
}
let source = BodySource {
json: Some("invalid json".to_string()),
file: None,
stdin: false,
};
let result: Result<TestData, _> = parse_body(&source, std::io::empty());
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, 2);
assert!(err.message.contains("Invalid JSON request body"));
}
}