diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..3d0c2ee7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(rm -f ~/.claude/hooks/gsd-check-update-worker.js)", + "Read(//c/Users/jkuchta/.claude/hooks/**)" + ] + }, + "enabledMcpjsonServers": [ + "atlassian", + "figma-remote-mcp", + "slack", + "playwright", + "github", + "mcp-server-snowflake" + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..9967a76f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +tests/generator/golden/** text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..7386e73f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,43 @@ +--- +name: Report a bug specific to the Meraki Python library +about: > + If you've hit a snag and you're confident the issue is with the library itself, + e.g. not with the API or Meraki's servers, then report it here. Otherwise see the links below. +title: '' +labels: '' +assignees: '' + +--- + +**Describe how you confirmed the issue is with the library, and not with the API itself, or a server-side issue of some other kind.** +You can usually confirm it's an issue with the library itself (or with Python) if you've executed the same operations using other tools (e.g. Postman, or Python `requests` library) and if you only have the issue when using _this_ library. For example, if you can't reproduce it with Postman, then it might be a library issue. _On the other hand_, if you can reproduce it across platforms, then it's probably _not_ an issue with the library. In that case, please report the issue to Meraki support. + +**Python version installed** +Which specific Python version are you using? + +**Meraki library version installed** +Which specific version of the library are you using? + +**Have you reproduced the issue with the latest version of this library? And with the latest version of Python?** + +**OS Platform** +Which OS has the problem? E.g. Linux, Windows 10, macOS 14, etc. + +**Describe the bug** +A clear and concise description of what the bug is, and why it seems to be a bug. + +**How can we replicate the problem you're reporting?** +Please provide enough steps so that we can reproduce the issue. It helps if you can share a link to the endpoint's page on [the interactive docs site](https://developer.cisco.com/meraki/api-v1/#!api-reference-overview). **We and Meraki Support will never need or ask for your API key or dashboard login credentials, so please don't share them.** + +For example, to reproduce: +1. Invoke X endpoint with Y request attributes and/or Z query parameters. +2. Expect outcome A. + +**Expected behavior** +A clear and concise description of what you expected to happen instead, and why. + +**Code snippets** +If applicable, add code snippets to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..28941a36 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,20 @@ +blank_issues_enabled: false +contact_links: + - name: I noticed an API operation is missing from the library + url: https://github.com/meraki/dashboard-api-python/tree/main/generator + about: > + The official library includes GA operations and excludes those that are Early Access (e.g., beta). To generate a local version of the library that includes Early Access operations, please follow these instructions. + - name: Meraki Interactive API documentation + url: https://developer.cisco.com/meraki/api-v1/#!api-reference-overview + about: > + The docs are a great first step if you have questions about how specific API endpoints are expected to work. + Find response schemas and even test out API calls in a demo environment, or with your own API token. + - name: Meraki Community Developers & APIs forum + url: https://community.meraki.com/t5/Developers-APIs/bd-p/api + about: > + The best place to ask questions about the Python library or Meraki APIs in general. How do I?-type questions + go here. + - name: Meraki Support + url: https://meraki.cisco.com/meraki-support/overview/ + about: > + For any critical issues with dashboard APIs or documentation site, please contact Meraki Support. \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..deac9726 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "daily" + groups: + all-deps: + patterns: + - "*" + - package-ecosystem: "uv" + directory: "/" + target-branch: "beta" + schedule: + interval: "daily" + groups: + all-deps: + patterns: + - "*" + - package-ecosystem: "uv" + directory: "/" + target-branch: "httpx" + schedule: + interval: "daily" + groups: + all-deps: + patterns: + - "*" \ No newline at end of file diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 00000000..26c511b1 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,104 @@ +name: Create GitHub Release + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to release from' + required: true + default: 'beta' + workflow_run: + workflows: ['Test PR'] + types: [completed] + branches: ['main', 'beta', 'httpx'] + +# The release is created with a GitHub App token. This workflow no longer +# commits anything back to the branch (the changelog is rendered in the +# regeneration PR), so the default token only needs read access for checkout. +permissions: + contents: read + +jobs: + create-release: + if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.event.workflow_run.head_branch }} + + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Read version + id: version + run: | + VERSION=$(grep '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + if [[ "$VERSION" == *"b"* || "$VERSION" == *"rc"* || "$VERSION" == *"a"* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Check if release already exists + id: check-release + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${{ steps.version.outputs.version }}" + if gh release view "$VERSION" > /dev/null 2>&1; then + echo "Release $VERSION already exists, skipping." + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Validate version matches branch + if: steps.check-release.outputs.exists == 'false' + run: | + BRANCH="${{ inputs.branch || github.event.workflow_run.head_branch }}" + VERSION="${{ steps.version.outputs.version }}" + PRERELEASE="${{ steps.version.outputs.prerelease }}" + + if [[ "$BRANCH" == "beta" && "$PRERELEASE" == "false" ]]; then + echo "::error::Beta branch must have a prerelease version (e.g. 3.1.0b0), got '$VERSION'" + exit 1 + fi + + if [[ "$BRANCH" == "httpx" && "$PRERELEASE" == "false" ]]; then + echo "::error::httpx branch must have a prerelease version (e.g. 4.0.0b1), got '$VERSION'" + exit 1 + fi + + if [[ "$BRANCH" == "main" && "$PRERELEASE" == "true" ]]; then + echo "::error::Main branch must not have a prerelease version, got '$VERSION'" + exit 1 + fi + + # CHANGELOG.md is rendered from changelog.d/ fragments inside the + # regeneration PR (see regenerate-library.yml), so it is already on the + # branch by the time the release is cut. Committing/pushing here would + # bypass the branch's required status checks and be rejected by the + # ruleset, so no git write happens in this workflow. --generate-notes + # below produces the GitHub release body. + - name: Create release + if: steps.check-release.outputs.exists == 'false' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + FLAGS="" + if [ "${{ steps.version.outputs.prerelease }}" = "true" ]; then + FLAGS="--prerelease" + fi + + gh release create "${{ steps.version.outputs.version }}" \ + --target "${{ inputs.branch || github.event.workflow_run.head_branch }}" \ + --title "${{ steps.version.outputs.version }}" \ + --generate-notes \ + $FLAGS diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 00000000..f9154948 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,28 @@ +name: Auto-merge Dependabot PRs +on: pull_request_target + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Fetch Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + # Only auto-merge patch/minor bumps and dev-dependency updates. Major + # bumps and direct production-dependency changes still require human review. + - name: Enable auto-merge for safe updates + if: >- + steps.meta.outputs.update-type == 'version-update:semver-patch' || + steps.meta.outputs.update-type == 'version-update:semver-minor' || + steps.meta.outputs.dependency-type == 'direct:development' + run: gh pr merge --squash --auto "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/enable-early-access.yml b/.github/workflows/enable-early-access.yml new file mode 100644 index 00000000..d5fc84c6 --- /dev/null +++ b/.github/workflows/enable-early-access.yml @@ -0,0 +1,36 @@ +name: Enable early access features + +on: + workflow_dispatch: + schedule: + - cron: '0 18 * * 3' + +jobs: + enable-early-access: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --python 3.13 + + - name: Enable all early access features + env: + MERAKI_DASHBOARD_API_KEY: ${{ secrets.TEST_ORG_API_KEY }} + EA_ORG_0: ${{ secrets.TEST_ORG_BETA_00_ID }} + EA_ORG_1: ${{ secrets.TEST_ORG_BETA_01_ID }} + EA_ORG_2: ${{ secrets.TEST_ORG_BETA_02_ID }} + EA_ORG_3: ${{ secrets.TEST_ORG_BETA_03_ID }} + EA_ORG_4: ${{ secrets.TEST_ORG_DEV_00_ID }} + EA_ORG_5: ${{ secrets.TEST_ORG_DEV_01_ID }} + EA_ORG_6: ${{ secrets.TEST_ORG_DEV_02_ID }} + EA_ORG_7: ${{ secrets.TEST_ORG_DEV_03_ID }} + run: uv run python examples/ci/enable_all_early_access.py diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 00000000..fabf50c7 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,34 @@ +name: Publish release to PyPI +on: + release: + types: [created] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish (e.g. 3.1.0b0)' + required: true + +permissions: + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.event.release.tag_name }} + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Build package + run: uv build + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/regenerate-library.yml b/.github/workflows/regenerate-library.yml new file mode 100644 index 00000000..09cf332d --- /dev/null +++ b/.github/workflows/regenerate-library.yml @@ -0,0 +1,145 @@ +name: Regenerate Python Library +on: + workflow_dispatch: + inputs: + library_version: + description: 'The version of the new library' + required: true + api_version: + description: 'The corresponding version of the API' + required: true + release_stage: + description: 'Release stage' + required: true + type: choice + options: + - ga + - beta + - dev + +# Writes (commit, PR, auto-merge) are performed with a GitHub App token, not +# GITHUB_TOKEN, so the default token only needs read access for checkout. +permissions: + contents: read + +jobs: + regenerate: + runs-on: ubuntu-latest + steps: + - name: Resolve branches + id: branches + run: | + case "${{ github.event.inputs.release_stage }}" in + dev) + echo "checkout_branch=httpx" >> "$GITHUB_OUTPUT" + echo "release_branch=httpx-release" >> "$GITHUB_OUTPUT" + echo "pr_base=httpx" >> "$GITHUB_OUTPUT" + ;; + beta) + echo "checkout_branch=beta" >> "$GITHUB_OUTPUT" + echo "release_branch=beta-release" >> "$GITHUB_OUTPUT" + echo "pr_base=beta" >> "$GITHUB_OUTPUT" + ;; + ga) + echo "checkout_branch=main" >> "$GITHUB_OUTPUT" + echo "release_branch=release" >> "$GITHUB_OUTPUT" + echo "pr_base=main" >> "$GITHUB_OUTPUT" + ;; + esac + + # Generate the App token before checkout so checkout persists *it* (not the + # read-only GITHUB_TOKEN) as the git credential used for the later push. + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + ref: ${{ steps.branches.outputs.checkout_branch }} + # Persist the App token (write access) so EndBug/add-and-commit can push. + # add-and-commit does NOT set up push auth from its github_token input — + # it relies on whatever credential checkout wrote to git config. + token: ${{ steps.app-token.outputs.token }} + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --group generator + + # Preserve the checked-out non-generated sources, then wipe meraki/ so the + # generator only regenerates the API modules. The generator reads the + # preserved copy from disk (MERAKI_SOURCE_DIR) instead of fetching them + # from raw.githubusercontent, which rate-limits (429s) the shared runner IP. + - name: Stash non-generated sources + run: | + cp -r meraki "$RUNNER_TEMP/meraki-src" + rm -rf meraki + + - name: Regenerate Python Library + env: + LIBRARY_VERSION: ${{ github.event.inputs.library_version }} + API_VERSION: ${{ github.event.inputs.api_version }} + MERAKI_SOURCE_DIR: ${{ runner.temp }}/meraki-src + MERAKI_DASHBOARD_API_KEY: ${{ (github.event.inputs.release_stage == 'beta' || github.event.inputs.release_stage == 'dev') && secrets.TEST_ORG_API_KEY || '' }} + BETA_ORG_ID: ${{ (github.event.inputs.release_stage == 'beta' || github.event.inputs.release_stage == 'dev') && secrets.TEST_ORG_BETA_00_ID || '' }} + run: | + ARGS="-g true -l -v $LIBRARY_VERSION -a $API_VERSION" + if [ -n "$BETA_ORG_ID" ]; then + ARGS="$ARGS -o $BETA_ORG_ID" + fi + uv run python generator/generate_library.py $ARGS + + - name: Set new version + env: + LIBRARY_VERSION: ${{ github.event.inputs.library_version }} + run: | + sed -i "s/^version = \".*\"/version = \"$LIBRARY_VERSION\"/" pyproject.toml + uv lock + + # Render changelog.d/ fragments into CHANGELOG.md here so the update rides + # the regeneration PR (and its required checks) instead of being pushed + # straight to a protected branch later. towncrier is in the dev group, so + # `uv sync --group generator` (default-groups = dev) already installed it. + - name: Build changelog + env: + LIBRARY_VERSION: ${{ github.event.inputs.library_version }} + run: | + if ls changelog.d/*.md >/dev/null 2>&1; then + uv run towncrier build --yes --version "$LIBRARY_VERSION" + else + echo "No changelog fragments; skipping towncrier build." + fi + + - name: Commit changes to new branch + uses: EndBug/add-and-commit@v10 + with: + author_name: GitHub Action + author_email: support@meraki.com + message: Auto-generated library v${{ github.event.inputs.library_version }} for API v${{ github.event.inputs.api_version }}. + new_branch: ${{ steps.branches.outputs.release_branch }} + + - name: Create pull request + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr create \ + --base "${{ steps.branches.outputs.pr_base }}" \ + --head "${{ steps.branches.outputs.release_branch }}" \ + --title "Release v${{ github.event.inputs.library_version }} (API v${{ github.event.inputs.api_version }})" \ + --body "Auto-generated library v${{ github.event.inputs.library_version }} for API v${{ github.event.inputs.api_version }}." + + - name: Enable auto-merge + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr merge "${{ steps.branches.outputs.release_branch }}" \ + --auto --squash diff --git a/.github/workflows/test-library-generator.yml b/.github/workflows/test-library-generator.yml new file mode 100644 index 00000000..43242b38 --- /dev/null +++ b/.github/workflows/test-library-generator.yml @@ -0,0 +1,64 @@ +name: Test Python library generator + +on: + push: + branches: ['main', 'release'] + paths: + - 'generator/**' + - 'tests/generator/**' + - 'pyproject.toml' + - 'uv.lock' + pull_request: + branches: ['main', 'release'] + paths: + - 'generator/**' + - 'tests/generator/**' + - 'pyproject.toml' + - 'uv.lock' + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --python 3.13 + + - name: Lint with ruff + run: | + uv run ruff check generator/ + uv run ruff format --check generator/ + + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --python 3.13 --group generator + + - name: Test generator + run: uv run pytest tests/generator/ -v diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml new file mode 100644 index 00000000..0b5a0e97 --- /dev/null +++ b/.github/workflows/test-library.yml @@ -0,0 +1,210 @@ +name: Test Python library + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --python 3.13 + + - name: Lint with ruff + run: | + uv run ruff check meraki/ tests/unit/ tests/integration/ tests/benchmarks/ + uv run ruff format --check meraki/ tests/unit/ tests/integration/ tests/benchmarks/ + + unit-test: + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --python ${{ matrix.python-version }} + + - name: Unit tests with coverage + run: uv run pytest tests/unit --cov --cov-report=term-missing --cov-fail-under=90 + + assign-orgs: + runs-on: ubuntu-latest + outputs: + assignments: ${{ steps.shuffle.outputs.assignments }} + steps: + - name: Shuffle org assignments + id: shuffle + run: | + python3 -c " + import json, random + versions = ['3.11', '3.12', '3.13', '3.14'] + indices = list(range(4)) + random.shuffle(indices) + assignments = dict(zip(versions, indices)) + print(f'assignments={json.dumps(assignments)}') + " >> "$GITHUB_OUTPUT" + + integration-test: + needs: [assign-orgs] + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + strategy: + fail-fast: true + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --python ${{ matrix.python-version }} + + - name: Integration tests + env: + ORG_GA_0: ${{ secrets.TEST_ORG_GA_00_ID }} + ORG_GA_1: ${{ secrets.TEST_ORG_GA_01_ID }} + ORG_GA_2: ${{ secrets.TEST_ORG_GA_02_ID }} + ORG_GA_3: ${{ secrets.TEST_ORG_GA_03_ID }} + ORG_BETA_0: ${{ secrets.TEST_ORG_BETA_00_ID }} + ORG_BETA_1: ${{ secrets.TEST_ORG_BETA_01_ID }} + ORG_BETA_2: ${{ secrets.TEST_ORG_BETA_02_ID }} + ORG_BETA_3: ${{ secrets.TEST_ORG_BETA_03_ID }} + ORG_DEV_0: ${{ secrets.TEST_ORG_DEV_00_ID }} + ORG_DEV_1: ${{ secrets.TEST_ORG_DEV_01_ID }} + ORG_DEV_2: ${{ secrets.TEST_ORG_DEV_02_ID }} + ORG_DEV_3: ${{ secrets.TEST_ORG_DEV_03_ID }} + run: | + BRANCH="${{ github.head_ref || github.ref_name }}" + BASE="${{ github.base_ref }}" + case "$BRANCH" in + main|release) PREFIX="GA" ;; + beta|beta-release) PREFIX="BETA" ;; + httpx|httpx-release) PREFIX="DEV" ;; + *) + case "$BASE" in + main) PREFIX="GA" ;; + beta) PREFIX="BETA" ;; + httpx) PREFIX="DEV" ;; + *) echo "Unknown branch: $BRANCH (base: $BASE)" && exit 1 ;; + esac + ;; + esac + INDEX=$(echo '${{ needs.assign-orgs.outputs.assignments }}' | jq -r '.["${{ matrix.python-version }}"]') + VAR_NAME="ORG_${PREFIX}_${INDEX}" + ORG_ID="${!VAR_NAME}" + uv run pytest tests/integration -v --tb=short --apikey ${{ secrets.TEST_ORG_API_KEY }} --o "$ORG_ID" + + - name: Validate against baseline + env: + ORG_GA_0: ${{ secrets.TEST_ORG_GA_00_ID }} + ORG_GA_1: ${{ secrets.TEST_ORG_GA_01_ID }} + ORG_GA_2: ${{ secrets.TEST_ORG_GA_02_ID }} + ORG_GA_3: ${{ secrets.TEST_ORG_GA_03_ID }} + ORG_BETA_0: ${{ secrets.TEST_ORG_BETA_00_ID }} + ORG_BETA_1: ${{ secrets.TEST_ORG_BETA_01_ID }} + ORG_BETA_2: ${{ secrets.TEST_ORG_BETA_02_ID }} + ORG_BETA_3: ${{ secrets.TEST_ORG_BETA_03_ID }} + ORG_DEV_0: ${{ secrets.TEST_ORG_DEV_00_ID }} + ORG_DEV_1: ${{ secrets.TEST_ORG_DEV_01_ID }} + ORG_DEV_2: ${{ secrets.TEST_ORG_DEV_02_ID }} + ORG_DEV_3: ${{ secrets.TEST_ORG_DEV_03_ID }} + run: | + BRANCH="${{ github.head_ref || github.ref_name }}" + BASE="${{ github.base_ref }}" + case "$BRANCH" in + main|release) PREFIX="GA" ;; + beta|beta-release) PREFIX="BETA" ;; + httpx|httpx-release) PREFIX="DEV" ;; + *) + case "$BASE" in + main) PREFIX="GA" ;; + beta) PREFIX="BETA" ;; + httpx) PREFIX="DEV" ;; + *) echo "Unknown branch: $BRANCH (base: $BASE)" && exit 1 ;; + esac + ;; + esac + INDEX=$(echo '${{ needs.assign-orgs.outputs.assignments }}' | jq -r '.["${{ matrix.python-version }}"]') + VAR_NAME="ORG_${PREFIX}_${INDEX}" + ORG_ID="${!VAR_NAME}" + if [ -z "$ORG_ID" ]; then + echo "::error::No org id resolved for ${VAR_NAME}; cannot validate baseline" + exit 1 + fi + EXPECTED_TOTAL=32 + ACTUAL=$(uv run pytest tests/integration --co -q --apikey ${{ secrets.TEST_ORG_API_KEY }} --o "$ORG_ID" 2>/dev/null | grep -oP '^\d+(?= tests? collected)' | tail -1) + ACTUAL="${ACTUAL:-0}" + if ! [[ "$ACTUAL" =~ ^[0-9]+$ ]]; then + echo "::error::Could not parse collected test count (got '$ACTUAL')" + exit 1 + fi + echo "Collected tests: $ACTUAL (baseline: $EXPECTED_TOTAL)" + if [ "$ACTUAL" -lt "$EXPECTED_TOTAL" ]; then + echo "::error::Regression: collected $ACTUAL tests, baseline expects $EXPECTED_TOTAL" + exit 1 + fi + + benchmark: + runs-on: ubuntu-latest + needs: [unit-test] + strategy: + fail-fast: true + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --python ${{ matrix.python-version }} + + - name: Run benchmarks + run: uv run pytest tests/benchmarks --benchmark-json=benchmark-${{ matrix.python-version }}.json + + - name: Upload benchmark results + uses: actions/upload-artifact@v7 + with: + name: benchmark-py${{ matrix.python-version }} + path: benchmark-${{ matrix.python-version }}.json diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml new file mode 100644 index 00000000..1783e1b9 --- /dev/null +++ b/.github/workflows/test-pr.yml @@ -0,0 +1,27 @@ +name: Test PR + +on: + push: + branches: ['main', 'beta', 'httpx'] + paths: + - 'meraki/**' + - 'tests/unit/**' + - 'tests/integration/**' + - 'pyproject.toml' + - 'uv.lock' + pull_request: + branches: ['main', 'beta', 'httpx'] + paths: + - 'meraki/**' + - 'tests/unit/**' + - 'tests/integration/**' + - 'pyproject.toml' + - 'uv.lock' + +permissions: + contents: read + +jobs: + test: + uses: ./.github/workflows/test-library.yml + secrets: inherit diff --git a/.github/workflows/watch-openapi-release.yml b/.github/workflows/watch-openapi-release.yml new file mode 100644 index 00000000..70ffd7d6 --- /dev/null +++ b/.github/workflows/watch-openapi-release.yml @@ -0,0 +1,292 @@ +name: Watch OpenAPI Releases + +on: + schedule: + - cron: '0 14 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-ga: + runs-on: ubuntu-latest + outputs: + has_new_release: ${{ steps.check.outputs.has_new_release }} + api_version: ${{ steps.check.outputs.api_version }} + new_sdk_version: ${{ steps.version.outputs.new_sdk_version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get latest GA release + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Retry: api.github.com occasionally 401s a job's freshly-minted token. + for attempt in 1 2 3 4 5; do + if LATEST=$(gh release list --repo meraki/openapi --exclude-pre-releases --limit 1 --json tagName -q '.[0].tagName'); then + break + fi + if [ "$attempt" = 5 ]; then + echo "::error::gh release list failed after 5 attempts" + exit 1 + fi + echo "::warning::gh release list failed (attempt $attempt/5); retrying in $((attempt * 3))s" + sleep $((attempt * 3)) + done + API_VERSION="${LATEST#v}" + # Reject anything that isn't a clean semver tag before it flows into + # downstream run: steps / workflow_dispatch inputs (script injection guard). + if ! [[ "$API_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Unexpected OpenAPI release tag: '$LATEST'" + exit 1 + fi + echo "api_version=$API_VERSION" >> "$GITHUB_OUTPUT" + + LAST_PROCESSED="${{ vars.LAST_OPENAPI_GA_RELEASE }}" + if [ "$LATEST" = "$LAST_PROCESSED" ]; then + echo "has_new_release=false" >> "$GITHUB_OUTPUT" + else + echo "has_new_release=true" >> "$GITHUB_OUTPUT" + fi + + - name: Compute next SDK version + if: steps.check.outputs.has_new_release == 'true' + id: version + run: | + CURRENT=$(git show "origin/main:pyproject.toml" | grep '^version' | sed 's/version = "\(.*\)"/\1/') + BASE=$(echo "$CURRENT" | sed 's/\(a\|b\|rc\).*//') + IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE" + NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" + echo "new_sdk_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + + check-beta: + runs-on: ubuntu-latest + outputs: + has_new_release: ${{ steps.check.outputs.has_new_release }} + api_version: ${{ steps.check.outputs.api_version }} + new_sdk_version: ${{ steps.version.outputs.new_sdk_version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get latest beta release + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Retry: api.github.com occasionally 401s a job's freshly-minted token. + for attempt in 1 2 3 4 5; do + if LATEST=$(gh release list --repo meraki/openapi --limit 20 --json tagName,isPrerelease -q '[.[] | select(.isPrerelease)] | .[0].tagName'); then + break + fi + if [ "$attempt" = 5 ]; then + echo "::error::gh release list failed after 5 attempts" + exit 1 + fi + echo "::warning::gh release list failed (attempt $attempt/5); retrying in $((attempt * 3))s" + sleep $((attempt * 3)) + done + if [ -z "$LATEST" ]; then + echo "has_new_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + API_VERSION="${LATEST#v}" + # Reject anything that isn't a clean semver tag before it flows into + # downstream run: steps / workflow_dispatch inputs (script injection guard). + if ! [[ "$API_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Unexpected OpenAPI release tag: '$LATEST'" + exit 1 + fi + echo "api_version=$API_VERSION" >> "$GITHUB_OUTPUT" + + LAST_PROCESSED="${{ vars.LAST_OPENAPI_BETA_RELEASE }}" + if [ "$LATEST" = "$LAST_PROCESSED" ]; then + echo "has_new_release=false" >> "$GITHUB_OUTPUT" + else + echo "has_new_release=true" >> "$GITHUB_OUTPUT" + fi + + - name: Compute next SDK version + if: steps.check.outputs.has_new_release == 'true' + id: version + env: + API_VERSION: ${{ steps.check.outputs.api_version }} + run: | + CURRENT=$(git show "origin/beta:pyproject.toml" | grep '^version' | sed 's/version = "\(.*\)"/\1/') + + # Parse API version: e.g. "1.81.2-beta.3" -> minor=81, patch=2, beta=3 + API_MINOR=$(echo "$API_VERSION" | cut -d. -f2) + API_PATCH=$(echo "$API_VERSION" | cut -d. -f3 | sed 's/-.*//') + API_BETA=$(echo "$API_VERSION" | sed 's/.*beta\.\?//') + + LAST_TAG="${{ vars.LAST_OPENAPI_BETA_RELEASE }}" + LAST_API_MINOR=$(echo "$LAST_TAG" | sed 's/^v//' | cut -d. -f2) + + BASE=$(echo "$CURRENT" | sed 's/\(a\|b\|rc\).*//') + IFS='.' read -r MAJOR LIB_MINOR LIB_PATCH <<< "$BASE" + + if [ "$API_MINOR" != "$LAST_API_MINOR" ]; then + LIB_MINOR=$((LIB_MINOR + 1)) + fi + + NEW_VERSION="${MAJOR}.${LIB_MINOR}.${API_PATCH}b${API_BETA}" + echo "new_sdk_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + + check-dev: + runs-on: ubuntu-latest + outputs: + has_new_release: ${{ steps.check.outputs.has_new_release }} + api_version: ${{ steps.check.outputs.api_version }} + new_sdk_version: ${{ steps.version.outputs.new_sdk_version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get latest beta release + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Retry: api.github.com occasionally 401s a job's freshly-minted token. + for attempt in 1 2 3 4 5; do + if LATEST=$(gh release list --repo meraki/openapi --limit 20 --json tagName,isPrerelease -q '[.[] | select(.isPrerelease)] | .[0].tagName'); then + break + fi + if [ "$attempt" = 5 ]; then + echo "::error::gh release list failed after 5 attempts" + exit 1 + fi + echo "::warning::gh release list failed (attempt $attempt/5); retrying in $((attempt * 3))s" + sleep $((attempt * 3)) + done + if [ -z "$LATEST" ]; then + echo "has_new_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + API_VERSION="${LATEST#v}" + # Reject anything that isn't a clean semver tag before it flows into + # downstream run: steps / workflow_dispatch inputs (script injection guard). + if ! [[ "$API_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Unexpected OpenAPI release tag: '$LATEST'" + exit 1 + fi + echo "api_version=$API_VERSION" >> "$GITHUB_OUTPUT" + + LAST_PROCESSED="${{ vars.LAST_OPENAPI_DEV_RELEASE }}" + if [ "$LATEST" = "$LAST_PROCESSED" ]; then + echo "has_new_release=false" >> "$GITHUB_OUTPUT" + else + echo "has_new_release=true" >> "$GITHUB_OUTPUT" + fi + + - name: Compute next SDK version + if: steps.check.outputs.has_new_release == 'true' + id: version + env: + API_VERSION: ${{ steps.check.outputs.api_version }} + run: | + CURRENT=$(git show "origin/httpx:pyproject.toml" | grep '^version' | sed 's/version = "\(.*\)"/\1/') + + API_MINOR=$(echo "$API_VERSION" | cut -d. -f2) + API_PATCH=$(echo "$API_VERSION" | cut -d. -f3 | sed 's/-.*//') + API_BETA=$(echo "$API_VERSION" | sed 's/.*beta\.\?//') + + LAST_TAG="${{ vars.LAST_OPENAPI_DEV_RELEASE }}" + LAST_API_MINOR=$(echo "$LAST_TAG" | sed 's/^v//' | cut -d. -f2) + + BASE=$(echo "$CURRENT" | sed 's/\(a\|b\|rc\).*//') + IFS='.' read -r MAJOR LIB_MINOR LIB_PATCH <<< "$BASE" + + if [ "$API_MINOR" != "$LAST_API_MINOR" ]; then + LIB_MINOR=$((LIB_MINOR + 1)) + fi + + NEW_VERSION="${MAJOR}.${LIB_MINOR}.${API_PATCH}b${API_BETA}" + echo "new_sdk_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + + trigger: + needs: [check-ga, check-beta, check-dev] + if: needs.check-ga.outputs.has_new_release == 'true' || needs.check-beta.outputs.has_new_release == 'true' || needs.check-dev.outputs.has_new_release == 'true' + runs-on: ubuntu-latest + env: + GH_REPO: ${{ github.repository }} + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Record processed releases + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ "${{ needs.check-ga.outputs.has_new_release }}" = "true" ]; then + gh variable set LAST_OPENAPI_GA_RELEASE --body "v${{ needs.check-ga.outputs.api_version }}" + fi + + if [ "${{ needs.check-beta.outputs.has_new_release }}" = "true" ]; then + gh variable set LAST_OPENAPI_BETA_RELEASE --body "v${{ needs.check-beta.outputs.api_version }}" + fi + + if [ "${{ needs.check-dev.outputs.has_new_release }}" = "true" ]; then + gh variable set LAST_OPENAPI_DEV_RELEASE --body "v${{ needs.check-dev.outputs.api_version }}" + fi + + - name: Enable early access + if: needs.check-beta.outputs.has_new_release == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + BEFORE_ID=$(gh run list --workflow=enable-early-access.yml --limit=1 --json databaseId -q '.[0].databaseId') + gh workflow run enable-early-access.yml + + for i in $(seq 1 30); do + sleep 10 + NEW_ID=$(gh run list --workflow=enable-early-access.yml --limit=1 --json databaseId -q '.[0].databaseId') + if [ "$NEW_ID" != "$BEFORE_ID" ]; then + gh run watch "$NEW_ID" + exit 0 + fi + done + echo "Timed out waiting for enable-early-access run to appear" + exit 1 + + - name: Trigger GA regeneration + if: needs.check-ga.outputs.has_new_release == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh workflow run regenerate-library.yml \ + -f library_version="${{ needs.check-ga.outputs.new_sdk_version }}" \ + -f api_version="${{ needs.check-ga.outputs.api_version }}" \ + -f release_stage="ga" + + - name: Trigger beta regeneration + if: needs.check-beta.outputs.has_new_release == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh workflow run regenerate-library.yml \ + -f library_version="${{ needs.check-beta.outputs.new_sdk_version }}" \ + -f api_version="${{ needs.check-beta.outputs.api_version }}" \ + -f release_stage="beta" + + - name: Trigger dev regeneration + if: needs.check-dev.outputs.has_new_release == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh workflow run regenerate-library.yml \ + -f library_version="${{ needs.check-dev.outputs.new_sdk_version }}" \ + -f api_version="${{ needs.check-dev.outputs.api_version }}" \ + -f release_stage="dev" diff --git a/.gitignore b/.gitignore index 61495a87..9a530e89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,26 @@ -.DS_Store -# No PyCharm config files -.idea/ -venv/ -__pycache__ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ + +# Virtual environments +venv/ +.venv/ + +# Testing +.pytest_cache/ +.coverage + +# IDEs +.idea/ +.vscode/ + +# OS +.DS_Store + +# Project +.scratch/ + +# Git worktrees +.worktrees/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..67a0a637 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.2 + hooks: + - id: ruff-format + exclude: ^(meraki/(aio/api|api/batch|api)/|notebooks/|generator/generate_snippets\.py) + - id: ruff + args: [--fix] + exclude: ^(meraki/(aio/api|api/batch|api)/|notebooks/|generator/generate_snippets\.py) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a169e9b7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this library are documented here. + +This file is maintained with [towncrier](https://towncrier.readthedocs.io/); +add a news fragment under `changelog.d/` for every user-facing change. See +[CONTRIBUTING.md](CONTRIBUTING.md) for the fragment format. + + + +## 4.3.0 (2026-07-09) + +### Added + +- On 5xx responses, the SDK now logs the Meraki `X-Request-Id` response header so it can be shared with Meraki to look up the request in server-side logs. If the header is absent, `none` is logged in its place. After retries are exhausted, the request ID is also logged at error level. + + +## 4.2.0b1 (2026-06-10) + +### Changed + +- Migrated the HTTP transport layer from `requests`/`aiohttp` to `httpx`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..f208d604 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# Contributing + +Meraki welcomes constructive pull requests that maintain backwards compatibility with prior versions. + +## Setup + +```bash +# Clone and install dev dependencies +git clone https://github.com/meraki/dashboard-api-python.git +cd dashboard-api-python +uv sync +``` + +## Development Workflow + +1. Create a feature branch from `main` +2. Make your changes +3. Run tests: `uv run pytest tests/unit` +4. Run linting: `uv run ruff check . && uv run ruff format --check .` +5. Open a pull request against `main` + +## Code Standards + +- Line length: 127 characters +- Formatter: ruff format +- Linter: ruff + flake8 +- Test coverage floor: 90% (core, non-generated code) +- Python versions: 3.11+ + +## What to Contribute + +- Bug fixes with regression tests +- Documentation improvements +- Test coverage improvements for non-generated code +- Performance improvements with benchmarks + +## What Not to Modify + +- Generated API scope files (`meraki/api/`, `meraki/aio/api/`) are auto-generated from the OpenAPI spec. Changes here will be overwritten. Fix the generator instead. +- Do not vendor or bundle dependencies. + +## Changelog Fragments + +Every user-facing change needs a news fragment in `changelog.d/`. Do not edit `CHANGELOG.md` by hand; towncrier builds it at release time. + +File name: `changelog.d/{issue}.{type}.md`. With no issue, use a slug prefixed with `+`: `changelog.d/+httpx-migration.changed.md`. + +Types: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`. + +```bash +# Issue-linked +echo "Retry on 429 now honors Retry-After." > changelog.d/1234.fixed.md + +# Or via towncrier +uv run towncrier create -c "Retry on 429 now honors Retry-After." 1234.fixed.md + +# Preview the rendered changelog without consuming fragments +uv run towncrier build --draft --version "$(grep '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/')" +``` + +One fragment per change. Multiple fragments per issue are fine (e.g. `1234.added.md` and `1234.fixed.md`). Fragments are not wiped by library regeneration; they survive until the next release build. + +## Running the Generator + +```bash +uv sync --group generator +uv run python generator/generate_library.py +``` + +## Questions + +- GitHub Issues: bug reports and feature requests +- [Meraki Community](https://community.meraki.com/): general discussion +- [api-feedback@meraki.net](mailto:api-feedback@meraki.net): direct contact diff --git a/README.md b/README.md index 0e6f390e..c7f5a2ab 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,256 @@ # Meraki Dashboard API Python Library -The Meraki Dashboard API Python library provides all current Meraki [Dashboard API](https://developer.cisco.com/docs/meraki-api-v1) calls to interface with the Cisco Meraki cloud-managed platform. The library is supported on Python 3.6 or above, and you can install it via [PyPI](https://pypi.org/project/meraki/): +A Python client for the Cisco Meraki [dashboard API](https://developer.cisco.com/meraki/api-v1/), covering every +current operation. It's generated from the API's OpenAPI spec, so it tracks the latest releases automatically. The full +source, including the generator, is here for Early Access participants and contributors; pull requests that maintain +backwards compatibility are welcome. Requires Python 3.10+, community-supported, installable via +[PyPI](https://pypi.org/project/meraki/): - pip install meraki + pip install --upgrade meraki -## Features +Or with [uv](https://docs.astral.sh/uv/): + + uv pip install --upgrade meraki -While you can make direct HTTP requests to dashboard API in any programming language or REST API client, using a client library can make it easier for you to focus on your specific use case, without the overhead of having to write functions to handle the dashboard API calls. The Python library can also take care of error handling, logging, retries, and other convenient processes and options for you automatically. +If you participate +in [our Early Access program](https://community.meraki.com/t5/Developers-APIs/UPDATED-Beta-testing-with-the-Meraki-Developer-Early-Access/m-p/145344#M5808) +and would like to use early access features via the library, you have two options: install a published +[beta release](#releases) from PyPI (`pip install meraki==bN`), or, if you need a library matched to your own +org's spec, [generate one yourself](https://github.com/meraki/dashboard-api-python/tree/main/generator#readme). -* Support for all API endpoints, as it uses the [OpenAPI specification](https://api.meraki.com/api/v1/openapiSpec) to generate source code -* Log all API requests made to a local file as well as on-screen console -* Automatic retries upon 429 rate limit errors, using the [`Retry-After` field](https://developer.cisco.com/meraki/api-v1/#!rate-limit) within response headers -* Get all (or a specified number of) pages of data with built-in pagination control -* Tweak settings such as maximum retries, certificate path, suppress logging, and other options -* Simulate POST/PUT/DELETE calls to preview first, so that network configuration does not get changed +### [Features](#features) · [Releases](#releases) · [Usage](#usage) · [Smart flow](#smart-flow-rate-limiting) · [AsyncIO](#asyncio) · [Versioning](VERSIONING.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) + +## Features + +You could hit the dashboard API with raw HTTP in any language. But then you own the rate-limit math, the retry loops, +the pagination bookkeeping, the auth headers, and the error handling, for every one of the hundreds of operations, and +you re-own it every time the API changes. This library does all of that for you, and stays current automatically because +it's generated from the API's own OpenAPI spec. + +What you get out of the box: + +- **Complete, always-current operation coverage** — every dashboard API operation, generated straight from the + [OpenAPI specification](https://api.meraki.com/api/v1/openapiSpec), so new operations land as the API ships them +- **Proactive rate limiting ("smart flow")** — per-org token buckets keep you under Meraki's 10 req/s org limit and + 100 req/s source-IP limit _before_ you get throttled, turning 429-and-retry churn into steady throughput. On by + default, zero config. [Details below.](#smart-flow-rate-limiting) +- **Automatic retries** — 429s honor the [`Retry-After`](https://developer.cisco.com/meraki/api-v1/#!rate-limit) + header; transient 5xx and select 4xx (network-delete/action-batch concurrency) back off and retry so your script + doesn't fall over on a blip +- **Built-in pagination** — pull all pages, or a specific number, with one call; no manual Link-header walking +- **Sync and async** — a synchronous client and a fully `async`/`await` client + ([AsyncIO](#asyncio)) sharing the same interface, so you scale up concurrency without rewriting your logic +- **Modern HTTP stack** — built on [httpx](https://www.python-httpx.org/) (not `requests`/`aiohttp`), a unified, + type-annotated, HTTP/2-capable backend powering both the sync and async clients +- **Early access via beta releases** — opt into beta builds to use API operations before they reach GA, in step with + Meraki's [Early Access program](https://community.meraki.com/t5/Developers-APIs/UPDATED-Beta-testing-with-the-Meraki-Developer-Early-Access/m-p/145344#M5808). [Details below.](#releases) +- **Dry-run mode** — simulate POST/PUT/DELETE calls to preview changes without touching your network configuration +- **Logging you can trust** — every request logged to file and console, with X-Request-Id captured on failures for + fast support escalation to Meraki +- **Kwarg validation** — optional typo protection that warns when an unrecognized keyword argument would otherwise be + silently ignored, catching bugs before they ship +- **Tunable everything** — retries, timeouts, certificate path, proxy, logging verbosity, and more, all configurable + per client or via environment ## Setup -1. Enable API access in your Meraki dashboard organization and obtain an API key ([instructions](https://documentation.meraki.com/zGeneral_Administration/Other_Topics/The_Cisco_Meraki_Dashboard_API)) +1. Enable API access in your Meraki dashboard organization and obtain an API + key ([instructions](https://documentation.meraki.com/zGeneral_Administration/Other_Topics/The_Cisco_Meraki_Dashboard_API)) + +2. Keep your API key safe and secure, as it is similar to a password for your dashboard. If publishing your Python code + to a wider audience, please research secure handling of API keys. + +3. Install [Python 3.10 or later](https://wiki.python.org/moin/BeginnersGuide/NonProgrammers) -2. Keep your API key safe and secure, as it is similar to a password for your dashboard. If publishing your Python code to a wider audience, please research secure handling of API keys. +4. Use _pip_ to install the library from the + Python [Package Index](https://pypi.org/project/meraki/): + - `pip install meraki` + - If _meraki_ was previously installed, you can upgrade to the latest non-beta release + with `pip install --upgrade meraki` -3. Install the latest version of [Python 3](ttps://wiki.python.org/moin/BeginnersGuide/NonProgrammers) +5. The library supports Meraki dashboard API v1. To install or check a specific version: + - `pip install meraki==4.3.0` installs that version (see the full [release history](https://pypi.org/project/meraki/#history)) + - `pip show meraki` reports the version currently installed + - Picking between stable and beta releases is covered under [Releases](#releases) below -4. Use _pip_ (or an alternative such as _easy_install_) to install the library from the Python [Package Index](https://pypi.org/project/meraki/): - * `pip install meraki` - * If you have both Python3 and Python2 installed, you may need to use `pip3` (so `pip3 install meraki`) along with `python3` on your system - * If _meraki_ was previously installed, you can upgrade to the latest non-beta release with `pip install --upgrade meraki` +## Releases -5. Meraki dashboard API v1 is currently in beta, so if you clone this repository and want to use v1 locally, rename the folder _meraki_v1_ to _meraki_, replacing the v0 contents there. You can also specify the version of the library when installing with _pip_: - * See the full [release history](https://pypi.org/project/meraki/#history) to pick the version you want, or use `pip install meraki==` without including a version number to display the list of available versions - * v0 versions of the Python library begin with _0_ (0.**x**.**y**), and v1 versions begin with _1_ (1.0.0b**z** for beta) - * Specify the version you want with the install command; for example: `pip install meraki==0.x.y` for v0 or `pip install meraki==1.0.0bz` for v1 beta - * You can also see the version currently installed with `pip show meraki` +`pip install --upgrade meraki` gets the latest **stable (GA)** release, which contains only GA operations from the +upstream dashboard API. Stable releases track upstream API minor versions and are published automatically when a new +OpenAPI spec ships. + +**Beta releases** (PEP 440 suffix, e.g. `4.3.0b1`) may also include beta API operations that haven't graduated to GA. +They are published to PyPI but never installed by default; opt in explicitly: + +```shell +pip install meraki==4.3.0b1 +``` + +Both the beta API operations they expose and the SDK surfaces themselves may change in breaking ways between beta releases, without notice. Beta API operations are subject to unannounced change or removal upstream, so upgrading from a beta to a stable release can _remove_ operations that never reached GA. Use a beta release only if you need early-access API operations or unreleased SDK features and accept that trade-off. + +For the full versioning scheme, cadence, and SDK-to-API version mapping, see [VERSIONING.md](VERSIONING.md). ## Usage -1. Export your API key as an [environment variable](https://www.twilio.com/blog/2017/01/how-to-set-environment-variables.html), for example: - ```shell - export MERAKI_DASHBOARD_API_KEY=093b24e85df15a3e66f1fc359f4c48493eaa1b73 - ``` +1. Export your API key as + an [environment variable](https://www.twilio.com/blog/2017/01/how-to-set-environment-variables.html), for example: -2. Alternatively, define your API key as a variable in your source code; this method is not recommended due to its inherent insecurity. + ```shell + export MERAKI_DASHBOARD_API_KEY=YOUR_KEY_HERE + ``` -3. Single line of code to import and use the library goes at the top of your script: +2. Alternatively, define it as a variable in your source code (not recommended: it's insecure). - ```python - import meraki - ``` +3. Import the library at the top of your script: -4. Instantiate the client (API consumer class), optionally specifying any of the parameters available to set: + ```python + import meraki + ``` - ```python - dashboard = meraki.DashboardAPI() - ``` +4. Instantiate the client (API consumer class), optionally specifying any of the parameters available to set: -5. Make dashboard API calls in your source code, using the format _client.scope.operation_, where _client_ is the name you defined in the previous step (**dashboard** above), _scope_ is the corresponding scope that represents the first tag from the OpenAPI spec, and _operation_ is the operation of the API endpoint. For example, to make a call to get the list of organizations accessible by the API key defined in step 1, use this function call: + ```python + dashboard = meraki.DashboardAPI() + ``` - ```python - my_orgs = dashboard.organizations.getOrganizations() - ``` +5. Make dashboard API calls in your source code, using the format _client.scope.operation_, where _client_ is the name + you defined in the previous step (**dashboard** above), _scope_ is the corresponding scope that represents the first + tag from the OpenAPI spec, and _operation_ is the operation ID from the OpenAPI spec. For example, to make a call to + get the list of organizations accessible by the API key defined in step 1, use this function call: -6. If you were using this module versions 0.34 and prior, that file's functions are included in the _legacy.py_ file, and you can adapt your existing scripts by replacing their `from meraki import meraki` line to `import meraki` + ```python + my_orgs = dashboard.organizations.getOrganizations() + ``` ### Examples + You can find fully working example scripts in the **examples** folder. -| Script | Purpose | -|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **org_wide_clients.py** | That code collects the clients of all networks, in all orgs to which the key has access. No changes are made, since only GET endpoints are called, and the data is written to local CSV output files. | +| Script | Purpose | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **org_wide_clients.py** | That code collects the clients of all networks, in all orgs to which the key has access. No changes are made, since only GET operations are called, and the data is written to local CSV output files. | -## AsyncIO -**asyncio** is a library to write concurrent code using the **async/await** syntax. Special thanks to Heimo Stieg ([@coreGreenberet](https://github.com/coreGreenberet)) who has ported the API to asyncio. +### Keyword argument validation -The usage is similiar to the sequential version above. However it has has some differences. +All optional parameters are passed as keyword arguments (`**kwargs`). By default, if you pass a misspelled or +unsupported kwarg, it is silently ignored. Enable `validate_kwargs` to log a warning when this happens: -1. Export your API key as an [environment variable](https://www.twilio.com/blog/2017/01/how-to-set-environment-variables.html), for example: +```python +dashboard = meraki.DashboardAPI(validate_kwargs=True) - ```shell - export MERAKI_DASHBOARD_API_KEY=093b24e85df15a3e66f1fc359f4c48493eaa1b73 - ``` +# This will log a warning: "updateNetwork: ignoring unrecognized kwargs: ['nme']" +dashboard.networks.updateNetwork(networkId, nme="HQ") +``` -2. Alternatively, define your API key as a variable in your source code; this method is not recommended due to its inherent insecurity. +This is off by default for backwards compatibility and zero performance overhead in production. -3. Single line of code to import and use the library goes at the top of your script: +## Smart flow rate limiting - ```python - import meraki.aio - ``` +The Meraki API enforces two rate limits: **10 requests/second per organization** and **100 requests/second per source +IP**. Exceed either and you get `429` responses. The traditional approach is reactive: send too fast, get a 429, wait +for `Retry-After`, retry. That wastes round-trips, and every 429 you generate also eats into the budget shared by other +applications hitting the same org. -4. Instantiate the client (API consumer class), optionally specifying any of the parameters available to set: +Smart flow is **proactive**. Each org gets its own token bucket, so the SDK paces requests to stay under the limit +_before_ sending, turning 429-and-retry churn into steady throughput. It's **enabled by default** with no code changes +required. - ```python - async with meraki.aio.AsyncDashboardAPI() as aiomeraki: - ``` - The **async with** statement is important here to make sure, that the client sessions will be closed after using the api. +Benefits: + +- **Fewer 429s** — requests are throttled client-side instead of bouncing off the server +- **Faster overall** — no `Retry-After` wait cycles wasted on avoidable rate-limit errors +- **Fairer** — reserves headroom (default 9 of 10 req/s per org) so you don't starve other apps on the same org +- **Zero-config** — org membership is learned automatically from the URLs you already call, and cached to disk + (`~/.meraki/.cache/`) so subsequent runs skip the lookup -5. Make dashboard API calls in your source code, using the format await _client.section.operation_, where _client_ is the name you defined in the previous step (**aiomeraki** above), _section_ is the corresponding group (or tag from the OpenAPI spec) from the [API docs](https://developer.cisco.com/meraki/api/#/rest), and _operation_ is the name (or operation ID from OpenAPI) of the API endpoint. For example, to make a call to get the list of organizations accessible by the API key defined in step 1, use this function call: +Tune it via kwargs on the client (all optional): + +```python +dashboard = meraki.DashboardAPI( + smart_flow_enabled=True, # default; set False to fall back to 429-retry only + smart_flow_org_rate=9, # max req/s per org (leaves 1 for other apps) + smart_flow_global_rate=100, # max req/s across all orgs (source-IP limit) + smart_flow_cache_mode="lazy", # "lazy" learns as you go; "eager" pre-fetches at init +) +``` + +See [config.py](https://github.com/meraki/dashboard-api-python/blob/main/meraki/config.py) for the full set of smart +flow options and their defaults. + +## AsyncIO + +The library ships a fully async client (`meraki.aio.AsyncDashboardAPI`) using **async/await**, alongside the +synchronous client. Original async port by Heimo Stieg ([@coreGreenberet](https://github.com/coreGreenberet)). + +### Usage + +Same as the synchronous client, with four differences: import `meraki.aio`, instantiate inside `async with`, `await` +each call, and run it all in an event loop. - ```python - my_orgs = await aiomeraki.organizations.getOrganizations() - ``` -6. Run everything inside an event loop. ```python import asyncio +import meraki.aio + + +async def main(): + # `async with` ensures the client's sessions are closed on exit + async with meraki.aio.AsyncDashboardAPI() as aiomeraki: + my_orgs = await aiomeraki.organizations.getOrganizations() + if __name__ == "__main__": - loop = asyncio.get_event_loop() - loop.run_until_complete(my_async_entry_point()) - - # if you are using Python 3.7+ you can also simply - # use the following line instead of the two lines above - asyncio.run(my_async_entry_point()) + asyncio.run(main()) ``` - ### Examples + You can find fully working example scripts in the **examples** folder. -| Script | Purpose | -|-------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **aio_org_wide_clients.py** | That code is a asyncio port from org_wide_clients.py and collects the clients of all networks, in all orgs to which the key has access. No changes are made, since only GET endpoints are called, and the data is written to local CSV output files. | -| **aio_ips2firewall.py** | That code will collect the source IP of security events and creates L7 firewall rules to block them. `usage: aio_ips2firewall.py [-h] -o ORGANIZATIONS [ORGANIZATIONS ...] [-f FILTER] [-s] [-d DAYS]` | +| Script | Purpose | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **aio_org_wide_clients.py** | An asyncio port of org_wide_clients.py: collects the clients of all networks, in all orgs to which the key has access. No changes are made, since only GET operations are called, and data is written to local CSVs. | +| **aio_ips2firewall.py** | Collects the source IP of security events and creates L7 firewall rules to block them. `usage: aio_ips2firewall.py [-h] -o ORGANIZATIONS [ORGANIZATIONS ...] [-f FILTER] [-s] [-d DAYS]` | + +## Note for application developers and ecosystem partners + +Identify your application with every API request by following the format defined +in [config.py](https://github.com/meraki/dashboard-api-python/blob/main/meraki/config.py) and passing the session +kwarg: + +```Python +MERAKI_PYTHON_SDK_CALLER +``` + +Unless you are an ecosystem partner, this identifier is optional. + +1. If you are an ecosystem partner and you have questions about this requirement, please reach out to your ecosystem + rep. +2. If you have any questions about the formatting, please ask your question by opening an issue in this repo. + +## Development + +This project uses [uv](https://docs.astral.sh/uv/) for dependency management and builds with +[Hatchling](https://hatch.pypa.io/). + +1. [Install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already. + +2. Install dev dependencies: + + uv sync + +3. Run tests: + + uv run pytest + +4. If you're working with the generator, install its additional dependencies: + + uv sync --group generator + +## Further documentation + +| Doc | Covers | +| ------------------------------------------ | ------------------------------------------------- | +| [CHANGELOG.md](CHANGELOG.md) | Release notes per version | +| [VERSIONING.md](VERSIONING.md) | Versioning scheme, GA vs beta, SDK-to-API mapping | +| [CONTRIBUTING.md](CONTRIBUTING.md) | How to contribute and add changelog fragments | +| [SECURITY.md](SECURITY.md) | Reporting security issues | +| [generator/README.md](generator/README.md) | Regenerating the library and Early Access usage | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..19b02ae4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 3.x | Yes | +| 2.x | No | +| < 2.0 | No | + +## Reporting a Vulnerability + +If you discover a security vulnerability in this library, please report it responsibly. + +**Do not open a public GitHub issue for security vulnerabilities.** + +Instead, use [GitHub's private vulnerability reporting](https://github.com/meraki/dashboard-api-python/security/advisories/new). This sends a private notification to the maintainers and lets us collaborate on a fix before public disclosure. + +Include: + +- Description of the vulnerability +- Steps to reproduce +- Affected versions +- Any potential mitigations you've identified + +## Scope + +This policy covers the `meraki` Python package published to PyPI. It does not cover the Meraki Dashboard API itself; for API-level security concerns, contact Cisco Meraki support directly. diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..da075370 --- /dev/null +++ b/TODO.md @@ -0,0 +1,78 @@ +# TODO: Code Maturity Recommendations + +## Summary + +| Metric | Value | +|--------|-------| +| Core lines (non-generated) | ~1,180 | +| Unit tests | 211 | +| Coverage (core) | 95.8% | +| Cyclomatic complexity ceiling | 42 (async `_request`) | +| Python versions | 3.11-3.14 | + +The project is production-grade. Generated API stubs are correctly excluded from coverage. The remaining issues are concentrated in `rest_session.py` and its async mirror. + +--- + +## Parallel Work (DONE) + +All 8 parallel tasks completed. 211 tests pass, 95.8% coverage, no warnings. + +- [x] **Fix RuntimeWarning in async tests** (replaced `AsyncMock` with `_AwaitableValue` wrapper) +- [x] **Raise coverage floor from 60% to 90%** (`pyproject.toml`) +- [x] **Fix bare `print()` in production code** (now uses `self._logger.warning(...)`) +- [x] **Remove dead `get_pages` pass-through method** (confirmed dead, removed) +- [x] **Remove unused F401 import** (added `# noqa: F401`) +- [x] **Consolidate line-length standard** (ruff now uses 127 via `pyproject.toml`) +- [x] **Dependabot config updated** (changed to `"uv"` ecosystem) +- [x] **Add integration test for async client** (`tests/integration/test_async_dashboard_api.py`) + +--- + +## Linear Work (sequential, each step builds on the previous) + +Order matters. Later items depend on or conflict with earlier ones. + +### Stream 1: Complexity Reduction (do in order) + +1. [ ] **Reduce `AsyncRestSession._request` complexity (42)** + - File: `meraki/aio/rest_session.py:140` + - Extract status-code handlers into discrete methods (match 429, match 5xx, etc.) + - Sync version (`rest_session.py:212`, complexity 12) already partially does this with `handle_4xx_errors`; apply same pattern to async + +2. [ ] **Reduce `_get_pages_legacy` complexity (24 sync, 19 async)** + - Files: `rest_session.py:526`, `aio/rest_session.py:432` + - Split event-log-specific pagination logic into a helper or strategy + - The "append results depending on endpoint type" block (lines 607-630) is doing three unrelated things + +3. [ ] **Eliminate sync/async code duplication** + - `rest_session.py` (670 lines) and `aio/rest_session.py` (547 lines) share ~80% identical logic + - Do this AFTER complexity reduction so you're deduplicating clean code, not spaghetti + - Consider a shared base or code-generation approach (the generator already exists for API stubs) + +### Stream 2: Type Safety (do in order) + +1. [ ] **Add type annotations to core modules** + - `rest_session.py`, `aio/rest_session.py`, `__init__.py`, `exceptions.py` have zero type hints + - Enables `mypy --strict` or `pyright` in CI + +2. [ ] **Add `py.typed` marker** + - Signals to downstream consumers that the package supports type checking + - Only meaningful after annotations exist + +### Stream 3: Coverage Gap Fill (do after Stream 1) + +1. [ ] **Cover the 36 missing lines** + - `rest_session.py` (25 lines): mostly logger-guarded branches and simulate path + - `aio/rest_session.py` (6 lines): similar pattern + - `__init__.py` (5 lines): lines 146-151 (logging edge case) + - Wait until after complexity refactors so you're not writing tests for code that's about to move + +--- + +## Future (next major version consideration) + +- [ ] **Consider `httpx` to unify sync/async** + - `httpx` provides both sync and async from one client, eliminating the dual-session architecture + - Breaking change; only worth it at next major version + - Would obsolete Stream 1 step 3 (deduplication), so decide before investing there diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 00000000..0ef9bcd2 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,99 @@ +# Versioning + +This document covers the SDK versioning strategy and how it relates to the upstream dashboard API. + +## Which version should I use? + +Use the latest stable release: + +```sh +pip install --upgrade meraki +``` + +Only use a different release if: + +- You want to test out the latest SDK features that aren't yet stable, or +- You want access to beta API operations that haven't graduated to GA yet, and you accept that those operations may change or disappear in future releases, even if you don't update or change your library version. + +## How do I know which version I'm using? + +If you're using v3.x+: + +```python +import meraki +print(meraki.__version__) # SDK version, e.g. "4.1.0b2" +print(meraki.__api_version__) # API version, e.g. "1.70.0-beta.2" +``` + +Otherwise, please see [Releases](https://github.com/meraki/dashboard-api-python/releases). + +### Why aren't the SDK version and the API version linked (as of [SDK 2.x, released April 8, 2025](https://github.com/meraki/dashboard-api-python/releases/tag/2.0.0))? + +The library and the API are related but separate software. Improving the SDK sometimes requires SDK-only changes, which in turn necessitates bumping the SDK version separately from the upstream API. + +## Scheme + +```text +MAJOR.MINOR.PATCH[bN] +``` + +| Segment | Bumped when | +| ------- | ----------------------------------------------------------------------------------------------------------------------- | +| MAJOR | SDK-only breaking changes are made (new HTTP backend, generated code structure changes, Python version support changes) | +| MINOR | New upstream API release (tracks Meraki dashboard API minor versions) | +| PATCH | SDK bug fixes or upstream API patch releases | +| bN | Pre-release beta suffix for unreleased major/minor tracks, including upstream API beta operations | + +## Objective + +The repo maintainers work to streamline the adoption of new major SDK versions and minimize breaking changes. The general expectation is as follows: + +- if you are exclusively using GA operations from the upstream API, and +- if you are keeping your local Python environment updated to [a contemporary Python version](https://devguide.python.org/versions/), i.e., a version that is not already nor end of life within 6 months, and +- if you are sticking to the SDK's own supported surfaces (i.e., not monkey-patching the library) + +Then you should be able to update your environment to the latest stable library version without breaking changes to your application. + +If you identify any unexpected breaking changes that are not a result of the upstream API changing, then please [report the issue](https://github.com/meraki/dashboard-api-python/issues). + +## Beta Convention + +Pre-release versions use PEP 440 beta suffixes: `4.1.0b1`, `4.1.0b2`, etc. + +Beta releases are published to PyPI and installable via `pip install meraki==4.1.0b2`. They are not installed by default (`pip install meraki` gets the latest stable). + +### API operation coverage + +- **Stable releases** contain only GA (stable) operations from the upstream API. If an operation is marked beta in the dashboard API spec, it is excluded from stable builds. +- **Beta releases** may include beta API operations from the dashboard API in addition to all GA operations. These operations are subject to change or removal without notice by the upstream API, since beta API operations are subject to unannounced breaking changes. + +This means upgrading from a beta to a stable release can _remove_ operations that were previously available, if those operations have not yet graduated to GA upstream. + +## SDK-to-API Version Mapping + +Each SDK minor release is generated from a specific dashboard API version: + +| SDK | API | Status | What changed | +| --- | ------------ | ----------- | --------------------------------------------------------------------------------------------------------------------- | +| 4.x | v1.70.0+ | Development | httpx migration (unified sync/async backend), supported Python version changes, all major developments moving forward | +| 3.x | v1.69.0+ | Stable | OASv3-based, automated release pipeline, supported Python version changes | +| 2.x | v1 (various) | Deprecated | Internal overhaul (async via aiohttp, pagination, logging, supported Python version changes) | +| 1.x | v1 (various) | Deprecated | Initial v1 API library, requests-based | + +Within a major, the minor version/patch versions advance with each API release. For example, 3.1.0 was generated from API v1.70.0. + +Patch versions can also advance based on SDK-only fixes. + +## Where Versions Live + +| What | Location | +| --------------- | ------------------------------------------------------------------ | +| SDK version | `meraki/_version.py` (`__version__`), `pyproject.toml` (`version`) | +| API version | `meraki/__init__.py` (`__api_version__`) | +| Git tags | `4.1.0b2` (no `v` prefix for 2.x+) | +| Release commits | `Auto-generated library v{SDK} for API v{API}.` | + +## Release Triggers + +- **Automated**: the generator runs when a new OpenAPI spec is detected, producing a minor bump +- **Manual**: major versions and SDK-only patches are released by maintainers diff --git a/changelog.d/.gitignore b/changelog.d/.gitignore new file mode 100644 index 00000000..f935021a --- /dev/null +++ b/changelog.d/.gitignore @@ -0,0 +1 @@ +!.gitignore diff --git a/docs/HTTPX-MIGRATION.md b/docs/HTTPX-MIGRATION.md new file mode 100644 index 00000000..9ee139e4 --- /dev/null +++ b/docs/HTTPX-MIGRATION.md @@ -0,0 +1,350 @@ +# HTTPX Migration Plan + +## Why Migrate? + +### The core problem: two libraries, one SDK + +This library maintains two HTTP backends (`requests` for sync, `aiohttp` for async) with ~80% duplicated logic across `rest_session.py` (670 lines) and `aio/rest_session.py` (547 lines). This duplication is the root cause of multiple concerns documented in `.planning/codebase/CONCERNS.md`: + +**Tech debt it directly eliminates:** + +- Sync/async code duplication (bugs must be fixed twice, inconsistencies accumulate) +- High cyclomatic complexity in `AsyncRestSession._request` (42), which exists partly because async required a full reimplementation +- Two pinned dependencies at risk of breaking changes (`requests<3`, `aiohttp<4`) + +**Bugs it fixes by replacement:** + +- Bare `except Exception` in async handler (replaces with typed `httpx.HTTPError`) +- Inconsistent error handling between sync (catches `requests.exceptions.RequestException`) and async (catches everything) +- Blocking `time.sleep()` call in async 4xx handler (`aio/rest_session.py:268`), which blocks the event loop during network-delete retry waits + +**Quality gaps it creates the opportunity to close:** + +- No type annotations in core modules (rewrite is the natural time to add them) +- Missing error path test coverage (new code gets new tests) +- Test mocking uses `responses` library (requests-only); migration to `respx` modernizes the test infra + +### Why httpx specifically? + +- Provides `httpx.Client` (sync) and `httpx.AsyncClient` (async) with an identical API surface +- After `await client.request()`, response body is already buffered; `.json()` is synchronous even on the async client (simplifies pagination logic) +- Same `verify=`, `timeout=` semantics as requests (minimal learning curve for contributors) +- `proxy=` as a simple string (matches current config model) +- `response.links` property parses Link headers identically to requests (same `{'next': {'url': '...'}}` dict format) +- Actively maintained, type-annotated from the start, HTTP/2 capable +- Industry momentum: FastAPI, Starlette, and most modern Python HTTP tooling default to or recommend httpx + +### What it does NOT solve + +These concerns remain and require separate work: + +- Adaptive retry strategy (app logic, not library choice) +- Pagination memory buffering (iterator pattern already exists) +- API key exposure risk (logging concern, unrelated to transport) +- OASv3 generator migration +- Request cancellation/OpenTelemetry integration (httpx has better primitives, but wiring them up is separate scope) + +--- + +## Phase 0: Integration Test Baseline + +Before touching HTTP code, capture a passing integration test run against the Meraki sandbox. This becomes the regression gate for all subsequent phases. + +- Run existing integration tests, record pass/fail state +- Document which endpoints are exercised +- This baseline validates that Phases 2-3 produce identical external behavior + +--- + +## Phase 1: Shared Utilities (additive, no breaking changes) + +**Create `meraki/http_utils.py`** with one library-agnostic function: + +### `encode_meraki_params(data) -> str | None` + +Replaces the monkey-patched `requests.models.RequestEncodingMixin._encode_params` (rest_session.py:41-107). Reimplements the custom array-of-objects encoding as a pure function using only `urllib.parse.urlencode`. + +Strategy: pre-encode params into a query string and append to the URL before passing to httpx (httpx has no monkey-patch hook for param encoding). + +Current behavior: +- Input: `{"param[]": [{"key_1": "value_1"}]}` +- Output: `param%5B%5Dkey_1=value_1` + +The existing impl uses `requests.utils.to_key_val_list` (just `.items()` on dicts) and `requests.compat.basestring` (just `str` in Python 3). Both are trivially replaceable. + +Note: `response.links` does NOT need a replacement utility. httpx provides `.links` with the same dict format as requests. + +--- + +## Phase 2: Session Base Class + +**Create `meraki/_session_base.py`** extracting shared logic from both session files: + +- All configuration storage (api_key, base_url, timeouts, retries, proxy, cert) +- Header construction +- URL resolution and validation +- Retry decision logic (`_should_retry_4xx`, `_get_retry_wait`) +- Param encoding dispatch (`_apply_params` calls `encode_meraki_params`) + +The two concrete session classes become thin I/O layers over this base. + +**Design decision for sync/async split:** The base class holds all decision logic (should we retry? how long to wait? what error to raise?) but does NOT hold the retry loop itself, because the loop calls `time.sleep()` (sync) vs `await asyncio.sleep()` (async). Each concrete class implements `_execute_with_retry` using the base's decision methods. This keeps the base simple and avoids abstract-method overhead. + +--- + +## Phase 3: Rewrite Sync Session + +**Rewrite `meraki/rest_session.py`** to use `httpx.Client`: + +| requests | httpx | +|----------|-------| +| `requests.session()` | `httpx.Client(headers=..., verify=..., proxy=..., timeout=..., follow_redirects=False)` | +| `session.request(method, url, allow_redirects=False, **kwargs)` | `self._client.request(method, url, **kwargs)` | +| `requests.exceptions.RequestException` | `httpx.HTTPError` | +| `response.reason` | `response.reason_phrase` | +| `response.links` | `response.links` (same API) | +| `verify=path` | `verify=path` (same) | +| `proxies={"https": url}` | `proxy=url` | +| `timeout=60` | `timeout=60` (same) | + +Key: params are pre-encoded into the URL via `_apply_params()`, so httpx never sees `params=`. + +**Important:** Remove the monkey-patch (`requests.models.RequestEncodingMixin._encode_params = encode_params` at line 107) in this same phase. If requests remains importable (e.g., generator scripts), the monkey-patch must not fire at SDK import time. + +--- + +## Phase 4: Rewrite Async Session + +**Rewrite `meraki/aio/rest_session.py`** to use `httpx.AsyncClient`: + +| aiohttp | httpx | +|---------|-------| +| `aiohttp.ClientSession(headers=..., timeout=aiohttp.ClientTimeout(...))` | `httpx.AsyncClient(headers=..., verify=..., proxy=..., timeout=..., follow_redirects=False)` | +| `response.status` | `response.status_code` | +| `await response.json(content_type=None)` | `response.json()` (sync after await on request) | +| `ssl=ssl_context` | `verify=path` (httpx handles SSLContext internally) | +| `proxy=url` (singular) | `proxy=url` (same) | +| `response.release()` | (delete, body already buffered) | +| `async with await self.request(...) as response:` | `response = await self.request(...)` | +| `response.links` | `response.links` (same API) | + +**Structural changes beyond the table:** + +- All 6 `async with await self.request(...) as response:` patterns (get, post, put, delete, _get_pages_legacy x2) become simple assignment. This is a pervasive rewrite, not a find-replace. +- `response.release()` calls in the async iterator are deleted (httpx buffers fully on await). +- `content_type=None` in `response.json()` calls (~10 occurrences) is dropped silently; httpx doesn't validate MIME type by default. + +The `asyncio.Semaphore` for concurrency control and `asyncio.create_task` for page pre-fetching remain unchanged. + +--- + +## Phase 5: Update Exceptions + +**Modify `meraki/exceptions.py`:** + +Current state: +- `APIError.__init__(metadata, response)` uses `response.status_code`, `response.reason` +- `AsyncAPIError.__init__(metadata, response, message)` uses `response.status`, `response.reason`, separate `message` param + +These have **different signatures and different attribute sources**. Unifying requires: + +1. Change `APIError`: + - `response.reason` -> `response.reason_phrase` + - `response.content` -> `response.content` (same in httpx) + +2. Change `AsyncAPIError`: + - `response.status` -> `response.status_code` + - `response.reason` -> `response.reason_phrase` + +3. Deprecation path for `AsyncAPIError`: + - Keep the class but make it a subclass of `APIError` with a compatibility `__init__` that accepts the old 3-arg signature + - Add a deprecation warning when instantiated directly + - Document in CHANGELOG that users should catch `APIError` for both sync and async in future versions + +--- + +## Deprecated: AsyncAPIError + +**Status:** Deprecated as of v4.0. Use `APIError` for both sync and async exceptions. + +### What Changed + +In previous versions, the SDK used two separate exception classes: +- `APIError` for synchronous errors +- `AsyncAPIError` for asynchronous errors + +Starting in v4.0, both sync and async sessions raise exceptions that inherit from `APIError`. The `AsyncAPIError` class remains available for backwards compatibility but is deprecated. + +### Migration + +**Before (v3.x):** +```python +from meraki.aio import AsyncDashboardAPI +from meraki.exceptions import AsyncAPIError + +async with AsyncDashboardAPI(api_key=API_KEY) as aiomeraki: + try: + response = await aiomeraki.organizations.getOrganizations() + except AsyncAPIError as e: + print(f"Error: {e.status} {e.reason}") +``` + +**After (v4.0+):** +```python +from meraki.aio import AsyncDashboardAPI +from meraki.exceptions import APIError # Changed + +async with AsyncDashboardAPI(api_key=API_KEY) as aiomeraki: + try: + response = await aiomeraki.organizations.getOrganizations() + except APIError as e: # Changed + print(f"Error: {e.status} {e.reason}") +``` + +### Backwards Compatibility + +Existing code using `except AsyncAPIError:` will continue to work because `AsyncAPIError` is now a subclass of `APIError`. However, you will see a `DeprecationWarning` when the exception is raised. + +To suppress the warning during migration: +```python +import warnings +warnings.filterwarnings('ignore', category=DeprecationWarning, module='meraki') +``` + +### Recommended Action + +Update exception handlers to catch `APIError` instead of `AsyncAPIError`. This future-proofs your code and eliminates deprecation warnings. + +--- + +## Phase 6: Update Dependencies + +**Modify `pyproject.toml`:** + +```toml +dependencies = [ + "httpx>=0.28,<1", +] + +[dependency-groups] +dev = [ + "respx>=0.22,<1", # replaces 'responses' + # ... rest unchanged +] +``` + +Remove: `requests`, `aiohttp`, `responses` + +Note on upper pin: `<1` guards against httpx 1.0 API breaks (they've discussed backwards-incompatible changes for 1.0). Re-evaluate when 1.0 ships. + +**This phase MUST be atomic with Phases 3-4.** If `requests` remains installed while the new code is live, the old monkey-patch import path could still fire from stale .pyc caches or editable installs. + +--- + +## Phase 7: Update Tests + +| Test file | Change | +|-----------|--------| +| `tests/unit/test_rest_session.py` | Mock `httpx.Response` instead of `requests.Response`; `.reason` -> `.reason_phrase`; `.links` remains same API | +| `tests/unit/test_aio_rest_session.py` | Replace aiohttp mocks with httpx mocks; `.status` -> `.status_code`; remove `__aenter__`/`__aexit__` patterns; `.json()` no longer awaitable | +| `tests/unit/test_mock_integration.py` | Replace `responses` library with `respx` | +| Integration tests | Re-run baseline from Phase 0, confirm identical pass/fail | + +--- + +## Phase 8: Backwards Compatibility + +| Concern | Resolution | +|---------|------------| +| `requests_proxy` parameter name | Keep it. Pass through as `proxy=` internally. | +| `REQUESTS_PROXY` config constant | Keep it. Just a default value string. | +| `AsyncAPIError` class | Keep as deprecated subclass of `APIError`. | +| `_req_session` internal attribute | Add deprecation property mapping to `_client`. | + +--- + +## Phase 9: Decompose Request Logic + +The current `AsyncRestSession._request` has complexity 40 (4x industry ceiling). During rewrite, decompose into: + +| Method | Responsibility | +|--------|---------------| +| `_execute_with_retry` | Retry loop, attempt counting, backoff timing | +| `_handle_rate_limit` | 429 detection, Retry-After parsing, wait logic | +| `_handle_error_response` | 4xx/5xx classification, exception raising | +| `_log_request` | Request/response debug logging | + +Each method stays under complexity 10. Decision logic lives in the base class; sync/async layers differ only in sleep/request calls. + +--- + +## Phase 10: Type Annotations + +Type-annotate the new unified session base class and both thin I/O layers. This is the natural place to introduce typing since the code is being rewritten anyway, and the shared base class gives ~80% coverage for free. + +- All public methods get full signatures (params and return types) +- Use `httpx.Response` directly (no wrapper) +- Add `py.typed` marker (PEP 561) in the same commit + +--- + +## Phase 11: Property-Based Tests + +Add `hypothesis` property-based tests for the param encoding utility: + +| Function | Properties to verify | +|----------|---------------------| +| `encode_meraki_params` | Roundtrip: parsed output matches input structure; never produces bare `=` without key; handles empty dicts/lists; output is valid URL query string | + +Add `hypothesis` to dev dependencies in `pyproject.toml`. + +--- + +## Phase 12: Generator Scripts (optional, low priority) + +`generator/generate_library.py` and siblings use `requests.get()` at build-time to fetch OpenAPI specs. Not shipped to users. Can migrate separately or leave as dev-only dependency. + +--- + +## TODO.md Items Made Obsolete + +These items from TODO.md are eliminated or simplified by this migration: + +- **Stream 1, Step 3** (Eliminate sync/async duplication) - obsolete +- **Stream 1, Step 1** (Async `_request` complexity 42) - rewrite > refactor +- **Stream 1, Step 2** (`_get_pages_legacy` complexity) - rewrite > refactor +- **Stream 3** (Cover 36 missing lines) - those lines get replaced +- **Add integration test for async client** - one client = one test surface + +--- + +## Risk Mitigation + +- httpx sync responses are fully buffered (like requests). No behavior change. +- httpx async responses: body already read after `await client.request()`. Simplifies async code. +- Connection pooling: `Client`/`AsyncClient` maintain pools like `requests.Session`. +- Timeout: httpx default is 5s, but SDK explicitly sets 60s. No issue. +- Certificate verification: `verify="/path/to/cert.pem"` works identically. +- Proxy: `proxy="http://host:port"` as string. Direct pass-through. +- Monkey-patch removal: must happen atomically with requests removal to avoid import-time side effects on other packages. + +--- + +## Execution Order + +| Step | Risk | Validation | +|------|------|------------| +| Phase 0 (integration baseline) | None | Record current pass/fail state | +| Phase 1 (utilities) | None | Unit tests for encode function | +| Phase 2 (base class) | None | Unit tests for shared logic | +| Phase 3 (sync rewrite) | **High** | Full sync test suite passes | +| Phase 4 (async rewrite) | **High** | Full async test suite passes | +| Phase 5 (exceptions) | Medium | Error formatting matches expected output | +| Phase 6 (dependencies) | Medium | `pip install -e .` succeeds; atomic with 3-4 | +| Phase 7 (tests) | Medium | All tests green | +| Phase 8 (compat) | Low | Existing user code still works | +| Phase 9 (decompose) | Low | Complexity scores under 10 per method | +| Phase 10 (types) | Low | mypy passes | +| Phase 11 (property tests) | Low | hypothesis finds no violations | +| Phase 12 (generator) | None | Optional, dev-only | +| Integration gate | **Critical** | Live API tests pass against Meraki sandbox | diff --git a/docs/OASV3-MIGRATION.md b/docs/OASV3-MIGRATION.md new file mode 100644 index 00000000..6acc00c0 --- /dev/null +++ b/docs/OASV3-MIGRATION.md @@ -0,0 +1,215 @@ +# OASv3 Generator: Plan + +## Context + +The Meraki Dashboard API offers both OASv2 (`/openapiSpec`) and OASv3 (`/openapiSpec?version=3`) specs. The v3 spec is OpenAPI 3.0.1 and has features not expressible in v2. The current production generator (`generate_library.py`) only handles v2. An abandoned attempt (`generate_library_oasv3.py`) exists but is monolithic, missing key features, and never finished. Goal: build a proper v3 generator following the v2 generator's modular architecture. + +## OASv3 Features Not in v2 + +| Feature | v2 | v3 | Code-gen impact | +|---------|----|----|-----------------| +| `requestBody` separate from `parameters` | Body params in `parameters` with `"in": "body"` | Dedicated `requestBody.content["application/json"].schema` | Need separate parsing path, merge into same dict format | +| `$ref` references | Inline schemas only | `$ref` to `#/components/schemas/...` | Must resolve at parse time | +| `oneOf` query params | N/A | e.g. `startDate` can be string OR object with `lt/gt/lte/gte/neq` | New param type to handle | +| `nullable: true` | N/A | Properties can be nullable | Informational for docstrings | +| `parameter.schema` structure | `parameter.type` directly OR `parameter.schema.properties` for body | Always `parameter.schema.type` for path/query | Different extraction path | +| `components/schemas` | `definitions` (but Meraki v2 doesn't use it) | Reusable schemas referenced via `$ref` | Need resolver | +| `servers` array | `host` + `basePath` + `schemes` | `servers[].url` with variables | Informational only | + +## Approach + +Replace `generator/generate_library_oasv3.py` with a proper modular implementation following the v2 generator's structure. Reuse `common.py`, all jinja2 templates, and produce identical output format. Port useful parsing logic from the abandoned oasv3 file. + +## Deprecation Plan + +Once the v3 generator is fully vetted: + +1. **Parity gate**: CI drift detection (step 6 in Verification) must show zero semantic differences on the live spec for 2+ consecutive API releases +2. **Rename**: `generate_library.py` becomes `generate_library_oasv2.py` (deprecated, retained for rollback) +3. **Promote**: `generate_library_oasv3.py` becomes `generate_library.py` (new default) +4. **Update CI/automation**: all workflows, Makefile targets, and docs point to the new default +5. **Deprecation notice**: `generate_library_oasv2.py` gets a warning on import and a docstring noting it is unmaintained +6. **Removal**: delete `generate_library_oasv2.py` after one minor version cycle with no rollbacks + +## File Changes + +| Action | File | +|--------|------| +| CREATE | `generator/generate_library_oasv3.py` (replace existing abandoned file) | +| CREATE | `tests/generator/fixtures/synthetic_spec_oasv3.json` | +| CREATE | `tests/generator/test_generate_library_oasv3_golden.py` | +| CREATE | `tests/generator/golden_oasv3/meraki/api/networks.py` | +| CREATE | `tests/generator/golden_oasv3/meraki/aio/api/networks.py` | +| CREATE | `tests/generator/golden_oasv3/meraki/api/batch/networks.py` | + +## Implementation + +### 1. Core v3 Parsing Functions + +Port from abandoned oasv3 + enhance: + +```python +def resolve_ref(spec: dict, ref: str, _visiting: set | None = None) -> dict | None + # Follow #/components/schemas/... JSON pointer + # Circular ref protection: track visited refs, return None on cycle + # Cache resolved refs on spec["_ref_cache"] to avoid repeated traversal + +def get_schema_from_item(item: dict, spec: dict) -> dict | None + # Extract schema, resolve $ref if present + +def parse_request_body(operation: str, request_body: dict, spec: dict) -> dict + # Parse requestBody.content by content type: + # - application/json: parse schema.properties + # - multipart/form-data: parse schema.properties, mark file fields (format: binary) + # - application/octet-stream: single binary body param + # Warn and skip unsupported content types + # Return dict with "in": "body" markers (same format v2 produces) + +def resolve_oneof_type(schema: dict) -> tuple[str, str] + # Returns (type, extra_description) for oneOf schemas + # Prefers string over object; appends object property names to description +``` + +### 2. Unified `parse_params` (v3 signature) + +```python +def parse_params( + operation: str, + parameters: list | None, + request_body: dict | None, + spec: dict, + param_filters=None +) -> dict +``` + +Internally: + +1. Collect path-level parameters, then merge operation-level parameters (operation wins on name+in collision) +2. Parse path/query params from merged `parameters` list (using `param.schema.type`) +3. For array params, respect `style`/`explode` (default: `style: form`, `explode: true` per OAS3 spec) +4. Parse body params from `requestBody` via `parse_request_body()` +5. Merge into single dict +6. Add pagination params if `perPage` present +7. Filter via `return_params()` (ported unchanged from v2) + +### 3. HTTP-Method Parsers + +Same signatures as v2 but with `request_body` and `spec` threaded through: + +```python +def parse_get_params(operation, parameters, request_body, spec) +def parse_post_and_put_params(method, operation, parameters, request_body, spec) +def parse_delete_params(operation, parameters, request_body, spec) +``` + +### 4. Module Generation + +Reuse v2 structure exactly: + +- `generate_library()` - top-level orchestrator +- `generate_modules()` - iterate scopes, create files +- `generate_standard_and_async_functions()` - render function templates +- `generate_action_batch_functions()` - render batch templates +- `render_class_template()` - render class headers +- Use `common.organize_spec()` for scope organization +- Use `jinja_env.filters["to_double_quote_list"]` for tag rendering +- Use `ruff` for formatting at end + +### 5. `x-batchable-actions` Handling + +Present in v3 spec (298 entries). Same structure as v2: `{ group, summary, resource, operation }`. Matching logic unchanged (match `endpoint["description"]` against `action["summary"]`). + +```python +batchable_actions = spec["x-batchable-actions"] +``` + +Look up operation type from spec (not hardcoded), matching v2's behavior. + +### 6. `oneOf` Query Parameter Strategy + +These are optional query params, so they land in `**kwargs` (not the typed signature). For docstrings: + +- Report type as `"string or object"` (accurate, not lossy) +- Document the object sub-properties (e.g., `lt`, `gt`, `lte`, `gte`, `neq`) +- If one were ever required, use no type annotation (bare param name) rather than lying with `str` +- The `rest_session.py` `encode_params` already handles dict values passed as query params + +### 7. CLI and Spec Fetching + +Same args as v2: `-h`, `-o`, `-k`, `-v`, `-a`, `-g` + +```python +requests.get("https://api.meraki.com/api/v1/openapiSpec", params={"version": 3}) +``` + +### 8. Test Fixture (`synthetic_spec_oasv3.json`) + +v3 equivalent of the existing `synthetic_spec.json`: + +- Same endpoints (getNetworkClients, updateNetworkSettings, deleteNetwork) converted to OASv3 structure (requestBody, parameter.schema, etc.) +- Add one `oneOf` query param to exercise object-type query param handling +- Add one `$ref` usage in requestBody to exercise reference resolution +- Add a circular `$ref` to exercise cycle detection (should resolve to None gracefully) +- Add a `nullable: true` property to exercise nullable annotation +- Add one endpoint with `multipart/form-data` requestBody containing a `format: binary` field +- Add path-level parameters on one path to exercise inheritance/override logic +- Same `x-batchable-actions` + +Golden files will NOT match v2 golden output. They will reflect v3-specific output differences (richer docstrings from `oneOf` descriptions, nullable annotations, etc.). The golden files validate that the v3 generator produces correct output for v3 features, not that it matches v2 byte-for-byte. + +## Key Design Decisions + +1. **Resolve `$ref` at parse time with cycle protection** - downstream code gets normalized dicts, no template changes needed. Visited-set guard prevents infinite loops on circular refs; cache avoids redundant traversal. +2. **`oneOf` reported accurately** - type shown as `"string or object"` in docstrings; if ever required, no type annotation rather than a lie +3. **`nullable` affects type hints** - nullable params get `| None` in the function signature (e.g., `name: str | None`); also noted in docstring. Only v3.0 `nullable: true` style supported (not v3.1 `type: [string, null]`). +4. **New file, not edit of abandoned one** - cleaner modular structure, easier to maintain +5. **Thread `spec` param through all functions** - needed for `$ref` resolution anywhere in the tree +6. **Content-type awareness** - `parse_request_body` handles `application/json`, `multipart/form-data`, and `application/octet-stream`. Unsupported types emit a warning and are skipped (not silently dropped). +7. **Path-level parameter inheritance** - operation params override path-level params on name+in match, per OAS3 spec +8. **Array serialization** - respect `style`/`explode` attributes; default to `form`+`explode:true` per OAS3 spec +9. **Preserve vendor extensions** - carry all `x-` fields through (not just `x-batchable-actions`); downstream templates can access them + +## Code Generation Quality Improvements + +### Replace `kwargs.update(locals())` + +The current v2 generator emits `kwargs.update(locals())` in every method, which is implicit and leaks internal variables. The v3 generator should emit explicit parameter construction: + +```python +# Current (v2) - implicit, fragile +def getOrganizationNetworks(self, organizationId, total_pages=1, direction="next", **kwargs): + kwargs.update(locals()) + # ... + +# Target (v3) - explicit, type-safe +def getOrganizationNetworks(self, organizationId, total_pages=1, direction="next", **kwargs): + params = {k: v for k, v in kwargs.items() if k in query_params} + body = {k: v for k, v in kwargs.items() if k in body_params} + # ... +``` + +Update the Jinja2 templates (`api_function_template.jinja2`, `batch_function_template.jinja2`) to emit the explicit form. This eliminates the `locals()` antipattern and makes generated code compatible with static analysis tools. + +### Generate Type Stubs + +Produce `.pyi` stub files alongside each generated scope module: + +- `meraki/api/organizations.pyi` with full signatures (param names, types from OAS3 schema, return types) +- Enables downstream type checking without runtime cost +- OAS3 schema types map directly: `string` -> `str`, `integer` -> `int`, `boolean` -> `bool`, `array` -> `list`, `object` -> `dict` +- `oneOf` params get `Union[str, dict]` +- `nullable: true` params get `| None` +- Return types: `dict` for single-object, `list[dict]` for arrays, `Generator` for paginated iterators + +Add a `--stubs` flag to the generator CLI. Template: `api_stub_template.jinja2`. + +--- + +## Verification + +1. Run `python generator/generate_library_oasv3.py` against live v3 spec +2. Diff output against v2-generated library (should be structurally identical, minor description differences from nullable/oneOf annotations) +3. Run golden-file tests: `pytest tests/generator/test_generate_library_oasv3_golden.py` +4. Run existing test suite to confirm no regressions +5. Spot-check generated functions for correct param handling (especially endpoints with requestBody, arrays, enums) +6. **CI drift detection**: automated diff of v2 vs v3 generator output on the live spec (catches divergence between generators over time) diff --git a/docs/SMARTFLOW.md b/docs/SMARTFLOW.md new file mode 100644 index 00000000..ba0797c4 --- /dev/null +++ b/docs/SMARTFLOW.md @@ -0,0 +1,217 @@ +# Smart Flow — Rate Limiter Specification + +Language-agnostic spec for the Meraki proactive rate limiter. The Python +reference implementation is `meraki/smart_flow.py`; this document defines the +*behavior* so any implementation (Python, PowerShell, .NET, …) can be verified +against the same conformance vectors in `smartflow-vectors.json`. + +Goal: prevent 429s before they happen by throttling per-organization request +rates locally, plus a global per-source-IP ceiling, learning which org each +network/device belongs to as traffic flows. + +--- + +## 1. Token bucket + +The primitive. One bucket = one rate limit. + +**State:** `rate` (tokens/sec), `capacity` (max tokens), `tokens` (current, float), +`last` (monotonic timestamp of last refill). + +**Init:** `tokens = capacity`, `last =` monotonic now at creation. +(The async reference lazily sets `last` on the first `acquire`; equivalent to +setting it at creation when no time passes before the first call.) + +**`acquire()`** — the whole algorithm, executed atomically per bucket: + +``` +now = monotonic() +elapsed = now - last +tokens = min(capacity, tokens + elapsed * rate) # refill +last = now +tokens = tokens - 1 # deduct unconditionally +wait = (-tokens / rate) if tokens < 0 else 0 # deficit -> sleep +if wait > 0: sleep(wait) +``` + +Critical properties, all load-bearing for parity: + +- **Deduct unconditionally; tokens may go negative.** The negative value *is* the + reservation. Concurrent callers each compute their own `wait` against the + accumulated deficit instead of colliding on the same instant. Do **not** clamp + tokens at zero. +- **Reserve under lock, sleep outside it.** Compute `wait` while holding the + per-bucket lock, release, then sleep. Serializing the sleeps destroys burst + parallelism. +- **`rate` has a floor of 0.5.** Setting `rate` to anything lower clamps to `0.5` + (prevents AIMD decrease from stalling a bucket to zero throughput). + +--- + +## 2. Rate defaults + +| Bucket | rate | capacity | +| --- | --- | --- | +| Per-org | `10.0` (SDK config default surfaces `9`) | `10` | +| Global (per source IP) | `100.0` | `100` (`int(global_rate)`) | + +Meraki enforces ~10 req/s per org and ~100 req/s per source IP. Per-org default +is set below the ceiling to reserve headroom for other apps. + +--- + +## 3. URL → identifier extraction + +Three regexes, searched (not full-match) against the request URL: + +| Identifier | Pattern | Capture | +| --- | --- | --- | +| org | `/organizations/([^/]+)` | org id | +| network | `/networks/([^/]+)` | network id | +| device | `/devices/([^/]+)` | serial | + +**`resolve_org(url)`** returns the first that resolves: + +1. org id directly from URL → return it +2. else network id from URL → look up `network_to_org[net]` (may be `None`) +3. else serial from URL → look up `serial_to_org[serial]` (may be `None`) +4. else `None` + +--- + +## 4. acquire(url) + +``` +global_bucket.acquire() # always, first +org = resolve_org(url) +if org: + bucket(org).acquire() # get-or-create org bucket +else: + resolve_inline(url) # sync ref; async fires a background task + org = resolve_org(url) + if org: bucket(org).acquire() +``` + +Global bucket is charged on **every** request regardless of org resolution. +An unresolved request pays only the global cost until its org is learned. + +**Sync vs async divergence (the one intentional difference):** +- Sync `resolve_inline` calls the resolver **inline and blocks**, so a freshly + resolved org's bucket is acquired *in the same call*. +- Async `_trigger_background_resolve` fires a one-shot background task and does + **not** block; the org bucket is acquired on *subsequent* calls once learned. + +An implementation without async (e.g. PowerShell sync) follows the sync path. + +--- + +## 5. AIMD rate adaptation + +Additive-increase / multiplicative-decrease on top of the static limits. + +**`on_rate_limited(url)`** (called on a 429): + +``` +org = resolve_org(url) +if org has a bucket: + bucket.rate *= 0.7 # multiplicative decrease +elif url is an "unresolved scoped url": + skip # do NOT penalize global +else: + global_bucket.rate *= 0.7 +``` + +**Unresolved scoped URL** = URL has a network or device component but **no** +explicit `/organizations/`. The offending org is specific but unknown; +penalizing the global bucket would punish every other org for one org's 429, so +skip and let resolution catch up. + +**`on_success(url)`** (called on a 2xx): + +``` +org = resolve_org(url) +if org has a bucket and bucket.rate < configured_org_rate: + bucket.rate = min(configured_org_rate, bucket.rate + 0.2) # additive increase +if global_bucket.rate < configured_global_rate: + global_bucket.rate = min(configured_global_rate, global_bucket.rate + 0.5) +``` + +Increments: **+0.2** per-org, **+0.5** global. Decrease factor: **×0.7** both. +Remember the `0.5` rate floor from §1 bounds the decrease. + +--- + +## 6. Learning org mappings + +**`learn_from_response(url, body)`** — called on successful GET responses to +populate the net→org / serial→org caches. + +Resolve the governing org first: +1. org id from URL (`/organizations/`), else +2. org id from body: `body.organizationId`, else `body.organization.id` +3. if neither → return (nothing learned) + +Then record mappings (only counting ones that *change* an existing value): + +- network id from **URL** → `network_to_org` +- serial from **URL** → `serial_to_org` +- from **body**: `body.networkId`, `body.serial`, `body.network.id` + +Each changed mapping increments a `dirty` counter (see §7). + +Explicit registration API (used by eager hydration / resolver callbacks): +`register_org(org)`, `register_network(net, org)`, `register_device(serial, org)`. + +--- + +## 7. Disk cache + +Persists learned mappings across sessions so warm runs skip re-learning. + +**Format** (`rate_limit_cache.json`, default `~/.meraki/.cache/`): + +```json +{ + "saved_at": "2026-07-08T12:00:00Z", + "networks": [{ "id": "N_1", "organization": { "id": "O_1" } }], + "devices": [{ "serial": "Q2XX-XXXX-XXXX", "organization": { "id": "O_1" } }] +} +``` + +**`saved_at`** is UTC, format `%Y-%m-%dT%H:%M:%SZ` (trailing `Z`, no offset). + +**Load / freshness:** +- No file → start empty. +- TTL default `604800` s (7 days). `None` = never expire. +- Expired if `saved_at` missing, unparseable, or `now - saved_at > ttl` → + ignore file, rebuild. `cache_fresh` stays false. +- On fresh load, populate both maps; set `cache_fresh = true`. +- Malformed JSON / missing keys / IO error → swallow, start empty. + +**Flush cadence:** when `dirty >= 50`, save and reset the counter. Async saves on +a background thread and only subtracts the flushed count on success (unsaved +mappings are retained, not lost, if the write fails). Async `shutdown()` drains +in-flight resolves + pending flush, then does a final save. + +--- + +## 8. Resolver & hydrator callbacks + +Hooks the SDK wires in so the limiter can fill its cache without knowing about +HTTP: + +- **resolver** `(id_type, identifier) -> org_id | None`, `id_type ∈ {"network","device"}`. + Called for an unresolved id. Guarded by a `pending_lookups` set so the same id + isn't resolved twice concurrently. +- **hydrator** `(org_id) -> void`. Called **once per org** (tracked in + `hydrated_orgs`) after first resolution, to bulk-register all of that org's + networks/devices via the register APIs. + +--- + +## Conformance + +`smartflow-vectors.json` encodes deterministic cases for each section above +(bucket math with injected timestamps, URL resolution, AIMD, learning, cache +freshness). An implementation is conformant if it reproduces every vector's +expected output. See that file's `notes` field per case. diff --git a/docs/generation-report.md b/docs/generation-report.md new file mode 100644 index 00000000..3196bdd3 --- /dev/null +++ b/docs/generation-report.md @@ -0,0 +1,80 @@ +# Generation Report + +## 2026-07-16 | Library v4.3.1 | API 1.72.0 + + +No Python keyword parameter conflicts detected. + + +## 2026-07-09 | Library v4.3.0 | API 1.72.0 + + +No Python keyword parameter conflicts detected. + + +## 2026-07-09 | Library v4.3.0 | API 1.72.0 + + +No Python keyword parameter conflicts detected. + + +## 2026-07-08 | Library v4.3.0b1 | API 1.72.0-beta.1 + + +No Python keyword parameter conflicts detected. + + +## 2026-07-01 | Library v4.3.0b0 | API 1.72.0-beta.0 + + +No Python keyword parameter conflicts detected. + + +## 2026-06-24 | Library v4.2.0b3 | API 1.71.0-beta.3 + + +No Python keyword parameter conflicts detected. + + +## 2026-06-17 | Library v4.2.0b2 | API 1.71.0-beta.2 + + +No Python keyword parameter conflicts detected. + + +## 2026-06-10 | Library v4.2.0b1 | API 1.71.0-beta.1 + + +No Python keyword parameter conflicts detected. + + +## 2026-06-10 | Library v4.1.0b1 | API 1.71.0-beta.1 + + +No Python keyword parameter conflicts detected. + + +## 2026-05-27 | Library v4.1.0b3 | API 1.70.0-beta.3 + + +No Python keyword parameter conflicts detected. + + +## 2026-05-20 | Library v4.1.0b2 | API 1.70.0-beta.2 + + +No Python keyword parameter conflicts detected. + + +## 2026-05-14 | Library v4.1.0b1 | API 1.70.0-beta.1 + + +No Python keyword parameter conflicts detected. + + +## 2026-05-08 | Library v4.1.0b0 | API 1.70.0-beta.0 + + +No Python keyword parameter conflicts detected. + + diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..76e0862c --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,65 @@ +# Releasing + +## Automated releases + +A GitHub Action polls [meraki/openapi releases](https://github.com/meraki/openapi/releases) daily at 14:00 UTC. When a new release is detected, it triggers library regeneration, runs tests, and publishes automatically. + +## Versioning rules + +1. **Source of truth is the target branch.** The current library minor version is read from `pyproject.toml` on `main` (GA), `beta` (pre-releases), or `httpx` (dev pre-releases). + +2. **GA releases go to `main`.** Bump library minor, reset patch: `X.Y.Z` becomes `X.(Y+1).0`. + +3. **Beta releases go to `beta`.** Library patch and beta number mirror the API release: + - Library patch = API patch (e.g. API `1.81.2-beta.0` → library patch `2`) + - Library beta number = API beta number (e.g. API `1.81.2-beta.3` → `bN` where N=3) + - New API minor bumps library minor (e.g. API minor 80 → 81 bumps library minor) + +4. **Dev releases go to `httpx`.** Same versioning logic as beta (mirrors API patch and beta number). Triggered by the same prerelease tags. The generator runs with the `-b httpx` flag and a test org ID for beta endpoint access. + +5. **API version is the OpenAPI tag with `v` stripped.** `v1.71.0-beta.2` becomes `1.71.0-beta.2`. + +6. **Manual releases can happen independently.** Patches, features, or fixes can be released at any time. They update `pyproject.toml` on their target branch, and the next automated release uses that as its baseline. + +7. **GA always bumps minor, never patch.** Even after manual patches (e.g. `3.1.1` becomes `3.2.0`, not `3.1.2`). + +8. **No version slot is reused.** If a manual release occupies the next expected version, the automated release takes the one after it. + +## Examples + +Starting from `main` at `3.1.0`, `beta` at `3.1.0`, `httpx` at `4.0.0`: + +| Event | API Version | Library Version | Branch | +| --- | --- | --- | --- | +| `v1.71.0-beta.0` detected | `1.71.0-beta.0` | `3.2.0b0` | `beta` | +| (same trigger) | `1.71.0-beta.0` | `4.1.0b0` | `httpx` | +| `v1.71.0-beta.1` detected | `1.71.0-beta.1` | `3.2.0b1` | `beta` | +| (same trigger) | `1.71.0-beta.1` | `4.1.0b1` | `httpx` | +| `v1.71.1-beta.0` detected | `1.71.1-beta.0` | `3.2.1b0` | `beta` | +| (same trigger) | `1.71.1-beta.0` | `4.1.1b0` | `httpx` | +| Manual bugfix (no API change) | `1.70.0` | `3.1.1` | `main` | +| `v1.71.0` detected (GA) | `1.71.0` | `3.2.0` | `main` | +| `v1.72.0-beta.0` detected | `1.72.0-beta.0` | `3.3.0b0` | `beta` | +| (same trigger) | `1.72.0-beta.0` | `4.2.0b0` | `httpx` | + +## Beta/dev release additional step + +When a new prerelease is detected, the `enable-early-access.yml` workflow runs first (waited on synchronously). This enables early access features on the test organizations so the generated beta and dev SDKs can be tested against them. + +## Changelog management + +`CHANGELOG.md` is generated by [towncrier](https://towncrier.readthedocs.io/) from news fragments in `changelog.d/`. Contributors add one fragment per user-facing change (see [CONTRIBUTING.md](../CONTRIBUTING.md) for the format); nobody edits `CHANGELOG.md` directly. + +The `create-release.yml` workflow runs `towncrier build --yes --version ` after version validation and before `gh release create`. The version comes from the same `pyproject.toml` read that drives the release tag, so there is no separate towncrier version to keep in sync. The build renders the fragments into `CHANGELOG.md`, deletes the consumed fragments, and commits both changes to the release branch before the GitHub release is cut. + +This runs only at release time, not during regeneration. `regenerate-library.yml` rewrites generated code under `meraki/` but never touches `changelog.d/` or `CHANGELOG.md`, so fragments added on `main`, `beta`, or `httpx` survive regeneration PRs and are consumed only by the next release on that branch. Each branch keeps its own fragments and accumulates them independently across the three-branch model. + +## Release branches and PRs + +| Stage | Source branch | Release branch | PR target | +| --- | --- | --- | --- | +| GA | `main` | `release` | `main` | +| Beta | `beta` | `beta-release` | `beta` | +| Dev | `httpx` | `httpx-release` | `httpx` | + +All release PRs are set to auto-merge (squash). Once merged and tests pass, the `create-release.yml` workflow creates a GitHub release, which triggers `publish-release.yml` to publish to PyPI. diff --git a/docs/smartflow-vectors.json b/docs/smartflow-vectors.json new file mode 100644 index 00000000..f2245270 --- /dev/null +++ b/docs/smartflow-vectors.json @@ -0,0 +1,230 @@ +{ + "$comment": "Conformance vectors for the Smart Flow rate limiter. See SMARTFLOW.md. All time is injected (monotonic seconds / epoch seconds) so cases are deterministic; no wall-clock is read. An implementation is conformant if it reproduces every expected output. Floats compared with tolerance 1e-9.", + "version": 1, + + "token_bucket": { + "$comment": "rate in tokens/sec, capacity in tokens. Each step calls acquire() at the given monotonic 'now'. tokens_after and wait are the post-acquire state. See SMARTFLOW.md section 1.", + "cases": [ + { + "name": "single_acquire_full_bucket", + "rate": 10.0, "capacity": 10.0, "init_tokens": 10.0, "init_last": 0.0, + "steps": [ + { "now": 0.0, "tokens_after": 9.0, "wait": 0.0 } + ], + "notes": "elapsed 0, no refill, deduct 1 from full bucket, no wait." + }, + { + "name": "burst_beyond_capacity_goes_negative", + "rate": 10.0, "capacity": 10.0, "init_tokens": 10.0, "init_last": 0.0, + "steps": [ + { "now": 0.0, "tokens_after": 9.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 8.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 7.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 6.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 5.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 4.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 3.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 2.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 1.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 0.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": -1.0, "wait": 0.1 }, + { "now": 0.0, "tokens_after": -2.0, "wait": 0.2 }, + { "now": 0.0, "tokens_after": -3.0, "wait": 0.3 }, + { "now": 0.0, "tokens_after": -4.0, "wait": 0.4 }, + { "now": 0.0, "tokens_after": -5.0, "wait": 0.5 } + ], + "notes": "Deduct unconditionally; deficit is the reservation. 11th call waits 0.1s, each subsequent +0.1s. Do NOT clamp at zero." + }, + { + "name": "refill_after_elapsed_time", + "rate": 10.0, "capacity": 10.0, "init_tokens": -5.0, "init_last": 0.0, + "steps": [ + { "now": 1.0, "tokens_after": 4.0, "wait": 0.0 } + ], + "notes": "1s elapsed refills 1*10=10 tokens: -5+10=5, capped at capacity 10 (not exceeded here), deduct 1 -> 4." + }, + { + "name": "refill_clamped_to_capacity", + "rate": 10.0, "capacity": 10.0, "init_tokens": 5.0, "init_last": 0.0, + "steps": [ + { "now": 100.0, "tokens_after": 9.0, "wait": 0.0 } + ], + "notes": "Huge elapsed would add 1000 tokens but refill is min(capacity, ...) = 10, then deduct 1 -> 9." + }, + { + "name": "global_bucket_defaults", + "rate": 100.0, "capacity": 100.0, "init_tokens": 100.0, "init_last": 0.0, + "steps": [ + { "now": 0.0, "tokens_after": 99.0, "wait": 0.0 } + ], + "notes": "Global per-source-IP bucket: rate 100, capacity int(100)=100." + } + ] + }, + + "rate_setter_floor": { + "$comment": "Setting rate clamps to a floor of 0.5. SMARTFLOW.md section 1.", + "cases": [ + { "name": "above_floor", "set_rate": 3.0, "expected_rate": 3.0 }, + { "name": "at_floor", "set_rate": 0.5, "expected_rate": 0.5 }, + { "name": "below_floor", "set_rate": 0.2, "expected_rate": 0.5 }, + { "name": "zero_clamped", "set_rate": 0.0, "expected_rate": 0.5 } + ] + }, + + "resolve_org": { + "$comment": "resolve_org(url) given a cache of network_to_org / serial_to_org. SMARTFLOW.md section 3.", + "network_to_org": { "N_1": "O_2" }, + "serial_to_org": { "Q2XX-XXXX-XXXX": "O_3" }, + "cases": [ + { "name": "explicit_org", "url": "/organizations/O_1/networks", "expected_org": "O_1" }, + { "name": "network_cached", "url": "/networks/N_1/clients", "expected_org": "O_2" }, + { "name": "network_uncached", "url": "/networks/N_9/clients", "expected_org": null }, + { "name": "device_cached", "url": "/devices/Q2XX-XXXX-XXXX/lldpCdp", "expected_org": "O_3" }, + { "name": "device_uncached", "url": "/devices/Q2ZZ-ZZZZ-ZZZZ/clients", "expected_org": null }, + { "name": "org_precedence_over_net", "url": "/organizations/O_1/networks/N_1", "expected_org": "O_1" }, + { "name": "no_identifier", "url": "/administered/identities/me", "expected_org": null } + ] + }, + + "is_unresolved_scoped_url": { + "$comment": "Purely URL-structural: true if a network/device component is present AND no explicit /organizations/. Independent of cache. SMARTFLOW.md section 5.", + "cases": [ + { "name": "org_url", "url": "/organizations/O_1/networks", "expected": false }, + { "name": "org_and_network", "url": "/organizations/O_1/networks/N_1", "expected": false }, + { "name": "network_only", "url": "/networks/N_9/clients", "expected": true }, + { "name": "device_only", "url": "/devices/Q2ZZ-ZZZZ-ZZZZ/clients", "expected": true }, + { "name": "no_scope", "url": "/administered/identities/me", "expected": false } + ] + }, + + "aimd": { + "$comment": "on_rate_limited multiplies rate by 0.7; on_success adds 0.2 (org) / 0.5 (global) capped at configured rate. Rate floor 0.5 applies via setter. SMARTFLOW.md section 5.", + "cases": [ + { + "name": "org_decrease", + "op": "on_rate_limited", "target": "org", "start_rate": 10.0, "expected_rate": 7.0, + "notes": "10 * 0.7 = 7.0" + }, + { + "name": "org_increase_below_cap", + "op": "on_success", "target": "org", "configured_rate": 10.0, "start_rate": 7.0, "expected_rate": 7.2, + "notes": "min(10, 7 + 0.2) = 7.2" + }, + { + "name": "org_increase_hits_cap", + "op": "on_success", "target": "org", "configured_rate": 10.0, "start_rate": 9.95, "expected_rate": 10.0, + "notes": "min(10, 9.95 + 0.2) = 10.0 (clamped)" + }, + { + "name": "org_increase_noop_at_cap", + "op": "on_success", "target": "org", "configured_rate": 10.0, "start_rate": 10.0, "expected_rate": 10.0, + "notes": "rate not < configured, no change" + }, + { + "name": "global_decrease", + "op": "on_rate_limited", "target": "global", "start_rate": 100.0, "expected_rate": 70.0, + "notes": "resolves to no known org and not an unresolved-scoped url -> global penalized. 100 * 0.7 = 70." + }, + { + "name": "global_increase_below_cap", + "op": "on_success", "target": "global", "configured_rate": 100.0, "start_rate": 70.0, "expected_rate": 70.5, + "notes": "min(100, 70 + 0.5) = 70.5" + }, + { + "name": "decrease_respects_floor", + "op": "on_rate_limited", "target": "org", "start_rate": 0.6, "expected_rate": 0.5, + "notes": "0.6 * 0.7 = 0.42, clamped up to floor 0.5 by the rate setter." + }, + { + "name": "rate_limited_unresolved_scoped_skips_global", + "op": "on_rate_limited", "target": "global", "url": "/networks/N_9/clients", + "org_resolvable": false, "start_rate": 100.0, "expected_rate": 100.0, + "notes": "Unresolved network/device url: org unknown but specific; global bucket must NOT be penalized. No change." + } + ] + }, + + "learn_from_response": { + "$comment": "Given url + body, which mappings change. org resolved from url first, else body.organizationId, else body.organization.id. Only changed mappings count. SMARTFLOW.md section 6.", + "cases": [ + { + "name": "org_and_network_from_url", + "url": "/organizations/O_1/networks/N_1", "body": {}, + "expected_network_to_org": { "N_1": "O_1" }, "expected_serial_to_org": {}, + "changed_networks": 1, "changed_devices": 0 + }, + { + "name": "org_and_device_from_url", + "url": "/organizations/O_1/devices/Q2AA-AAAA-AAAA", "body": {}, + "expected_network_to_org": {}, "expected_serial_to_org": { "Q2AA-AAAA-AAAA": "O_1" }, + "changed_networks": 0, "changed_devices": 1 + }, + { + "name": "org_from_body_organizationId", + "url": "/networks/N_1/clients", "body": { "organizationId": "O_5" }, + "expected_network_to_org": { "N_1": "O_5" }, "expected_serial_to_org": {}, + "changed_networks": 1, "changed_devices": 0 + }, + { + "name": "org_from_body_organization_id", + "url": "/networks/N_2/clients", "body": { "organization": { "id": "O_6" } }, + "expected_network_to_org": { "N_2": "O_6" }, "expected_serial_to_org": {}, + "changed_networks": 1, "changed_devices": 0 + }, + { + "name": "nested_network_id_from_body", + "url": "/organizations/O_1/somethingElse", "body": { "network": { "id": "N_3" } }, + "expected_network_to_org": { "N_3": "O_1" }, "expected_serial_to_org": {}, + "changed_networks": 1, "changed_devices": 0 + }, + { + "name": "body_serial_and_networkId", + "url": "/organizations/O_1/x", "body": { "networkId": "N_4", "serial": "Q2BB-BBBB-BBBB" }, + "expected_network_to_org": { "N_4": "O_1" }, "expected_serial_to_org": { "Q2BB-BBBB-BBBB": "O_1" }, + "changed_networks": 1, "changed_devices": 1 + }, + { + "name": "no_org_anywhere_noop", + "url": "/administered/identities/me", "body": {}, + "expected_network_to_org": {}, "expected_serial_to_org": {}, + "changed_networks": 0, "changed_devices": 0 + }, + { + "name": "unchanged_mapping_not_counted", + "url": "/organizations/O_1/networks/N_1", "body": {}, + "preexisting_network_to_org": { "N_1": "O_1" }, + "expected_network_to_org": { "N_1": "O_1" }, "expected_serial_to_org": {}, + "changed_networks": 0, "changed_devices": 0, + "notes": "Mapping already equals O_1, so no change is counted (dirty not incremented)." + } + ] + }, + + "cache_freshness": { + "$comment": "Load-time freshness decision. now and saved_ts are epoch seconds (injected). ttl in seconds; null = never expire. SMARTFLOW.md section 7.", + "cases": [ + { "name": "fresh_within_ttl", "now": 1000000.0, "saved_ts": 999000.0, "ttl": 604800.0, "expected_fresh": true, + "notes": "age 1000s < ttl -> load, cache_fresh true." }, + { "name": "expired_past_ttl", "now": 2000000.0, "saved_ts": 1000000.0, "ttl": 604800.0, "expected_fresh": false, + "notes": "age 1,000,000s > 604800 -> ignore file, rebuild." }, + { "name": "saved_at_missing", "now": 1000000.0, "saved_ts": null, "ttl": 604800.0, "expected_fresh": false, + "notes": "No/unparseable saved_at -> expired." }, + { "name": "ttl_none_never_expires", "now": 9999999999.0, "saved_ts": 1.0, "ttl": null, "expected_fresh": true, + "notes": "ttl None disables expiry regardless of age." }, + { "name": "exactly_at_ttl_boundary", "now": 604800.0, "saved_ts": 0.0, "ttl": 604800.0, "expected_fresh": true, + "notes": "age == ttl is NOT > ttl, so still fresh (boundary inclusive)." } + ] + }, + + "saved_at_format": { + "$comment": "saved_at is UTC strftime %Y-%m-%dT%H:%M:%SZ. Parser returns epoch seconds or null. SMARTFLOW.md section 7.", + "cases": [ + { "name": "valid", "value": "2026-07-08T12:00:00Z", "expected_epoch": 1783512000.0 }, + { "name": "missing_z", "value": "2026-07-08T12:00:00", "expected_epoch": null }, + { "name": "with_offset", "value": "2026-07-08T12:00:00+00:00", "expected_epoch": null }, + { "name": "not_a_string", "value": 12345, "expected_epoch": null }, + { "name": "garbage", "value": "not-a-date", "expected_epoch": null } + ] + } +} diff --git a/docs/superpowers/plans/2026-07-08-httpx-to-main-migration.md b/docs/superpowers/plans/2026-07-08-httpx-to-main-migration.md new file mode 100644 index 00000000..d42360be --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-httpx-to-main-migration.md @@ -0,0 +1,231 @@ +# httpx → main Final Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Merge the httpx-based transport rewrite (branch `origin/httpx`) into `main`, making main's tree exactly httpx while preserving both branches' commit history, then regenerate the API surface to GA. + +**Architecture:** A no-fast-forward merge records httpx as a second parent (preserving both histories in the DAG), then `git read-tree --reset` forces the working tree to be byte-identical to httpx (no 155-file conflict resolution). The one genuinely main-only feature not already on httpx (X-Request-Id-on-5xx) is replayed via cherry-pick. The generated `meraki/api` code and version strings arrive as beta throwaway and are regenerated to GA by the maintainer. + +**Tech Stack:** git (merge, read-tree, cherry-pick), Python 3, uv, pytest, ruff. + +## Global Constraints + +- Base branch for the PR: `main`. Work branch: `migrate/httpx`. +- httpx tip = `origin/httpx` (`6b20c2a`). main tip = `ec91975`. Merge base = `6d3dd19`. +- Tree after merge MUST equal `origin/httpx` byte-for-byte (verified via `git diff --stat origin/httpx` = empty). +- Both parent histories MUST remain reachable (merge commit has exactly 2 parents). +- Do NOT hand-port app-id/bearer or delete()-params — both already exist on httpx tip. +- Do NOT cherry-pick dangling `6c8bf68` — it is a duplicate of httpx tip's app-id/bearer feature. +- The maintainer regenerates `meraki/api`, `meraki/aio/api`, and version strings AFTER the merge — the plan does not touch generated code or versions. +- Pre-existing stashes `stash@{0}` / `stash@{1}` on main are the maintainer's — do not pop or drop them. + +--- + +### Task 1: Create migration branch and history-preserving merge + +**Files:** +- No file edits; git plumbing only. Result: `migrate/httpx` branch with a 2-parent merge commit whose tree == `origin/httpx`. + +**Interfaces:** +- Consumes: `main` (current), `origin/httpx` (fetched). +- Produces: branch `migrate/httpx` at a merge commit `M` with parents `[main_tip, origin/httpx_tip]` and tree identical to `origin/httpx`. + +- [ ] **Step 1: Fetch and confirm clean starting state** + +```bash +cd "c:/Users/jkuchta/Work/_Repos/meraki/dashboard-api-python" +git fetch origin +git checkout main +git status --short # expect: empty (stashes are fine, not shown here) +git rev-parse --short main origin/httpx +``` +Expected: working tree clean; `main` = `ec91975...`, `origin/httpx` = `6b20c2a...`. + +- [ ] **Step 2: Create the migration branch** + +```bash +git checkout -b migrate/httpx main +``` +Expected: `Switched to a new branch 'migrate/httpx'`. + +- [ ] **Step 3: Start the merge without committing** + +```bash +git merge --no-commit --no-ff origin/httpx +``` +Expected: reports conflicts (155 files). This is expected and ignored — the next step overwrites the tree wholesale. Do NOT resolve conflicts by hand. + +- [ ] **Step 4: Force index + working tree to exactly httpx** + +```bash +git read-tree -u --reset origin/httpx +``` +Expected: no output. Index and working tree now match `origin/httpx` byte-for-byte; merge is still in progress (MERGE_HEAD set). + +- [ ] **Step 5: Verify tree equals httpx before committing** + +```bash +git diff --stat origin/httpx # expect: EMPTY (identical trees) +git diff --stat --cached origin/httpx # expect: EMPTY +``` +Expected: both empty. If not empty, STOP — do not commit; investigate. + +- [ ] **Step 6: Commit the merge** + +```bash +git commit --no-edit +``` +Expected: a merge commit is created. + +- [ ] **Step 7: Verify 2-parent merge and history preservation** + +```bash +git log -1 --format='%h parents: %p' # expect 2 parent hashes +git rev-parse --short 'HEAD^1' 'HEAD^2' # expect ec91975 (main), 6b20c2a (httpx) +git merge-base --is-ancestor ec91975 HEAD && echo "MAIN HISTORY PRESERVED" +git merge-base --is-ancestor 6b20c2a HEAD && echo "HTTPX HISTORY PRESERVED" +git rev-list --count origin/httpx..HEAD^1 # expect 83 (main-only commits reachable) +``` +Expected: 2 parents (main tip + httpx tip), both "PRESERVED" lines print, count = 83. + +- [ ] **Step 8: No commit here** — the merge commit from Step 6 is the deliverable. Proceed to Task 2. + +--- + +### Task 2: Replay X-Request-Id-on-5xx (the one missing feature) + +**Files:** +- Modify (via cherry-pick `18e47b1`): `meraki/session/base.py`, `meraki/session/async_.py` +- Test (via cherry-pick): `tests/unit/test_rest_session.py`, `tests/unit/test_aio_rest_session.py`, `tests/unit/conftest.py` +- Add (via cherry-pick): `changelog.d/+log-x-request-id-on-5xx.added.md` + +**Interfaces:** +- Consumes: merge commit from Task 1 (httpx `session/` layout). +- Produces: X-Request-Id logging on 5xx responses in both sync and async sessions, with unit tests. `18e47b1` was built on an older base (`5da8e29`), so a conflict in `base.py`/`async_.py` is possible. + +- [ ] **Step 1: Confirm the feature is genuinely absent at the merge tip** + +```bash +git show HEAD:meraki/session/base.py | grep -niE "x-request-id|request_id|requestId" || echo "ABSENT — cherry-pick needed" +``` +Expected: "ABSENT — cherry-pick needed". + +- [ ] **Step 2: Cherry-pick the feature commit** + +```bash +git cherry-pick 18e47b1 +``` +Expected: either clean success, OR a conflict in `meraki/session/base.py` / `meraki/session/async_.py`. + +- [ ] **Step 3: If conflict — resolve** + +Open each conflicted file. The intent: on a 5xx response, read the `X-Request-Id` response header and include it in the error log line. Keep httpx's surrounding code (its `session/base.py` structure), inserting only the request-id logging from the incoming patch. After resolving: + +```bash +git add meraki/session/base.py meraki/session/async_.py +git cherry-pick --continue +``` +Expected: cherry-pick completes. If the changelog/test files also conflict, take the incoming (`--theirs`) version for the new changelog fragment and merge test additions. + +- [ ] **Step 4: Run the cherry-picked tests to verify they pass** + +```bash +uv run pytest tests/unit/test_rest_session.py tests/unit/test_aio_rest_session.py -v -k "request_id or requestId or 5xx or request_id_log" 2>&1 | tail -30 +``` +Expected: the X-Request-Id tests from `18e47b1` PASS. If the `-k` filter matches nothing, run the two files in full and confirm the request-id tests are present and green. + +- [ ] **Step 5: No separate commit** — `git cherry-pick` already created the commit. Proceed to Task 3. + +--- + +### Task 3: Full verification before handoff + +**Files:** +- No edits. Verification only. + +**Interfaces:** +- Consumes: `migrate/httpx` after Tasks 1-2. +- Produces: evidence the merged tree imports and the httpx test suite passes on top of the merge. (Generated `meraki/api` is still beta here — regeneration is the maintainer's step, done after this plan.) + +- [ ] **Step 1: Sync dependencies to the merged lockfile** + +```bash +uv sync +``` +Expected: environment resolves against httpx's `uv.lock` (httpx, not requests). No errors. + +- [ ] **Step 2: Import smoke test (transport swap sanity)** + +```bash +uv run python -c "import meraki; d = meraki.DashboardAPI.__init__; print('sync import OK'); import meraki.aio; print('async import OK')" +``` +Expected: both "OK" lines print, no ImportError/ModuleNotFoundError referencing `rest_session`. + +- [ ] **Step 3: Run the unit + session test suite** + +```bash +uv run pytest tests/unit -q 2>&1 | tail -30 +``` +Expected: all pass. These are httpx's own tests plus the cherry-picked request-id tests. + +- [ ] **Step 4: Run generator tests** + +```bash +uv run pytest tests/generator -q 2>&1 | tail -20 +``` +Expected: pass (validates the httpx generator/scaffolding landed intact). + +- [ ] **Step 5: Lint** + +```bash +uv run ruff check meraki tests 2>&1 | tail -20 +``` +Expected: clean, or only pre-existing httpx-branch findings (compare against `git show origin/httpx` if unsure). + +- [ ] **Step 6: Record verification result** + +If any step fails, STOP and report which — do not open the PR. If all pass, the branch is ready for the maintainer's regeneration step and PR. + +--- + +### Task 4: Handoff for regeneration + PR (maintainer-owned) + +**Files:** +- Maintainer regenerates: `meraki/api/**`, `meraki/aio/api/**`, `meraki/_version.py`, `meraki/__init__.py` (`__api_version__`). + +**Interfaces:** +- Consumes: verified `migrate/httpx` branch. +- Produces: GA-versioned generated code, then a PR into `main`. + +- [ ] **Step 1: Regenerate API surface to GA (maintainer runs the generator)** + +The generated code and version strings on the branch are currently httpx's beta values (`4.3.0b1`, `1.72.0-beta.1`). Maintainer runs the generator against the GA spec and sets the GA version. This plan does not prescribe the generator command — it is the maintainer's existing workflow. + +- [ ] **Step 2: Re-run verification after regeneration** + +```bash +uv run pytest tests/unit tests/generator -q 2>&1 | tail -20 +uv run python -c "import meraki, meraki.aio; print('import OK')" +``` +Expected: pass. Confirms regenerated code matches httpx session call signatures. + +- [ ] **Step 3: Push and open the PR** + +```bash +git push -u origin migrate/httpx +``` +Then open a PR `migrate/httpx -> main`. PR body should note: transport rewrite (requests → httpx), both histories preserved, X-Request-Id-on-5xx replayed, API regenerated to GA. + +- [ ] **Step 4: Do NOT delete branch `httpx`** until the PR is merged and the team confirms the dev track's next steps. + +--- + +## Appendix: PyPI publish outage (root-caused during this migration) + +**Symptom:** Every beta/dev release after 2026-06-10 (`3.2.0b3`, `3.3.0b0`, `4.2.0b1/b2/b3`, `4.3.0b0/b1`) created a GitHub Release but never published to PyPI. GA releases (`3.2.0`, `3.3.0`) published fine. + +**Root cause:** `release`-triggered workflows run the workflow file from the **tagged commit's tree**, not the default branch. PRs #407 (`db0ddfe`, beta) and #408 (`1f84868`, httpx) deleted `publish-release.yml` from beta+httpx on 2026-06-10 as "unreachable orchestration." The first beta cut 7 minutes later (`4.2.0b1`) had no publish workflow in its tree, so the `release: created` event had nothing to run. GA survived because GA tags come off `main`, which kept the file. Truth table (publish-release.yml in tag tree ⟺ on PyPI) held with zero exceptions. + +**Forward fix (applied 2026-07-08):** restored `publish-release.yml` to `httpx` (`acbfd6a`), `beta` (`7fd69d5`), and `migrate/httpx` (`c7800d2`, part of this migration). `create-release`/`watch-openapi-release`/`regenerate-library`/`enable-early-access` correctly stay single-sourced on `main` (their `workflow_run`/`schedule`/`workflow_dispatch` triggers use main's copy; only `release`-triggered workflows must live on every release-cutting branch). + +**Backfill:** decided NOT to backfill the 8 orphaned PyPI versions — superseded quickly, and PyPI uploads are permanent/non-reusable. Gaps remain by choice. diff --git a/docs/superpowers/plans/2026-07-15-write-query-parameters.md b/docs/superpowers/plans/2026-07-15-write-query-parameters.md new file mode 100644 index 00000000..ef79be22 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-write-query-parameters.md @@ -0,0 +1,218 @@ +# Write-operation Query Parameters Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve OpenAPI query parameters on generated POST, PUT, and PATCH methods, including action-batch methods. + +**Architecture:** The generator will collect query parameters independently of the HTTP method. Standard and asynchronous methods will pass them through trailing-compatible session helper arguments; batch methods will encode them into the action resource with the already-installed `httpx` dependency. + +**Tech Stack:** Python 3.11+, Jinja2, httpx, pytest + +## Global Constraints + +- Add exactly one regression test. +- Cover standard, asynchronous, and action-batch generation. +- Do not add endpoint-specific exceptions or regenerate generated API modules. +- Do not add a dependency or perform unrelated refactoring. + +--- + +### Task 1: Generate and transport query parameters on write operations + +**Files:** +- Modify: `tests/generator/test_generator_audit.py` +- Modify: `generator/generate_library.py` +- Modify: `generator/batch_function_template.jinja2` +- Modify: `generator/batch_class_template.jinja2` +- Modify: `meraki/session/sync.py` +- Modify: `meraki/session/async_.py` + +**Interfaces:** +- Consumes: OpenAPI parameters where `in == "query"`, existing Jinja templates, and existing `SessionBase.request(..., **kwargs)` support for `params`. +- Produces: `RestSession.post/put/patch(metadata, url, json=None, params=None)`, matching async signatures, generated write calls using `params=params`, and batch action resources containing the encoded query string. + +- [ ] **Step 1: Add the single failing regression test** + +Append this synthetic spec and test before the module's `if __name__ == "__main__"` block in `tests/generator/test_generator_audit.py`: + +```python +def _write_query_spec(): + return { + "paths": { + "/things": { + "post": { + "operationId": "claimThings", + "tags": ["organizations"], + "summary": "Claim things", + "parameters": [ + { + "name": "validate", + "in": "query", + "required": False, + "schema": {"type": "boolean"}, + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string", "description": "Thing name"} + }, + } + } + } + }, + } + } + } + } + + +def test_write_operations_generate_query_params_for_all_clients(): + clear_cache() + spec = _write_query_spec() + section = {"/things": {"post": spec["paths"]["/things"]["post"]}} + output = io.StringIO() + async_output = io.StringIO() + batch_output = io.StringIO() + + generate_library.generate_standard_and_async_functions( + _jinja_env(), TEMPLATE_DIR, section, output, async_output, spec + ) + generate_library.generate_action_batch_functions( + _jinja_env(), + TEMPLATE_DIR, + section, + batch_output, + [{"summary": "Claim things", "operation": "create"}], + spec, + ) + + for rendered in (output.getvalue(), async_output.getvalue()): + assert 'query_params = ["validate", ]' in rendered + assert "self._session.post(metadata, resource, payload, params=params)" in rendered + + batch_rendered = batch_output.getvalue() + assert 'query_params = ["validate", ]' in batch_rendered + assert 'resource += f"?{httpx.QueryParams(params)}"' in batch_rendered +``` + +- [ ] **Step 2: Run the regression test and verify RED** + +Run: + +```powershell +uv run pytest tests/generator/test_generator_audit.py::test_write_operations_generate_query_params_for_all_clients -q +``` + +Expected: FAIL because generated POST methods do not define query parameters or pass `params`, and batch resources do not contain a query string. + +- [ ] **Step 3: Collect query parameters for all generated methods** + +In `generate_standard_and_async_functions`, initialize query parameters before method dispatch and restrict array handling to query arrays: + +```python +query_params = return_params(operation, all_params, ["query"]) +array_params = {k: v for k, v in query_params.items() if v["type"] == "array"} +body_params = path_params = {} +``` + +Leave each method branch responsible only for `body_params` and `path_params`. For POST, PUT, and PATCH, build the call without changing body-only output: + +```python +args = "metadata, resource" +if body_params: + args += ", payload" +if query_params: + args += ", params=params" +call_line = f"return self._session.{method}({args})" +``` + +Apply the same query and query-array initialization in `generate_action_batch_functions` before its method-specific body handling. + +- [ ] **Step 4: Encode batch query parameters in the resource** + +Add the existing runtime dependency to `generator/batch_class_template.jinja2`: + +```python +import urllib + +import httpx +``` + +After query-array normalization in `generator/batch_function_template.jinja2`, append query parameters to the batch resource: + +```jinja2 +{% if query_params|length > 0 %} +resource += f"?{httpx.QueryParams(params)}" + +{% endif %} +``` + +- [ ] **Step 5: Pass query parameters through synchronous session helpers** + +Change POST, PUT, and PATCH in `meraki/session/sync.py` without breaking existing positional JSON callers: + +```python +def post(self, metadata, url, json=None, params=None): + metadata["method"] = "POST" + metadata["url"] = url + metadata["params"] = params + metadata["json"] = json + response = self.request(metadata, "POST", url, params=params, json=json) +``` + +Apply the same trailing `params=None`, metadata assignment, and request keyword to `put` and `patch`. + +- [ ] **Step 6: Pass query parameters through asynchronous session helpers** + +Make the equivalent changes in `meraki/session/async_.py`: + +```python +async def post(self, metadata, url, json=None, params=None): + metadata["method"] = "POST" + metadata["url"] = url + metadata["params"] = params + metadata["json"] = json + response = await self.request(metadata, "POST", url, params=params, json=json) +``` + +Apply the same trailing argument and propagation to async `put` and `patch`. + +- [ ] **Step 7: Run focused tests and verify GREEN** + +Run: + +```powershell +uv run pytest tests/generator/test_generator_audit.py tests/unit/test_rest_session.py tests/unit/test_aio_rest_session.py -q +``` + +Expected: all tests pass. + +- [ ] **Step 8: Run formatting and the full test suite** + +Run: + +```powershell +uv run ruff format --check generator meraki tests +uv run ruff check generator meraki tests +uv run pytest -q +``` + +Expected: all commands exit 0 with no formatting, lint, or test failures. + +- [ ] **Step 9: Review and commit** + +Run: + +```powershell +git diff --check +git diff --stat +git add generator/generate_library.py generator/batch_function_template.jinja2 generator/batch_class_template.jinja2 meraki/session/sync.py meraki/session/async_.py tests/generator/test_generator_audit.py docs/superpowers/plans/2026-07-15-write-query-parameters.md +git commit -m "fix: preserve query params on write operations" +``` + +Expected: one implementation commit containing the generator, runtime transport, regression test, and plan. diff --git a/docs/superpowers/specs/2026-07-15-write-query-parameters-design.md b/docs/superpowers/specs/2026-07-15-write-query-parameters-design.md new file mode 100644 index 00000000..f29b5f9d --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-write-query-parameters-design.md @@ -0,0 +1,21 @@ +# Write-operation query parameters + +## Problem + +The generator documents query parameters on POST, PUT, and PATCH operations but only serializes request-body parameters. Those query parameters are silently dropped. Action-batch methods have the same omission because their action resource never receives a query string. + +## Design + +- Collect query parameters for every generated HTTP method. +- Pass both `params` and `json` through synchronous and asynchronous POST, PUT, and PATCH session helpers. +- For action batches, append encoded query parameters to the action `resource`, because the action format has no separate query-parameter field. +- Keep existing GET and DELETE behavior unchanged. +- Keep keyword validation aware of both query and body parameters. + +## Test + +Add one hermetic generator regression test using a synthetic POST operation with a boolean query parameter and a JSON body property. The test will verify that standard and asynchronous generated clients pass both `params` and `payload`, and that the batch client includes the encoded query parameter in its resource. + +## Scope + +No endpoint-specific exceptions, generated API regeneration, new dependency, or unrelated refactoring is included. diff --git a/examples/aio_get_pages_iterator.py b/examples/aio_get_pages_iterator.py new file mode 100644 index 00000000..d4bd6659 --- /dev/null +++ b/examples/aio_get_pages_iterator.py @@ -0,0 +1,140 @@ +import argparse +import asyncio +import time + +import meraki.aio + +# Either input your API key below, or set an environment variable +# for example, in Terminal on macOS: export MERAKI_DASHBOARD_API_KEY=66839003d2861bc302b292eb66d3b247709f2d0d +api_key = "" + +ORGANIZATION_ID = "" +NETWORK_ID = "" + + +def timeit(func): + async def process(func, *args, **params): + if asyncio.iscoroutinefunction(func): + print("this function is a coroutine: {}".format(func.__name__)) + return await func(*args, **params) + else: + print("this is not a coroutine") + return func(*args, **params) + + async def helper(*args, **params): + print("{}.time".format(func.__name__)) + start = time.time() + result = await process(func, *args, **params) + + # Test normal function route... + # result = await process(lambda *a, **p: print(*a, **p), *args, **params) + + print(">>>", time.time() - start) + return result + + return helper + + +@timeit +async def getNetworksLegacy(aiomeraki: meraki.aio.AsyncDashboardAPI, perPage=5): + count = 0 + for x in await aiomeraki.organizations.getOrganizationNetworks( + organizationId=ORGANIZATION_ID, perPage=perPage, total_pages=-1 + ): + print(f"{x['id']} - {x['name']}") + count = count + 1 + print(f"Found {count} networks") + + +@timeit +async def getNetworksIterator(aiomeraki: meraki.aio.AsyncDashboardAPI, perPage=5): + count = 0 + async for x in aiomeraki.organizations.getOrganizationNetworks( + organizationId=ORGANIZATION_ID, perPage=perPage, total_pages=-1 + ): + print(f"{x['id']} - {x['name']}") + count = count + 1 + print(f"Found {count} networks") + + +@timeit +async def getNetworkEventsLegacy(aiomeraki: meraki.aio.AsyncDashboardAPI, perPage=5): + count = 0 + result = await aiomeraki.networks.getNetworkEvents( + networkId=NETWORK_ID, perPage=perPage, total_pages=50, productType="wireless" + ) + for x in result["events"]: + print(f"{x['occurredAt']}") + count = count + 1 + print(f"Found {count} events") + + +@timeit +async def getNetworkEventsIterator(aiomeraki: meraki.aio.AsyncDashboardAPI, perPage=5): + count = 0 + async for x in aiomeraki.networks.getNetworkEvents( + networkId=NETWORK_ID, perPage=perPage, total_pages=50, productType="wireless" + ): + print(f"{x['occurredAt']}") + count = count + 1 + print(f"Found {count} events") + + +async def main(): + argparse.ArgumentParser( + description="Example for demonstrating the use_iterator_for_get_pages parameter" + ) + + # Instantiate a Meraki dashboard API session + # NOTE: you have to use "async with" so that the session will be closed correctly at the end of the usage + async with meraki.aio.AsyncDashboardAPI( + api_key, + base_url="https://api.meraki.com/api/v1", + log_file_prefix=__file__[:-3], + print_console=True, + use_iterator_for_get_pages=True, + ) as aiomeraki_iterator: + async with meraki.aio.AsyncDashboardAPI( + api_key, + base_url="https://api.meraki.com/api/v1", + log_file_prefix=__file__[:-3], + print_console=False, + use_iterator_for_get_pages=False, + ) as aiomeraki_legacy: + pass + + print("Test legacy") + await getNetworksLegacy(aiomeraki_legacy) + + await asyncio.sleep(2) # just wait two seconds between the tests + + print("Test iterator") + await getNetworksIterator(aiomeraki_iterator) + + print( + "-----------------------------------------------------------------------" + ) + print( + "-----------------------------------------------------------------------" + ) + print( + "-----------------------------------------------------------------------" + ) + print( + "-----------------------------------------------------------------------" + ) + + print("Test legacy") + await getNetworkEventsLegacy(aiomeraki_legacy) + + await asyncio.sleep(2) # just wait two seconds between the tests + + print("Test iterator") + await getNetworkEventsIterator(aiomeraki_iterator) + + print("Script complete!") + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/examples/aio_ips2firewall_v0.py b/examples/aio_ips2firewall_v0.py deleted file mode 100644 index 105045ea..00000000 --- a/examples/aio_ips2firewall_v0.py +++ /dev/null @@ -1,119 +0,0 @@ -import csv -from datetime import datetime, timedelta -import os -import asyncio -import argparse -import ipaddress -from typing import Dict,List -import sys - -import meraki.aio - -# Either input your API key below, or set an environment variable -# for example, in Terminal on macOS: export MERAKI_DASHBOARD_API_KEY=66839003d2861bc302b292eb66d3b247709f2d0d -api_key = "" - -def removeSmallAmounts( ip_counts:Dict[str,int], filter:int): - ret = ip_counts.copy() - for k,v in ip_counts.items(): - if v < filter: - ret.pop(k) - - return ret - -async def analyzeOrganization(aiomeraki: meraki.aio.AsyncDashboardAPI, orgId:str, days:int) -> Dict[str,int]: - ret = {} - timespan = days * 24 * 60 * 60 - events = await aiomeraki.security_events.getOrganizationSecurityEvents(orgId, timespan=timespan,total_pages=-1) - for e in events: - ip, port = e["srcIp"].rsplit(":",1) - ip = ip.strip("[]") # remove brackets in case of ipv6 - if not ipaddress.ip_address(ip).is_private: # dont block private ip addresses on the public ip of the firewall - ret[ip] = ret.get(ip, 0) + 1 - - return ret - -async def updateFirewallrules(aiomeraki: meraki.aio.AsyncDashboardAPI, networkId:str, ip_list:List[str]): - rules = await aiomeraki.mx_l7_firewall.getNetworkL7FirewallRules(networkId) - - rules=rules["rules"] - #get the currently blocked ip ranges - current_blocks = [x["value"] for x in rules if x["type"] == "ipRange"] - - new_blocks = current_blocks + list(set(ip_list)-set(current_blocks)) - new_blocks = sorted(new_blocks) - - #generate new rules based on the list of total ip ranges to block - rules_to_add = [{"policy":"deny", "type":"ipRange", "value":x} for x in new_blocks] - - #remove all currently blocked ip ranges - rules = [x for x in rules if x["type"] != "ipRange"] - - rules = rules + rules_to_add - - await aiomeraki.mx_l7_firewall.updateNetworkL7FirewallRules(networkId,rules=rules) - -async def main(): - - parser = argparse.ArgumentParser(description='Block IP Addresses based on security events') - parser.add_argument('-o','--organization', type=str, nargs='+', dest="organizations", required=True, - help='the name/id of the organization(s) you want to analyze/secure') - parser.add_argument("-f",'--filter', dest='filter', type=int, default=5, - help='how often must an attack be listed before it gets blocked') - parser.add_argument("-s",'--save', dest='save', action='store_true', - help='write the blocklist to all networks in the organization.') - parser.add_argument("-d",'--days', dest='days', default=31, type=int, - help='How many days should be analyzed.') - - if len(sys.argv) < 3: - parser.print_help() - return - - try: - args = parser.parse_args() - if args.days >= 365: - print("days must be < 365") - parser.print_help() - return - except SystemExit: - return - except: - print("could not parse arguments") - parser.print_help() - return - - # Instantiate a Meraki dashboard API session - # NOTE: you have to use "async with" so that the session will be closed correctly at the end of the usage - async with meraki.aio.AsyncDashboardAPI( - api_key, - base_url="https://api.meraki.com/api/v0", - log_file_prefix=__file__[:-3], - print_console=False, - ) as aiomeraki: - # Get list of organizations to which API key has access - organizations = await aiomeraki.organizations.getOrganizations() - for x in organizations: - if x["id"] in args.organizations or x["name"] in args.organizations: - print(f"Analyzing organization {x['name']}") - result = await analyzeOrganization(aiomeraki, x["id"], args.days) - result = removeSmallAmounts(result, args.filter) - sum = 0 - - for k,v in result.items(): - print(f"{k} attacked {v} times.") - sum = sum + v - print(f"Total attacks: {sum} from {len(result)} different IP adresses") - - #apply the found ip ranges to the firewall - if args.save: - for n in await aiomeraki.networks.getOrganizationNetworks(x["id"]): - print(f"Updating Network {n['name']}") - await updateFirewallrules(aiomeraki,n["id"], result.keys()) - - print("Script complete!") - - -if __name__ == "__main__": - loop = asyncio.get_event_loop() - loop.run_until_complete(main()) - diff --git a/examples/aio_ips2firewall_v1.py b/examples/aio_ips2firewall_v1.py new file mode 100644 index 00000000..10448033 --- /dev/null +++ b/examples/aio_ips2firewall_v1.py @@ -0,0 +1,160 @@ +import argparse +import asyncio +import ipaddress +import sys +from typing import Dict, List + +import meraki.aio + +# Either input your API key below, or set an environment variable +# for example, in Terminal on macOS: export MERAKI_DASHBOARD_API_KEY=66839003d2861bc302b292eb66d3b247709f2d0d +api_key = "" + + +def removeSmallAmounts(ip_counts: Dict[str, int], filter: int): + ret = ip_counts.copy() + for k, v in ip_counts.items(): + if v < filter: + ret.pop(k) + + return ret + + +async def analyzeOrganization( + aiomeraki: meraki.aio.AsyncDashboardAPI, orgId: str, days: int +) -> Dict[str, int]: + ret = {} + timespan = days * 24 * 60 * 60 + events = await aiomeraki.appliance.getOrganizationApplianceSecurityEvents( + orgId, timespan=timespan, total_pages=-1 + ) + for e in events: + ip, port = e["srcIp"].rsplit(":", 1) + ip = ip.strip("[]") # remove brackets in case of ipv6 + if ( + not ipaddress.ip_address(ip).is_private + ): # don't block private ip addresses on the public ip of the firewall + ret[ip] = ret.get(ip, 0) + 1 + + return ret + + +async def updateFirewallrules( + aiomeraki: meraki.aio.AsyncDashboardAPI, networkId: str, ip_list: List[str] +): + rules = await aiomeraki.appliance.getNetworkApplianceFirewallL7FirewallRules( + networkId + ) + + rules = rules["rules"] + # get the currently blocked ip ranges + current_blocks = [x["value"] for x in rules if x["type"] == "ipRange"] + + new_blocks = current_blocks + list(set(ip_list) - set(current_blocks)) + new_blocks = sorted(new_blocks) + + # generate new rules based on the list of total ip ranges to block + rules_to_add = [ + {"policy": "deny", "type": "ipRange", "value": x} for x in new_blocks + ] + + # remove all currently blocked ip ranges + rules = [x for x in rules if x["type"] != "ipRange"] + + rules = rules + rules_to_add + + await aiomeraki.appliance.updateNetworkApplianceFirewallL7FirewallRules( + networkId, rules=rules + ) + + +async def main(): + parser = argparse.ArgumentParser( + description="Block IP Addresses based on security events" + ) + parser.add_argument( + "-o", + "--organization", + type=str, + nargs="+", + dest="organizations", + required=True, + help="the name/id of the organization(s) you want to analyze/secure", + ) + parser.add_argument( + "-f", + "--filter", + dest="filter", + type=int, + default=5, + help="how often must an attack be listed before it gets blocked", + ) + parser.add_argument( + "-s", + "--save", + dest="save", + action="store_true", + help="write the blocklist to all networks in the organization.", + ) + parser.add_argument( + "-d", + "--days", + dest="days", + default=31, + type=int, + help="How many days should be analyzed.", + ) + + if len(sys.argv) < 3: + parser.print_help() + return + + try: + args = parser.parse_args() + if args.days >= 365: + print("days must be < 365") + parser.print_help() + return + except SystemExit: + return + except Exception: + print("could not parse arguments") + parser.print_help() + return + + # Instantiate a Meraki dashboard API session + # NOTE: you have to use "async with" so that the session will be closed correctly at the end of the usage + async with meraki.aio.AsyncDashboardAPI( + api_key, + base_url="https://api.meraki.com/api/v1", + log_file_prefix=__file__[:-3], + print_console=False, + ) as aiomeraki: + # Get list of organizations to which API key has access + organizations = await aiomeraki.organizations.getOrganizations() + for x in organizations: + if x["id"] in args.organizations or x["name"] in args.organizations: + print(f"Analyzing organization {x['name']}") + result = await analyzeOrganization(aiomeraki, x["id"], args.days) + result = removeSmallAmounts(result, args.filter) + sum = 0 + + for k, v in result.items(): + print(f"{k} attacked {v} times.") + sum = sum + v + print(f"Total attacks: {sum} from {len(result)} different IP adresses") + + # apply the found ip ranges to the firewall + if args.save: + for n in await aiomeraki.organizations.getOrganizationNetworks( + x["id"] + ): + print(f"Updating Network {n['name']}") + await updateFirewallrules(aiomeraki, n["id"], result.keys()) + + print("Script complete!") + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/examples/aio_org_wide_clients_v0.py b/examples/aio_org_wide_clients_v0.py deleted file mode 100644 index 2cd4d1b2..00000000 --- a/examples/aio_org_wide_clients_v0.py +++ /dev/null @@ -1,138 +0,0 @@ -import csv -from datetime import datetime -import os -import asyncio - -import meraki.aio - -# Either input your API key below, or set an environment variable -# for example, in Terminal on macOS: export MERAKI_DASHBOARD_API_KEY=093b24e85df15a3e66f1fc359f4c48493eaa1b73 -api_key = "" - - -async def listNetworkClients(aiomeraki: meraki.aio.AsyncDashboardAPI, folder_name, network): - print(f'Finding clients in network {network["name"]}') - try: - # Get list of clients on network, filtering on timespan of last 14 days - clients = await aiomeraki.clients.getNetworkClients( - network["id"], - timespan=60 * 60 * 24 * 14, - perPage=1000, - total_pages="all", - ) - except meraki.AsyncAPIError as e: - print(f"Meraki API error: {e}") - except Exception as e: - print(f"some other error: {e}") - else: - if clients: - # Write to file - file_name = f'{network["name"]}.csv' - output_file = open( - f"{folder_name}/{file_name}", mode="w", newline="\n" - ) - field_names = clients[0].keys() - csv_writer = csv.DictWriter( - output_file, - field_names, - delimiter=",", - quotechar='"', - quoting=csv.QUOTE_ALL, - ) - csv_writer.writeheader() - csv_writer.writerows(clients) - output_file.close() - print( - f"Successfully output {len(clients)} clients' data to file {file_name}" - ) - return network["name"], field_names - return network["name"], None - - -async def listOrganization(aiomeraki: meraki.aio.AsyncDashboardAPI, org): - print(f'Analyzing organization {org["name"]}:') - org_id = org["id"] - - # Get list of networks in organization - try: - networks = await aiomeraki.networks.getOrganizationNetworks(org_id) - except meraki.AsyncAPIError as e: - print(f"Meraki API error: {e}") - return org["name"] - except Exception as e: - print(f"some other error: {e}") - return org["name"] - - # Create local folder - todays_date = f"{datetime.now():%Y-%m-%d}" - folder_name = f"Org {org_id} clients {todays_date}" - if folder_name not in os.listdir(): - os.mkdir(folder_name) - - # Iterate through networks - total = len(networks) - print(f"Iterating through {total} networks in organization {org_id}") - - # create a list of all networks in the organization so we can call them all concurrently - networkClientsTasks = [listNetworkClients(aiomeraki, folder_name, net) for net in networks] - for task in asyncio.as_completed(networkClientsTasks): - networkname, field_names = await task - print(f"finished network: {networkname}") - - # Stitch together one consolidated CSV per org - output_file = open(f"{folder_name}.csv", mode="w", newline="\n") - field_names = ['id', 'mac', 'description', 'ip', 'ip6', 'ip6Local', 'user', 'firstSeen', 'lastSeen', 'manufacturer', 'os', 'recentDeviceSerial', 'recentDeviceName', 'recentDeviceMac', 'ssid', 'vlan', 'switchport', 'usage', 'status', 'notes', 'smInstalled', 'groupPolicy8021x'] - field_names.insert(0, "Network Name") - field_names.insert(1, "Network ID") - - csv_writer = csv.DictWriter( - output_file, - field_names, - delimiter=",", - quotechar='"', - quoting=csv.QUOTE_ALL, - ) - csv_writer.writeheader() - for net in networks: - file_name = f'{net["name"]}.csv' - if file_name in os.listdir(folder_name): - with open(f"{folder_name}/{file_name}") as input_file: - csv_reader = csv.DictReader( - input_file, - delimiter=",", - quotechar='"', - quoting=csv.QUOTE_ALL, - ) - next(csv_reader) - for row in csv_reader: - row["Network Name"] = net["name"] - row["Network ID"] = net["id"] - csv_writer.writerow(row) - return org["name"] - - -async def main(): - # Instantiate a Meraki dashboard API session - # NOTE: you have to use "async with" so that the session will be closed correctly at the end of the usage - async with meraki.aio.AsyncDashboardAPI( - api_key, - base_url="https://api.meraki.com/api/v0", - log_file_prefix=__file__[:-3], - print_console=False, - ) as aiomeraki: - # Get list of organizations to which API key has access - organizations = await aiomeraki.organizations.getOrganizations() - - # create a list of all organizations so we can call them all concurrently - organizationTasks = [listOrganization(aiomeraki, org) for org in organizations] - for task in asyncio.as_completed(organizationTasks): - # as_completed returns an iterator, so we just have to await the iterator and not call it - organizationName = await task - print(f"finished organization: {organizationName}") - - print("Script complete!") - - -if __name__ == "__main__": - loop = asyncio.get_event_loop() - loop.run_until_complete(main()) diff --git a/examples/aio_org_wide_clients_v1.py b/examples/aio_org_wide_clients_v1.py index acc65fb5..ffa79f53 100644 --- a/examples/aio_org_wide_clients_v1.py +++ b/examples/aio_org_wide_clients_v1.py @@ -1,7 +1,7 @@ +import asyncio import csv -from datetime import datetime import os -import asyncio +from datetime import datetime import meraki.aio @@ -10,8 +10,10 @@ api_key = "" -async def listNetworkClients(aiomeraki: meraki.aio.AsyncDashboardAPI, folder_name, network): - print(f'Finding clients in network {network["name"]}') +async def listNetworkClients( + aiomeraki: meraki.aio.AsyncDashboardAPI, folder_name, network +): + print(f"Finding clients in network {network['name']}") try: # Get list of clients on network, filtering on timespan of last 14 days clients = await aiomeraki.networks.getNetworkClients( @@ -27,10 +29,8 @@ async def listNetworkClients(aiomeraki: meraki.aio.AsyncDashboardAPI, folder_nam else: if clients: # Write to file - file_name = f'{network["name"]}.csv' - output_file = open( - f"{folder_name}/{file_name}", mode="w", newline="\n" - ) + file_name = f"{network['name']}.csv" + output_file = open(f"{folder_name}/{file_name}", mode="w", newline="\n") field_names = clients[0].keys() csv_writer = csv.DictWriter( output_file, @@ -50,7 +50,7 @@ async def listNetworkClients(aiomeraki: meraki.aio.AsyncDashboardAPI, folder_nam async def listOrganization(aiomeraki: meraki.aio.AsyncDashboardAPI, org): - print(f'Analyzing organization {org["name"]}:') + print(f"Analyzing organization {org['name']}:") org_id = org["id"] # Get list of networks in organization @@ -74,17 +74,42 @@ async def listOrganization(aiomeraki: meraki.aio.AsyncDashboardAPI, org): print(f"Iterating through {total} networks in organization {org_id}") # create a list of all networks in the organization so we can call them all concurrently - networkClientsTasks = [listNetworkClients(aiomeraki, folder_name, net) for net in networks] + networkClientsTasks = [ + listNetworkClients(aiomeraki, folder_name, net) for net in networks + ] for task in asyncio.as_completed(networkClientsTasks): networkname, field_names = await task print(f"finished network: {networkname}") # Stitch together one consolidated CSV per org output_file = open(f"{folder_name}.csv", mode="w", newline="\n") - field_names = ['id', 'mac', 'description', 'ip', 'ip6', 'ip6Local', 'user', 'firstSeen', 'lastSeen', 'manufacturer', 'os', 'recentDeviceSerial', 'recentDeviceName', 'recentDeviceMac', 'ssid', 'vlan', 'switchport', 'usage', 'status', 'notes', 'smInstalled', 'groupPolicy8021x'] + field_names = [ + "id", + "mac", + "description", + "ip", + "ip6", + "ip6Local", + "user", + "firstSeen", + "lastSeen", + "manufacturer", + "os", + "recentDeviceSerial", + "recentDeviceName", + "recentDeviceMac", + "ssid", + "vlan", + "switchport", + "usage", + "status", + "notes", + "smInstalled", + "groupPolicy8021x", + ] field_names.insert(0, "Network Name") field_names.insert(1, "Network ID") - + csv_writer = csv.DictWriter( output_file, field_names, @@ -94,7 +119,7 @@ async def listOrganization(aiomeraki: meraki.aio.AsyncDashboardAPI, org): ) csv_writer.writeheader() for net in networks: - file_name = f'{net["name"]}.csv' + file_name = f"{net['name']}.csv" if file_name in os.listdir(folder_name): with open(f"{folder_name}/{file_name}") as input_file: csv_reader = csv.DictReader( @@ -115,10 +140,10 @@ async def main(): # Instantiate a Meraki dashboard API session # NOTE: you have to use "async with" so that the session will be closed correctly at the end of the usage async with meraki.aio.AsyncDashboardAPI( - api_key, - base_url="https://api.meraki.com/api/v1", - log_file_prefix=__file__[:-3], - print_console=False, + api_key, + base_url="https://api.meraki.com/api/v1", + log_file_prefix=__file__[:-3], + print_console=False, ) as aiomeraki: # Get list of organizations to which API key has access organizations = await aiomeraki.organizations.getOrganizations() diff --git a/examples/apiData2CSV_v0.py b/examples/apiData2CSV_v0.py deleted file mode 100644 index fecda227..00000000 --- a/examples/apiData2CSV_v0.py +++ /dev/null @@ -1,128 +0,0 @@ -import csv -from datetime import datetime -import os -import json -import argparse -import sys - -import meraki - -import urllib.parse -import platform - -# This example pulls API calls from the passed in org_id from the last timespan -# seconds, where the default timespan is 900 (hint 24 hours = 3600 seconds) and -# generates a CSV file with the data. -# -# Either input your API key below by uncommenting line 10 and changing line 16 to api_key=API_KEY, -# or set an environment variable (preferred) to define your API key. The former is insecure and not recommended. -# For example, in Linux/macOS: export MERAKI_DASHBOARD_API_KEY=093b24e85df15a3e66f1fc359f4c48493eaa1b73 -# API_KEY = '093b24e85df15a3e66f1fc359f4c48493eaa1b73' -# -# Optionally, Cisco partners can set their BE GEO ID by using export BE_GEO_ID=XXXXXX -# where XXXXX is a valid BE GEO ID. This is used for metrics collection. -# -# Optionally, a calling application can be set by using export MERAKI_PYTHON_SDK_CALLER=YYYYY -# where YYYYY is a string identifying the application, script, or whatever piece of code -# is callig the Meraki Python SDK - - -def main(org_id, timespan): - # Instantiate a Meraki dashboard API session - dashboard = meraki.DashboardAPI( - base_url='https://api-mp.meraki.com/api/v0/', - print_console=False, - output_log=False, - ) - - # Get list of API usage data and start the output csv string - apiUsage = dashboard.api_usage.getOrganizationApiRequests(org_id, timespan=timespan, total_pages=-1) - csvString = 'method,host,path,queryString,tsDate,tsTime,responseCode,sourceIp,userAgent,' - csvString += 'implementation,implementationVersion,distro,distroVersion,system,systemRelease,' - csvString += 'cpu,be_geo_id,caller\r\n' - cumulativeAPIcalls = 0; - for use in apiUsage: - csvString += use['method'] + ',' - csvString += use['host'] + ',' - csvString += use['path'] + ',' - csvString += use['queryString'] + ',' - csvString += use['ts'].split('T')[0] + ',' - csvString += use['ts'].split('T')[1].replace('Z','') + ',' - csvString += str(use['responseCode']) + ',' - csvString += use['sourceIp'] + ',' - - # Special User-Agent processing - if 'python-meraki' in use['userAgent']: - print(use['userAgent']) - userAgent = use['userAgent'].split(' ') - csvString += userAgent[0] + ',' - if len(userAgent) > 1: - if "implementation" in userAgent[1]: - userAgentDict = json.loads(urllib.parse.unquote(userAgent[1])) - if "implementation" in userAgentDict: - csvString += userAgentDict['implementation']['name'] + ',' - csvString += userAgentDict['implementation']['version'] + ',' - else: - csvString += ',,' - if "distro" in userAgentDict: - csvString += userAgentDict['distro']['name'] + ',' - csvString += userAgentDict['distro']['version'] + ',' - else: - csvString += ',,' - if "system" in userAgentDict: - csvString += userAgentDict['system']['name'] + ',' - csvString += userAgentDict['system']['release'] + ',' - else: - csvString += ',,' - if "cpu" in userAgentDict: - csvString += userAgentDict['cpu'] + ',' - else: - csvString += ',' - if "be_geo_id" in userAgentDict: - csvString += userAgentDict['be_geo_id'] + ',' - else: - csvString += ',' - if "application" in userAgentDict: - csvString += userAgentDict['application'] + ',' - elif "caller" in userAgentDict: - csvString += userAgentDict['caller'] + ',' - else: - csvString += ',' - else: - csvString += ',,,,,,,,,' - else: - csvString += ',,,,,,,,,' - else: - csvString += use['userAgent']+ ',' - csvString += ',,,,,,,,,' - - csvString += '\r\n' - - # Output the file - now = datetime.now() - dt_string = now.strftime("%Y-%m-%d_%H-%M-%S") - filename = org_id + '_' + str(timespan) + '_' + dt_string + '.csv' - file = open(filename, 'w') - file.write(csvString) - file.close() - print('Results written to ' + filename) - -if __name__ == '__main__': - # First check for API key - if "MERAKI_DASHBOARD_API_KEY" not in os.environ: - print('You must set the MERAKI_DASHBOARD_API_KEY variable') - sys.exit() - - # Now check arguments - parser = argparse.ArgumentParser(description='Generate a CSV file of Meraki API activity for an organization.') - parser.add_argument('org_id', help='Organization id to pull API activity from') - parser.add_argument('--timespan', type=int, default=900, - help='The timespan (in seconds) for which the information will be fetched. Default = 900 (15 mins)') - args = parser.parse_args() - print('About to run with org_id: ' + args.org_id + ' and timespan: ' + str(args.timespan)) - - # Finally, let's roll - start_time = datetime.now() - main(args.org_id, args.timespan) - end_time = datetime.now() - print(f'\nScript complete, total runtime {end_time - start_time}') diff --git a/examples/apiData2CSV_v1.py b/examples/apiData2CSV_v1.py index 52bb1873..f97f3907 100644 --- a/examples/apiData2CSV_v1.py +++ b/examples/apiData2CSV_v1.py @@ -1,14 +1,12 @@ -import csv -from datetime import datetime -import os -import json import argparse +import json +import os import sys +import urllib.parse +from datetime import datetime -import meraki_v1 +import meraki -import urllib.parse -import platform # This example pulls API calls from the passed in org_id from the last timespan # seconds, where the default timespan is 900 (hint 24 hours = 3600 seconds) and @@ -29,100 +27,115 @@ def main(org_id, timespan): # Instantiate a Meraki dashboard API session - dashboard = meraki_v1.DashboardAPI( - base_url='https://api-mp.meraki.com/api/v1/', + dashboard = meraki.DashboardAPI( + base_url="https://api.meraki.com/api/v1/", print_console=False, output_log=False, ) # Get list of API usage data and start the output csv string - apiUsage = dashboard.organizations.getOrganizationApiRequests(org_id, timespan=timespan, total_pages=-1) - csvString = 'method,host,path,queryString,tsDate,tsTime,responseCode,sourceIp,userAgent,' - csvString += 'implementation,implementationVersion,distro,distroVersion,system,systemRelease,' - csvString += 'cpu,be_geo_id,caller\r\n' - cumulativeAPIcalls = 0; + apiUsage = dashboard.organizations.getOrganizationApiRequests( + org_id, timespan=timespan, total_pages=-1 + ) + csvString = ( + "method,host,path,queryString,tsDate,tsTime,responseCode,sourceIp,userAgent," + ) + csvString += "implementation,implementationVersion,distro,distroVersion,system,systemRelease," + csvString += "cpu,be_geo_id,caller\r\n" for use in apiUsage: - csvString += use['method'] + ',' - csvString += use['host'] + ',' - csvString += use['path'] + ',' - csvString += use['queryString'] + ',' - csvString += use['ts'].split('T')[0] + ',' - csvString += use['ts'].split('T')[1].replace('Z','') + ',' - csvString += str(use['responseCode']) + ',' - csvString += use['sourceIp'] + ',' + csvString += use["method"] + "," + csvString += use["host"] + "," + csvString += use["path"] + "," + csvString += use["queryString"] + "," + csvString += use["ts"].split("T")[0] + "," + csvString += use["ts"].split("T")[1].replace("Z", "") + "," + csvString += str(use["responseCode"]) + "," + csvString += use["sourceIp"] + "," # Special User-Agent processing - if 'python-meraki' in use['userAgent']: - print(use['userAgent']) - userAgent = use['userAgent'].split(' ') - csvString += userAgent[0] + ',' + if "python-meraki" in use["userAgent"]: + print(use["userAgent"]) + userAgent = use["userAgent"].split(" ") + csvString += userAgent[0] + "," if len(userAgent) > 1: if "implementation" in userAgent[1]: userAgentDict = json.loads(urllib.parse.unquote(userAgent[1])) if "implementation" in userAgentDict: - csvString += userAgentDict['implementation']['name'] + ',' - csvString += userAgentDict['implementation']['version'] + ',' + csvString += userAgentDict["implementation"]["name"] + "," + csvString += userAgentDict["implementation"]["version"] + "," else: - csvString += ',,' + csvString += ",," if "distro" in userAgentDict: - csvString += userAgentDict['distro']['name'] + ',' - csvString += userAgentDict['distro']['version'] + ',' + csvString += userAgentDict["distro"]["name"] + "," + csvString += userAgentDict["distro"]["version"] + "," else: - csvString += ',,' + csvString += ",," if "system" in userAgentDict: - csvString += userAgentDict['system']['name'] + ',' - csvString += userAgentDict['system']['release'] + ',' + csvString += userAgentDict["system"]["name"] + "," + csvString += userAgentDict["system"]["release"] + "," else: - csvString += ',,' + csvString += ",," if "cpu" in userAgentDict: - csvString += userAgentDict['cpu'] + ',' + csvString += userAgentDict["cpu"] + "," else: - csvString += ',' + csvString += "," if "be_geo_id" in userAgentDict: - csvString += userAgentDict['be_geo_id'] + ',' + csvString += userAgentDict["be_geo_id"] + "," else: - csvString += ',' + csvString += "," if "application" in userAgentDict: - csvString += userAgentDict['application'] + ',' + csvString += userAgentDict["application"] + "," elif "caller" in userAgentDict: - csvString += userAgentDict['caller'] + ',' + csvString += userAgentDict["caller"] + "," else: - csvString += ',' + csvString += "," else: - csvString += ',,,,,,,,,' + csvString += ",,,,,,,,," else: - csvString += ',,,,,,,,,' + csvString += ",,,,,,,,," else: - csvString += use['userAgent']+ ',' - csvString += ',,,,,,,,,' + csvString += use["userAgent"] + "," + csvString += ",,,,,,,,," - csvString += '\r\n' + csvString += "\r\n" # Output the file now = datetime.now() dt_string = now.strftime("%Y-%m-%d_%H-%M-%S") - filename = org_id + '_' + str(timespan) + '_' + dt_string + '.csv' - file = open(filename, 'w') + filename = org_id + "_" + str(timespan) + "_" + dt_string + ".csv" + file = open(filename, "w") file.write(csvString) file.close() - print('Results written to ' + filename) + print("Results written to " + filename) + -if __name__ == '__main__': +if __name__ == "__main__": # First check for API key if "MERAKI_DASHBOARD_API_KEY" not in os.environ: - print('You must set the MERAKI_DASHBOARD_API_KEY variable') + print("You must set the MERAKI_DASHBOARD_API_KEY variable") sys.exit() # Now check arguments - parser = argparse.ArgumentParser(description='Generate a CSV file of Meraki API activity for an organization.') - parser.add_argument('org_id', help='Organization id to pull API activity from') - parser.add_argument('--timespan', type=int, default=900, - help='The timespan (in seconds) for which the information will be fetched. Default = 900 (15 mins)') + parser = argparse.ArgumentParser( + description="Generate a CSV file of Meraki API activity for an organization." + ) + parser.add_argument("org_id", help="Organization id to pull API activity from") + parser.add_argument( + "--timespan", + type=int, + default=900, + help="The timespan (in seconds) for which the information will be fetched. Default = 900 (15 mins)", + ) args = parser.parse_args() - print('About to run with org_id: ' + args.org_id + ' and timespan: ' + str(args.timespan)) + print( + "About to run with org_id: " + + args.org_id + + " and timespan: " + + str(args.timespan) + ) # Finally, let's roll start_time = datetime.now() main(args.org_id, args.timespan) end_time = datetime.now() - print(f'\nScript complete, total runtime {end_time - start_time}') + print(f"\nScript complete, total runtime {end_time - start_time}") diff --git a/examples/async_crud_lifecycle.py b/examples/async_crud_lifecycle.py new file mode 100644 index 00000000..a79ac9cb --- /dev/null +++ b/examples/async_crud_lifecycle.py @@ -0,0 +1,344 @@ +"""Async CRUD lifecycle demo for the Meraki Dashboard API Python SDK. + +Exercises create/update/delete workflows across org-level and network-level +resources (policy objects, alerts profiles, group policies, MQTT brokers, +webhooks, SSIDs, network settings, and action batches) using the async client. + +All operations run concurrently per org and each resource is fully cleaned up +before the script exits. + +Usage: + export MERAKI_DASHBOARD_API_KEY= + python examples/async_crud_lifecycle.py +""" + +import asyncio +import hashlib +import time +from functools import wraps + +import meraki.aio + + +KEYWORD = "TestKeyword" + + +def _salt(): + return hashlib.md5(str(time.time()).encode()).hexdigest()[:6] + + +def timed(fn): + @wraps(fn) + async def wrapper(*args, **kwargs): + start = time.perf_counter() + result = await fn(*args, **kwargs) + elapsed = time.perf_counter() - start + return (*result, elapsed) + + return wrapper + + +@timed +async def test_policy_object(dashboard, org_id, org_name): + """Create -> rename -> delete a policy object.""" + salt = _salt() + print(f"[{org_name}] Creating policy object...") + obj = await dashboard.organizations.createOrganizationPolicyObject( + org_id, + name=f"test-policy-object-{salt}", + category="network", + type="cidr", + cidr="10.0.0.0/24", + ) + obj_id = obj["id"] + print(f"[{org_name}] Created policy object: {obj_id}") + + print(f"[{org_name}] Renaming policy object...") + await dashboard.organizations.updateOrganizationPolicyObject(org_id, obj_id, name=f"test-policy-object-{salt}-r") + + print(f"[{org_name}] Deleting policy object...") + await dashboard.organizations.deleteOrganizationPolicyObject(org_id, obj_id) + print(f"[{org_name}] Policy object deleted.") + + return ("policy_object", obj_id) + + +@timed +async def test_policy_object_group(dashboard, org_id, org_name): + """Create -> rename -> delete a policy object group.""" + salt = _salt() + print(f"[{org_name}] Creating policy object group...") + group = await dashboard.organizations.createOrganizationPolicyObjectsGroup( + org_id, name=f"test-policy-group-{salt}", category="NetworkObjectGroup" + ) + group_id = group["id"] + print(f"[{org_name}] Created policy object group: {group_id}") + + print(f"[{org_name}] Renaming policy object group...") + await dashboard.organizations.updateOrganizationPolicyObjectsGroup(org_id, group_id, name=f"test-policy-group-{salt}-r") + + print(f"[{org_name}] Deleting policy object group...") + await dashboard.organizations.deleteOrganizationPolicyObjectsGroup(org_id, group_id) + print(f"[{org_name}] Policy object group deleted.") + + return ("policy_object_group", group_id) + + +@timed +async def test_alerts_profile(dashboard, org_id, org_name): + """Create -> update -> delete an org alerts profile.""" + salt = _salt() + print(f"[{org_name}] Creating alerts profile...") + profile = await dashboard.organizations.createOrganizationAlertsProfile( + org_id, + type="wanUtilization", + alertCondition={"duration": 60, "window": 600, "bit_rate_bps": 1000000, "interface": "wan1"}, + recipients={"emails": ["test@example.com"]}, + networkTags=["__all_tags__"], + description=f"test-alert-profile-{salt}", + ) + profile_id = profile["id"] + print(f"[{org_name}] Created alerts profile: {profile_id}") + + print(f"[{org_name}] Updating alerts profile description...") + await dashboard.organizations.updateOrganizationAlertsProfile( + org_id, profile_id, description=f"test-alert-profile-{salt}-r" + ) + + print(f"[{org_name}] Deleting alerts profile...") + await dashboard.organizations.deleteOrganizationAlertsProfile(org_id, profile_id) + print(f"[{org_name}] Alerts profile deleted.") + + return ("alerts_profile", profile_id) + + +@timed +async def test_group_policy(dashboard, net_id, org_name): + """Create -> update -> delete a group policy.""" + salt = _salt() + print(f"[{org_name}] Creating group policy...") + gp = await dashboard.networks.createNetworkGroupPolicy(net_id, name=f"test-group-policy-{salt}") + gp_id = gp["groupPolicyId"] + print(f"[{org_name}] Created group policy: {gp_id}") + + print(f"[{org_name}] Updating group policy...") + await dashboard.networks.updateNetworkGroupPolicy(net_id, gp_id, name=f"test-group-policy-{salt}-r") + + print(f"[{org_name}] Deleting group policy...") + await dashboard.networks.deleteNetworkGroupPolicy(net_id, gp_id) + print(f"[{org_name}] Group policy deleted.") + + return ("group_policy", gp_id) + + +@timed +async def test_mqtt_broker(dashboard, net_id, org_name): + """Create -> update -> delete an MQTT broker.""" + salt = _salt() + print(f"[{org_name}] Creating MQTT broker...") + broker = await dashboard.networks.createNetworkMqttBroker( + net_id, name=f"test-mqtt-broker-{salt}", host="mqtt.example.com", port=1883 + ) + broker_id = broker["id"] + print(f"[{org_name}] Created MQTT broker: {broker_id}") + + print(f"[{org_name}] Updating MQTT broker...") + await dashboard.networks.updateNetworkMqttBroker(net_id, broker_id, name=f"test-mqtt-broker-{salt}-r") + + print(f"[{org_name}] Deleting MQTT broker...") + await dashboard.networks.deleteNetworkMqttBroker(net_id, broker_id) + print(f"[{org_name}] MQTT broker deleted.") + + return ("mqtt_broker", broker_id) + + +@timed +async def test_webhook_http_server(dashboard, net_id, org_name): + """Create -> update -> delete a webhook HTTP server.""" + salt = _salt() + print(f"[{org_name}] Creating webhook HTTP server...") + server = await dashboard.networks.createNetworkWebhooksHttpServer( + net_id, name=f"test-webhook-server-{salt}", url="https://example.com/webhook" + ) + server_id = server["id"] + print(f"[{org_name}] Created webhook HTTP server: {server_id}") + + print(f"[{org_name}] Updating webhook HTTP server...") + await dashboard.networks.updateNetworkWebhooksHttpServer(net_id, server_id, name=f"test-webhook-server-{salt}-r") + + print(f"[{org_name}] Deleting webhook HTTP server...") + await dashboard.networks.deleteNetworkWebhooksHttpServer(net_id, server_id) + print(f"[{org_name}] Webhook HTTP server deleted.") + + return ("webhook_http_server", server_id) + + +@timed +async def test_webhook_payload_template(dashboard, net_id, org_name): + """Create -> delete a webhook payload template.""" + salt = _salt() + print(f"[{org_name}] Creating webhook payload template...") + template = await dashboard.networks.createNetworkWebhooksPayloadTemplate( + net_id, + name=f"test-payload-template-{salt}", + body='{"event": "{{alertType}}", "network": "{{networkName}}"}', + ) + template_id = template["payloadTemplateId"] + print(f"[{org_name}] Created payload template: {template_id}") + print(f"[{org_name}] body: {template.get('body', '')[:80]}") + + print(f"[{org_name}] Deleting payload template...") + await dashboard.networks.deleteNetworkWebhooksPayloadTemplate(net_id, template_id) + print(f"[{org_name}] Payload template deleted.") + + return ("webhook_payload_template", template_id) + + +@timed +async def test_ssid(dashboard, net_id, org_name): + """Read -> update -> restore SSID 0.""" + print(f"[{org_name}] Reading SSID 0...") + ssid = await dashboard.wireless.getNetworkWirelessSsid(net_id, "0") + original_name = ssid["name"] + print(f"[{org_name}] SSID 0 name: {original_name}") + + new_name = "test-ssid-renamed" + print(f"[{org_name}] Updating SSID 0 name to '{new_name}'...") + await dashboard.wireless.updateNetworkWirelessSsid(net_id, "0", name=new_name) + + print(f"[{org_name}] Restoring SSID 0 name to '{original_name}'...") + await dashboard.wireless.updateNetworkWirelessSsid(net_id, "0", name=original_name) + print(f"[{org_name}] SSID 0 restored.") + + return ("ssid_0", net_id) + + +@timed +async def test_network_settings(dashboard, net_id, org_name): + """Read -> toggle -> restore network settings.""" + print(f"[{org_name}] Reading network settings...") + settings = await dashboard.networks.getNetworkSettings(net_id) + original = settings.get("localStatusPageEnabled", True) + print(f"[{org_name}] localStatusPageEnabled = {original}") + + toggled = not original + print(f"[{org_name}] Toggling localStatusPageEnabled to {toggled}...") + await dashboard.networks.updateNetworkSettings(net_id, localStatusPageEnabled=toggled) + + print(f"[{org_name}] Restoring localStatusPageEnabled to {original}...") + await dashboard.networks.updateNetworkSettings(net_id, localStatusPageEnabled=original) + print(f"[{org_name}] Network settings restored.") + + return ("network_settings", net_id) + + +@timed +async def test_action_batch(dashboard, org_id, net_id, org_name): + """Submit async action batch with 3 air marshal rules, poll to completion.""" + actions = [ + { + "resource": f"/networks/{net_id}/wireless/airMarshal/rules", + "operation": "create", + "body": { + "type": "block", + "match": {"string": f"test-rule-{i}", "type": "bssid"}, + }, + } + for i in range(1, 4) + ] + + print(f"[{org_name}] Submitting action batch with 3 air marshal rules...") + batch = await dashboard.organizations.createOrganizationActionBatch(org_id, actions, confirmed=True, synchronous=False) + batch_id = batch["id"] + print(f"[{org_name}] Action batch submitted: {batch_id}") + + while True: + status = await dashboard.organizations.getOrganizationActionBatch(org_id, batch_id) + if status["status"]["completed"]: + break + if status["status"]["failed"]: + raise RuntimeError(f"Action batch {batch_id} failed in org {org_id}") + await asyncio.sleep(1) + + print(f"[{org_name}] Action batch completed. Rules created:") + for action in status["actions"]: + rule = action.get("body", {}) + print(f" - type={rule.get('type')}, match={rule.get('match')}") + + return ("action_batch", batch_id) + + +async def process_org(dashboard, org): + org_id = org["id"] + org_name = org["name"] + + # Create network (needed for network-level tests) + print(f"[{org_name}] Creating wireless network...") + network = await dashboard.organizations.createOrganizationNetwork( + org_id, + name=f"test-airmarshal-network-{_salt()}", + productTypes=["wireless"], + ) + net_id = network["id"] + print(f"[{org_name}] Created network: {net_id}") + + # Run all tests concurrently + results = await asyncio.gather( + # Org-level (no network dependency) + test_policy_object(dashboard, org_id, org_name), + test_policy_object_group(dashboard, org_id, org_name), + test_alerts_profile(dashboard, org_id, org_name), + # Network-level + test_group_policy(dashboard, net_id, org_name), + test_mqtt_broker(dashboard, net_id, org_name), + test_webhook_http_server(dashboard, net_id, org_name), + test_webhook_payload_template(dashboard, net_id, org_name), + test_ssid(dashboard, net_id, org_name), + test_network_settings(dashboard, net_id, org_name), + test_action_batch(dashboard, org_id, net_id, org_name), + ) + + # Delete the network + print(f"[{org_name}] Deleting network {net_id}...") + await dashboard.networks.deleteNetwork(net_id) + print(f"[{org_name}] Network deleted.") + + return { + "org_id": org_id, + "network_id": net_id, + "resources": list(results), + } + + +async def main(): + total_start = time.perf_counter() + + async with meraki.aio.AsyncDashboardAPI(suppress_logging=True) as dashboard: + orgs = await dashboard.organizations.getOrganizations() + targets = [o for o in orgs if KEYWORD in o["name"]] + + results = await asyncio.gather(*(process_org(dashboard, org) for org in targets)) + + total_elapsed = time.perf_counter() - total_start + + print("\n" + "=" * 60) + print("Resources touched:") + print("=" * 60) + for r in results: + print(f"\nOrg: {r['org_id']}") + print(f" Network: {r['network_id']}") + for resource_type, resource_id, elapsed in r["resources"]: + print(f" {resource_type}: {resource_id} ({elapsed:.2f}s)") + + print("\n" + "=" * 60) + print("Timing summary:") + print("=" * 60) + for r in results: + print(f"\nOrg: {r['org_id']}") + for resource_type, _, elapsed in r["resources"]: + print(f" {resource_type:30s} {elapsed:6.2f}s") + print(f"\nTotal elapsed: {total_elapsed:.2f}s") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/bulk_firmware_upgrade_manager.py b/examples/bulk_firmware_upgrade_manager.py new file mode 100644 index 00000000..3ad341f2 --- /dev/null +++ b/examples/bulk_firmware_upgrade_manager.py @@ -0,0 +1,208 @@ +import datetime +import time + +import meraki + +""" +Cisco Meraki Bulk Firmware Upgrade Manager +John M. Kuchta .:|:.:|:. https://github.com/TKIPisalegacycipher +This script will pull network IDs from an org and then create asynchronous action batches. Each batch will contain, for +each network, an action that will either schedule a new upgrade, or delay an existing upgrade datetime stamp by X days +(configurable). Each batch can contain up to 100 actions, therefore, each batch can modify up to 100 networks. + +As always, you should read the docs before diving in. If you know how these features work, then it will be easier to +understand and leverage this tool. + +Firmware upgrades endpoint: https://developer.cisco.com/meraki/api-v1/#!get-network-firmware-upgrades +Action batches: https://developer.cisco.com/meraki/api-v1/#!action-batches-overview + +NB: Once you start the script, there are no confirmation prompts or previews, so test in a lab if necessary. + +NB: When the final batch has been submitted, depending on the batch size, it may take a few minutes to finish. Feeling +creative? Then try extending this script (using existing code, for the most part) to confirm when the batches are +complete. Feeling super creative? Wrap this behind a Flask frontend and have yourself a merry little GUI. +""" + +# init Meraki Python SDK session +dashboard = meraki.DashboardAPI(suppress_logging=True, single_request_timeout=120) + +# Configurable options +# Organization ID. Replace this with your actual organization ID. +organization_id = "YOUR ORG ID HERE" # Use your own organization ID. +product_type = "appliance" +time_delta_in_days = 30 # Max is 1 month per the firmware upgrades endpoint docs +actions_per_batch = 100 # Max number of actions to submit in a batch. 100 is the maximum. Bigger batches take longer. +wait_factor = ( + 0.33 # Wait factor for action batches when the action batch queue is full. +) + +# Firmware IDs; not needed for rescheduling, only for upgrading. If you plan to use this for upgrading, then you should +# first GET the availableVersions IDs and use those here instead, since they have probably changed from the time this +# was published. +new_firmware_id = 2128 # Did you update this to your actual FW ID by GETing your availableFirmwareVersions? +old_firmware_id = 2009 # Did you update this to your actual FW ID by GETing your availableFirmwareVersions? + + +def time_formatter(date_time_stamp): + # Basic time formatter to return strings that the API requires + formatted_date_time_stamp = date_time_stamp.replace(microsecond=0).isoformat() + "Z" + return formatted_date_time_stamp + + +# Time stamps +utc_now = datetime.datetime.utcnow() +utc_future = utc_now + datetime.timedelta(days=time_delta_in_days) +utc_now_formatted = time_formatter(utc_now) +utc_future_formatted = time_formatter(utc_future) + +action_reschedule_existing = { + "products": {f"{product_type}": {"nextUpgrade": {"time": utc_future_formatted}}} +} + +# Use this action to schedule a new upgrade. If you do not provide a time param (as shown above), it will execute +# immediately. IMPORTANT: See API docs for more info before using this. +action_schedule_new_upgrade = { + "products": { + f"{product_type}": { + "nextUpgrade": { + "time": utc_future_formatted, + "toVersion": {"id": new_firmware_id}, + } + } + } +} + +# GET the network list +networks_list = dashboard.organizations.getOrganizationNetworks( + organizationId=organization_id +) + + +def format_single_action(resource, operation, body): + # Combine a single set of batch components into an action + action = {"resource": resource, "operation": operation, "body": body} + + return action + + +def create_single_upgrade_action(network_id): + # Create a single upgrade action + # AB component parts, rename action + action_resource = f"/networks/{network_id}/firmwareUpgrades" + action_operation = "update" + # Choose whether to reschedule an existing or start a new upgrade + action_body = action_reschedule_existing + + upgrade_action = format_single_action( + action_resource, action_operation, action_body + ) + + return upgrade_action + + +def run_an_action_batch(org_id, actions_list, synchronous=False): + # Create and run an action batch + batch_response = dashboard.organizations.createOrganizationActionBatch( + organizationId=org_id, + actions=actions_list, + confirmed=True, + synchronous=synchronous, + ) + + return batch_response + + +def create_action_list(net_list): + # Creates a list of actions and returns it + # Iterate through the list of network IDs and create an action for each, then collect it + list_of_actions = list() + + for network in net_list: + # Create the action + single_action = create_single_upgrade_action(network["id"]) + list_of_actions.append(single_action) + + return list_of_actions + + +def batch_actions_splitter(batch_actions): + # Split the list of actions into smaller lists of maximum 100 actions each + # For each ID in range length of network_ids + for i in range(0, len(batch_actions), actions_per_batch): + # Create an index range for network_ids of 100 items: + yield batch_actions[i : i + actions_per_batch] + + +def action_batch_runner(batch_actions_lists, org_id): + # Create an action batch for each list of actions + # Store the responses + responses = list() + number_of_batches = len(batch_actions_lists) + number_of_batches_submitted = 0 + + # Make a batch for each list + for batch_action_list in batch_actions_lists: + action_batch_queue_checker(org_id) + batch_response = run_an_action_batch(org_id, batch_action_list) + responses.append(batch_response) + number_of_batches_submitted += 1 + + # Inform user of progress. + print(f"Submitted batch {number_of_batches_submitted} of {number_of_batches}.") + + return responses + + +def action_batch_queue_checker(org_id): + all_action_batches = dashboard.organizations.getOrganizationActionBatches( + organizationId=org_id + ) + running_action_batches = [ + batch + for batch in all_action_batches + if batch["status"]["completed"] is False and batch["status"]["failed"] is False + ] + total_running_actions = 0 + + for batch in running_action_batches: + batch_actions = len(batch["actions"]) + total_running_actions += batch_actions + + wait_seconds = total_running_actions * wait_factor + + while len(running_action_batches) > 4: + print( + f"There are already five action batches in progress with a total of {total_running_actions} running " + f"actions. Waiting {wait_seconds} seconds." + ) + time.sleep(wait_seconds) + print("Checking again.") + + all_action_batches = dashboard.organizations.getOrganizationActionBatches( + organizationId=org_id + ) + running_action_batches = [ + batch + for batch in all_action_batches + if batch["status"]["completed"] is False + and batch["status"]["failed"] is False + ] + total_running_actions = 0 + + for batch in running_action_batches: + batch_actions = len(batch["actions"]) + total_running_actions += batch_actions + + wait_seconds = total_running_actions * wait_factor + + +# Create a list of upgrade actions +upgrade_actions_list = create_action_list(networks_list) + +# Split the list into multiple lists of max 100 items each +upgrade_actions_lists = list(batch_actions_splitter(upgrade_actions_list)) + +# Run the action batches to clone the networks +upgraded_networks_responses = action_batch_runner( + upgrade_actions_lists, organization_id +) diff --git a/examples/ci/enable_all_early_access.py b/examples/ci/enable_all_early_access.py new file mode 100644 index 00000000..19a7b5ed --- /dev/null +++ b/examples/ci/enable_all_early_access.py @@ -0,0 +1,51 @@ +"""Opt in to all available early access features for Meraki organizations. + +Reads EA_ORG_0 through EA_ORG_7 from the environment, and for each org +fetches the feature list, creates org-wide opt-ins for any features not +already enabled, and prints a summary report. + +WARNING: This will subject the organizations to unannounced breaking API +changes. +""" + +import asyncio +import os +import sys + +import meraki.aio + +ORG_IDS: list[str] = [v for i in range(8) if (v := os.environ.get(f"EA_ORG_{i}", ""))] +if not ORG_IDS: + sys.exit("No EA_ORG_* environment variables are set") + + +async def enable_early_access(dashboard, org_id: str): + features = await dashboard.organizations.getOrganizationEarlyAccessFeatures(org_id) + opt_ins = await dashboard.organizations.getOrganizationEarlyAccessFeaturesOptIns(org_id) + + opted_in_short_names = {oi["shortName"] for oi in opt_ins} + + tasks = [] + for feature in features: + if feature["shortName"] not in opted_in_short_names: + tasks.append(dashboard.organizations.createOrganizationEarlyAccessFeaturesOptIn(org_id, feature["shortName"])) + + new_opt_ins = await asyncio.gather(*tasks) if tasks else [] + + all_opt_ins = opt_ins + list(new_opt_ins) + print(f"\nOrg {org_id}: enabled {len(new_opt_ins)} new features ({len(opt_ins)} already opted in)") + print(f" All early access features ({len(all_opt_ins)} total):") + for oi in all_opt_ins: + print(f" - {oi['shortName']}") + + return all_opt_ins + + +async def main(): + async with meraki.aio.AsyncDashboardAPI(suppress_logging=True) as dashboard: + results = await asyncio.gather(*(enable_early_access(dashboard, org_id) for org_id in ORG_IDS)) + return results + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/find_early_access_operations.py b/examples/find_early_access_operations.py new file mode 100644 index 00000000..2cfe1c96 --- /dev/null +++ b/examples/find_early_access_operations.py @@ -0,0 +1,50 @@ +import meraki + +# The organization you supply here should be enrolled in early access to use this script. +# If your organization is not enrolled in early access, you will not see early access operations +# in the OAS. +ORGANIZATION_ID = "YOUR_ORG_ID_HERE" + +d = meraki.DashboardAPI(suppress_logging=True) + +oas = d.organizations.getOrganizationOpenapiSpec(ORGANIZATION_ID, version=3) + +paths = oas["paths"] + +operations = list() + +# parse paths, e.g. '/path/to/operation' +for path in paths: + # parse operation in each path, e.g. 'get' + for key in paths[path].keys(): + if "x-release-stage" in paths[path][key].keys(): + operations.append( + { + "id": paths[path][key]["operationId"], + "description": paths[path][key]["description"], + "x-release-stage": paths[path][key]["x-release-stage"], + } + ) + +print(f"Total number of non-GA operations is {len(operations)}.") + +# Find the channels. We only want the distinct values. +channels = list(set([operation["x-release-stage"] for operation in operations])) + +# Sort the channels. +channels.sort() + +# How many channels? +print(f"{len(channels)} channels found. The channels are {channels}.") + +# How many operations in each channel? +for channel in channels: + print( + f"There are {len([operation for operation in operations if operation['x-release-stage'] == channel])}" + f" operations in the {channel} channel." + ) + +if input("Would you like to see the operations? y/N") == "y": + print(operations) +else: + print("Goodbye.") diff --git a/examples/get_pages_iterator.py b/examples/get_pages_iterator.py new file mode 100644 index 00000000..95f1308a --- /dev/null +++ b/examples/get_pages_iterator.py @@ -0,0 +1,104 @@ +import argparse +import asyncio + +import meraki + +# Either input your API key below, or set an environment variable +# for example, in Terminal on macOS: export MERAKI_DASHBOARD_API_KEY=66839003d2861bc302b292eb66d3b247709f2d0d +api_key = "" + +ORGANIZATION_ID = "" +NETWORK_ID = "" + + +def getNetworksLegacy(meraki: meraki.DashboardAPI, perPage=5): + count = 0 + for x in meraki.organizations.getOrganizationNetworks( + organizationId=ORGANIZATION_ID, perPage=perPage, total_pages=-1 + ): + print(f"{x['id']} - {x['name']}") + count = count + 1 + print(f"Found {count} networks") + + +def getNetworksIterator(meraki: meraki.DashboardAPI, perPage=5): + count = 0 + for x in meraki.organizations.getOrganizationNetworks( + organizationId=ORGANIZATION_ID, perPage=perPage, total_pages=-1 + ): + print(f"{x['id']} - {x['name']}") + count = count + 1 + print(f"Found {count} networks") + + +def getNetworkEventsLegacy(meraki: meraki.DashboardAPI, perPage=5): + count = 0 + result = meraki.networks.getNetworkEvents( + networkId=NETWORK_ID, perPage=perPage, total_pages=50, productType="wireless" + ) + for x in result["events"]: + print(f"{x['occurredAt']}") + count = count + 1 + print(f"Found {count} events") + + +def getNetworkEventsIterator(meraki: meraki.DashboardAPI, perPage=5): + count = 0 + for x in meraki.networks.getNetworkEvents( + networkId=NETWORK_ID, perPage=perPage, total_pages=50, productType="wireless" + ): + print(f"{x['occurredAt']}") + count = count + 1 + print(f"Found {count} events") + + +async def main(): + argparse.ArgumentParser( + description="Example for demonstrating the use_iterator_for_get_pages parameter" + ) + + # Instantiate a Meraki dashboard API session + # NOTE: you have to use "async with" so that the session will be closed correctly at the end of the usage + meraki_iterator = meraki.DashboardAPI( + api_key, + base_url="https://api.meraki.com/api/v1", + log_file_prefix=__file__[:-3], + print_console=True, + use_iterator_for_get_pages=True, + ) + + meraki_legacy = meraki.DashboardAPI( + api_key, + base_url="https://api.meraki.com/api/v1", + log_file_prefix=__file__[:-3], + print_console=False, + use_iterator_for_get_pages=False, + ) + + print("Test legacy") + getNetworksLegacy(meraki_legacy) + + await asyncio.sleep(2) # just wait two seconds between the tests + + print("Test iterator") + getNetworksIterator(meraki_iterator) + + print("-----------------------------------------------------------------------") + print("-----------------------------------------------------------------------") + print("-----------------------------------------------------------------------") + print("-----------------------------------------------------------------------") + + print("Test legacy") + getNetworkEventsLegacy(meraki_legacy) + + await asyncio.sleep(2) # just wait two seconds between the tests + + print("Test iterator") + getNetworkEventsIterator(meraki_iterator) + + print("Script complete!") + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/examples/local_subnet_dumper.py b/examples/local_subnet_dumper.py new file mode 100644 index 00000000..c03da80c --- /dev/null +++ b/examples/local_subnet_dumper.py @@ -0,0 +1,134 @@ +import json + +import meraki + +""" +2023-11-13 +Author: John M. Kuchta ( TKIPisalegacycipher // https://github.com/TKIPisalegacycipher ) +Requrires: Meraki library v1.39.0 or later. + +This script gathers all your appliances' local subnets from your organizations' networks and dumps them to a JSON file. +You might find this handy for certain IPAM exercises. Subnets from appliances in "single LAN" mode will have VLAN 0. + +EXTRA CREDIT +If you are feeling adventurous, or just want an opportunity to flex your Python skills, consider re-writing this script +to work asynchronously, which can substantially improve the speed for large environments. You might start by gathering +all the relevant information asynchronously, then doing the list comprehensions after the API calls are complete. +""" + +# You can exclude specific organization or networks here. This is optional but recommended if you have a large +# deployment including lots of irrelevant networks. +excluded_org_ids = [] +excluded_network_ids = [] + +# init session +d = meraki.DashboardAPI() + +# gather orgs +my_orgs = d.organizations.getOrganizations() +my_orgs = [org for org in my_orgs if org["id"] not in excluded_org_ids] + +print("done gathering organizations") + +# gather networks +my_networks = [ + d.organizations.getOrganizationNetworks(organization["id"], total_pages=all) + for organization in my_orgs +] + +print("done gathering networks") + +my_appliance_networks = [ + network + for netlist in my_networks + for network in netlist + if network["id"] not in excluded_network_ids + and "appliance" in network["productTypes"] +] + +print("done gathering appliance networks") + +# gather routed networks -- appliances in passthrough mode don't have local subnets +my_appliance_routed_networks = [ + network + for network in my_appliance_networks + if d.appliance.getNetworkApplianceSettings(network["id"])["deploymentMode"] + == "routed" +] + +print("done gathering routed appliance networks") + +my_appliance_networks_with_vlans = [ + network + for network in my_appliance_routed_networks + if d.appliance.getNetworkApplianceVlansSettings(network["id"])["vlansEnabled"] +] + +print("done gathering appliance network vlan settings") + +my_appliance_networks_without_vlans = [ + network + for network in my_appliance_routed_networks + if network not in my_appliance_networks_with_vlans +] + +my_vlan_lists = [ + { + "organizationId": network["organizationId"], + "networkId": network["id"], + "vlans": d.appliance.getNetworkApplianceVlans(network["id"]), + } + for network in my_appliance_networks_with_vlans +] + +print("done gathering appliance network vlans") + +my_lans = [ + { + "organizationId": network["organizationId"], + "networkId": network["id"], + "lan": d.appliance.getNetworkApplianceSingleLan(network["id"]), + } + for network in my_appliance_networks_without_vlans +] + +print("done gathering appliance network lans") + +# unpack the subnets +vlan_subnets = list() + +for item in my_vlan_lists: + for vlan in item["vlans"]: + this_subnet = dict() + this_subnet["organizationId"] = item["organizationId"] + this_subnet["networkId"] = vlan["networkId"] + this_subnet["subnet"] = vlan["subnet"] + this_subnet["vlanId"] = vlan["id"] + this_subnet["applianceIp"] = vlan["applianceIp"] + vlan_subnets.append(this_subnet) + +lan_subnets = list() + +for item in my_lans: + this_subnet = dict() + this_subnet["organizationId"] = item["organizationId"] + this_subnet["networkId"] = item["networkId"] + this_subnet["subnet"] = item["lan"]["subnet"] + this_subnet["vlanId"] = 0 + this_subnet["applianceIp"] = item["lan"]["applianceIp"] + lan_subnets.append(this_subnet) + +all_subnets = vlan_subnets + lan_subnets + +print("done assembling subnets") + +# dump the subnets to a JSON file +json_object = json.dumps(all_subnets, indent=4) + +with open( + "subnets.json", + "w", +) as outfile: + outfile.write(json_object) + +print("subnets dumped to subnets.json") diff --git a/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/mappings.json b/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/mappings.json new file mode 100644 index 00000000..650c9456 --- /dev/null +++ b/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/mappings.json @@ -0,0 +1,162 @@ +{ + "knownParams": [ + { + "names": { + "old": "applianceIp", + "new": null + }, + "purpose": "MX appliance IP interface address", + "status": "deprecated", + "newEndpoint": null + }, + { + "names": { + "old": "networkId", + "new": null + }, + "purpose": "MX appliance network ID", + "status": "deprecated", + "newEndpoint": null + }, + { + "names": { + "old": "groupPolicyId", + "new": null + }, + "status": "deprecated", + "purpose": "MX appliance VLAN group policy ID", + "newEndpoint": null + }, + { + "names": { + "old": "id", + "new": "vlanId" + }, + "status": "renamed", + "purpose": "VLAN ID of the interface VLAN", + "newEndpoint": "interface" + }, + { + "names": { + "old": "name", + "new": "name" + }, + "status": "reused", + "purpose": "VLAN or interface name", + "newEndpoint": "interface" + }, + { + "names": { + "old": "subnet", + "new": "subnet" + }, + "status": "reused", + "purpose": "VLAN or interface subnet in CIDR format", + "newEndpoint": "interface" + }, + { + "names": { + "old": "dhcpBootOptionsEnabled", + "new": "bootOptionsEnabled" + }, + "status": "renamed", + "purpose": "DHCP boot options toggle", + "newEndpoint": "interfaceDhcp" + }, + { + "names": { + "old": "reservedIpRanges", + "new": "reservedIpRanges" + }, + "status": "reused", + "newEndpoint": "interfaceDhcp" + }, + { + "names": { + "old": "fixedIpAssignments", + "new": "fixedIpAssignments", + "newSubParam": "mac" + }, + "status": "transformed", + "transforms": [ + { + "type": "demote dynamic key", + "action": "make subparam" + }, + { + "type": "rename None", + "action": "name" + } + ], + "purpose": "DHCP reservation MAC", + "newEndpoint": "interfaceDhcp" + }, + { + "names": { + "old": "dnsNameservers", + "new": "dnsNameserversOption" + }, + "status": "transformed", + "transforms": [ + { + "type": "rename mode", + "action": "" + }, + { + "type": "split delimited strings", + "action": "\n" + }, + { + "type": "add param", + "action": "dnsCustomNameservers", + "fallback": "custom" + } + ], + "modes": [ + { + "old": "opendns", + "new": "openDns" + }, + { + "old": "google_dns", + "new": "googlePublicDns" + }, + { + "old": "upstream_dns", + "new": "custom" + } + ], + "purpose": "DNS Nameserver assignment mode", + "newEndpoint": "interfaceDhcp" + }, + { + "names": { + "old": "dhcpHandling", + "new": "dhcpMode" + }, + "status": "transformed", + "transforms": [ + { + "type": "rename mode", + "action": "" + } + ], + "modes": [ + { + "old": "Do not respond to DHCP requests", + "new": "dhcpDisabled" + }, + { + "old": "Run a DHCP server", + "new": "dhcpServer" + }, + { + "old": "Relay DHCP to another server", + "new": "dhcpRelay" + } + ], + "purpose": "DHCP mode", + "newEndpoint": "interfaceDhcp" + } + ] +} \ No newline at end of file diff --git a/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/merakiApplianceVlanToL3SwitchInterfaceMigrator.py b/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/merakiApplianceVlanToL3SwitchInterfaceMigrator.py new file mode 100644 index 00000000..e6c66f3b --- /dev/null +++ b/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/merakiApplianceVlanToL3SwitchInterfaceMigrator.py @@ -0,0 +1,542 @@ +# This script requires the Meraki SDK, sys, copy, json +import copy +import json +import sys + +import meraki + +# Treat your API key like a password. Store it in your environment variables as 'MERAKI_DASHBOARD_API_KEY' and let the SDK call it for you. +# Or, call it manually after importing Python's os module: +# API_KEY = os.getenv('MERAKI_DASHBOARD_API_KEY') + +# CUSTOMIZABLE STRINGS +SETTINGS_FILENAME = "settings.json" +MAPPINGS_FILENAME = "mappings.json" +CONFIRM = "Would you like to proceed?" +CHOICES = "(y/n)" +STATIC_SETTINGS_REVIEW = "Please review these static settings before proceeding." +APPLIANCE_SETTINGS_REVIEW = "Please review these appliance settings before proceeding." +VLAN_ERROR = "The appliance has a VLAN for which settings.json does not have interface information. Add the interface information to continue." +GOODBYE = "Now go check your L3 switch's routing settings." + + +# DEFINE a method that will ingest settings and param mappings +def ingest(settings_filename: str, mappings_filename: str): + # INGEST SETTINGS, AND PARAM MAPPINGS + # READ the settings and mappings + with open(settings_filename, "r") as settings_json: + settings = json.load(settings_json) + + with open(mappings_filename, "r") as mappings_json: + mappings = json.load(mappings_json) + + # CREATE a list of all the tagged VLAN IDs in settings.json + tagged_vlan_ids = [vlan["id"] for vlan in settings["vlans"]["others"]] + + # CREATE a list of all the interface IPs in settings.json + interface_ips = [vlan["interfaceIp"] for vlan in settings["vlans"]["others"]] + interface_ips.append(settings["vlans"]["native"]["interfaceIp"]) + + return (settings, mappings, tagged_vlan_ids, interface_ips) + + +# DEFINE a function to build working configs that we can manipulate. In this example, it will be useful to separate the interfaces with +# DHCP enabled vice disabled, because interfaces with DHCP enabled have options that need to be PUT to two different endpoints. +def build_working_configs( + starting_configs: list, + feature_old_toggle_param: str = "dhcpHandling", + feature_old_toggle_disabled_mode: str = "Do not respond to DHCP requests", +): + # DEEPCOPY the starting config. We'll manipulate a separate working config. DEEPCOPY is important because direct assignment creates a + # reference to the original object, whereas we want to modify this one without modifying the original, in case we want to debug and + # compare old with new. + working_configs = copy.deepcopy(starting_configs) + + # CREATE a reference to the working config that only contains items with the feature enabled. + # Anything we modify here will also be reflected in the working_config array. + working_configs_with_feature = [ + config + for config in working_configs + if config[feature_old_toggle_param] != feature_old_toggle_disabled_mode + ] + + return (working_configs, working_configs_with_feature) + + +# DEFINE a pretty print function. +def printj(ugly_json_object: list): + # The json.dumps() method converts a JSON object into human-friendly formatted text + pretty_json_string = json.dumps(ugly_json_object, indent=2, sort_keys=False) + print(pretty_json_string) + + +# DEFINE a method that will check each param against the knownParams in mappings.json and choose the appropriate action for that param, +# and then return a list of tasks per config item. +def build_task_list(*, old_configs: list, mappings: list): + # Make an empty to-do list. + to_do = [] + # Search every item (in this case, a VLAN) + for old_config in old_configs: + # Search every parameter in the old config for a matching one in the mappings file, + # and make a list out of it. + old_config_matched_params = [ + param + for param in mappings["knownParams"] + if param["names"]["old"] in old_config + ] + + # Make an empty dict() for each old_config where we'll store the lists of params needing action + params_to = dict() + + # REMOVE + # Check each matched param's mapping status and assign to an appropriate array based on the required action. + params_to["remove"] = [ + param + for param in old_config_matched_params + if param["status"] == "deprecated" + ] + remaining_params = [ + param + for param in old_config_matched_params + if param not in params_to["remove"] + ] + + # REUSE + params_to["reuse"] = [ + param for param in old_config_matched_params if param["status"] == "reused" + ] + remaining_params = [ + param for param in remaining_params if param not in params_to["reuse"] + ] + + # RENAME + params_to["rename"] = [ + param for param in old_config_matched_params if param["status"] == "renamed" + ] + remaining_params = [ + param for param in remaining_params if param not in params_to["rename"] + ] + + # TRANSFORM + params_to["transform"] = [ + param + for param in old_config_matched_params + if param["status"] == "transformed" + ] + remaining_params = [ + param for param in remaining_params if param not in params_to["transform"] + ] + + if len(remaining_params) != 0: + print( + f"I found params in the source that aren't mapped in the mappings file. These are: {remaining_params}" + ) + print("I will now quit so you can map those params.") + sys.exit() + + # Add this compiled dict to our to-do list. + to_do.append(params_to) + + return to_do + + +# DEFINE a method that will remove params +def remove_params(task_list: list, old_configs: list): + # Operate on each task in the list + modified_params = set() + modified_configs = set() + past_tense_verb = "Removed" + # Iterate through the tasks and configs at the same time. + for task, config in zip(task_list, old_configs): + # Iterate through each param in the task + for param in task: + modified_params.add(param["names"]["old"]) + modified_configs.add(config["name"]) + + # Remove the param + config.pop(param["names"]["old"]) + + print(f"{past_tense_verb} {modified_params} from {modified_configs}.\n") + + return old_configs + + +# DEFINE a method that will rename params +def rename_params(task_list: list, old_configs: list): + # Operate on each task in the list + modified_params = set() + modified_configs = set() + past_tense_verb = "Renamed" + # Iterate through the tasks and configs at the same time. + for task, config in zip(task_list, old_configs): + # Iterate through each param in the task + for param in task: + modified_params.add(param["names"]["old"]) + modified_configs.add(config["name"]) + + # Add a param with the new name, and assign it the old value while removing it from the list + config[param["names"]["new"]] = config.pop(param["names"]["old"]) + + print(f"{past_tense_verb} {modified_params} from {modified_configs}.\n") + return old_configs + + +# DEFINE a method that will replace null values in subkeys with blank strings +def transform_replace_none(*, param, config, transform): + modified_params = set() + modified_configs = set() + # Check each parameter for null values + for subparam in config[param["names"]["new"]]: + if None in subparam.values(): + subparam[transform["action"]] = "" + + return (modified_params, modified_configs) + + +# DEFINE a method that will rename a param's mode +def transform_rename_mode(*, param, config): + modified_params = set() + modified_configs = set() + # Check each mode for the current param + for mode in param["modes"]: + # If the current mode corresponds to a new one, replace it with the new one + if config[param["names"]["new"]] == mode["old"]: + config[param["names"]["new"]] = mode["new"] + modified_params.add(param["names"]["new"]) + modified_configs.add(config["name"]) + + return (modified_params, modified_configs) + + +# DEFINE a method that will split a param's mode +def transform_split_mode(*, param, config, transform): + modified_params = set() + modified_configs = set() + # Split the given param's mode by the delimiter specified in the mappings if the delimter is there + # if transform['action'] in config[param['names']['new']]: + config[param["names"]["new"]] = config[param["names"]["new"]].split( + transform["action"] + ) + modified_params.add(param["names"]["new"]) + modified_configs.add(config["name"]) + + return (modified_params, modified_configs) + + +# DEFINE a method that will split a param's mode +def transform_add_param(*, param, config, transform): + modified_params = set() + modified_configs = set() + # Add to the config a param with the new name from the transform, and assign it the old value + config[transform["action"]] = config[param["names"]["new"]] + # Assign the original param with the fallback mode + config[param["names"]["new"]] = transform["fallback"] + + modified_params.add(param["names"]["new"]) + modified_params.add(transform["action"]) + modified_configs.add(config["name"]) + + return (modified_params, modified_configs) + + +# DEFINE a method that will demote a dynamic key to a key-value pair +def transform_demote_dynamic_key(*, param, config, **kwargs: dict): + modified_params = set() + modified_configs = set() + interface_ips = kwargs["interface_ips"] + + # Make a new list that will contain all the new dicts + new_param_list = [] + + # For each instance of a dynamic key in this particular vlan config + for dynamic_key in config[param["names"]["new"]]: + # Make a new dict that lists the key as a value, and its nested key/value pairs as additional top-level key/value pairs + new_param = dict() + # Make the dynamic key the value of the new key name per mappings + new_param[param["names"]["newSubParam"]] = dynamic_key + # Now add all the subkeys + new_param.update(config[param["names"]["new"]][dynamic_key]) + + # LET'S TALK INTERFACE ADDRESSES + # If the IP address in the reservation matches a new interface IP for the switch, then the two settings will + # interfere. We'll simply drop the DHCP reservation for that IP address if it's the same as the IP address for + # the interface. + if new_param["ip"] not in interface_ips: + # Add the new param to the new param list + new_param_list.append(new_param) + + # Assign the new param list to the original key + config[param["names"]["new"]] = new_param_list + + modified_params.add(param["names"]["new"]) + modified_configs.add(config["name"]) + + return (modified_params, modified_configs) + + +# DEFINE a method that coordinates the transforms +def transform_coordinate(*, param, config, transform, **kwargs: dict): + modified_params = set() + modified_configs = set() + interface_ips = kwargs["interface_ips"] + + # Perform each transform if called for in mappings + if transform["type"] == "rename None": + # Hand off the transform to a single-purpose method + transformed_params, transformed_configs = transform_replace_none( + param=param, config=config, transform=transform + ) + # Merge the modification sets from that method + modified_params |= transformed_params + modified_configs |= transformed_configs + + if transform["type"] == "rename mode": + # Hand off the transform to a single-purpose method + transformed_params, transformed_configs = transform_rename_mode( + param=param, config=config + ) + # Merge the modification sets from that method + modified_params |= transformed_params + modified_configs |= transformed_configs + + if ( + transform["type"] == "split delimited strings" + and transform["action"] in config[param["names"]["new"]] + ): + # Hand off the transform to a single-purpose method + transformed_params, transformed_configs = transform_split_mode( + param=param, config=config, transform=transform + ) + # Merge the modification sets from that method + modified_params |= transformed_params + modified_configs |= transformed_configs + + if transform["type"] == "add param" and isinstance( + config[param["names"]["new"]], list + ): + # Hand off the transform to a single-purpose method + transformed_params, transformed_configs = transform_add_param( + param=param, config=config, transform=transform + ) + # Merge the modification sets from that method + modified_params |= transformed_params + modified_configs |= transformed_configs + + if transform["type"] == "demote dynamic key" and isinstance( + config[param["names"]["new"]], dict + ): + # Hand off the transform to a single-purpose method + transformed_params, transformed_configs = transform_demote_dynamic_key( + param=param, config=config, transform=transform, interface_ips=interface_ips + ) + # Merge the modification sets from that method + modified_params |= transformed_params + modified_configs |= transformed_configs + + return (modified_params, modified_configs) + + +# DEFINE a method that will transform params. +def transform_params(task_list: list, old_configs: list, **kwargs: dict): + # Operate on each task in the list + modified_params = set() + modified_configs = set() + past_tense_verb = "Transformed" + interface_ips = kwargs["interface_ips"] + # Iterate through the tasks and configs at the same time. + for task, config in zip(task_list, old_configs): + # Iterate through each param in the task + for param in task: + modified_params.add(param["names"]["old"]) + modified_configs.add(config["name"]) + + # Add a param with the new name, and assign it the old value while removing it from the list + config[param["names"]["new"]] = config.pop(param["names"]["old"]) + + # Iterate through each transformation + for transform in param["transforms"]: + transformed_params, transformed_configs = transform_coordinate( + param=param, + config=config, + transform=transform, + interface_ips=interface_ips, + ) + # Merge the modification sets from that method + modified_params |= transformed_params + modified_configs |= transformed_configs + + print(f"{past_tense_verb} {modified_params} from {modified_configs}.\n") + return old_configs + + +# DEFINE a method that adds the static configuration information from settings.json to each interface +def assign_statics(tagged_vlan_ids: list, settings, old_configs: list): + for interface in old_configs: + # ASSIGN the static information that isn't derived from the appliance config + # Set the native VLAN info + if interface["vlanId"] == settings["vlans"]["native"]["id"]: + interface["defaultGateway"] = settings["vlans"]["native"]["defaultGateway"] + interface["interfaceIp"] = settings["vlans"]["native"]["interfaceIp"] + # Set the tagged VLAN info + elif interface["vlanId"] in tagged_vlan_ids: + # Use a list comprehension, then pop it, to get the interface IP + interface["interfaceIp"] = [ + tagged_vlan["interfaceIp"] + for tagged_vlan in settings["vlans"]["others"] + if tagged_vlan["id"] == interface["vlanId"] + ].pop() + # We need to have this static information for each VLAN in the appliance config. If we don't find it, then we'll quit so you can fix settings.json. + else: + print(VLAN_ERROR) + sys.exit() + return old_configs + + +# DEFINE a function to create the interfaces +def create_interfaces(dashboard, settings, switch_interfaces: list): + # Start a list to collect responses. They will be handy because they'll have the created inteface IDs. + responses = [] + # Create each interface. The native VLAN will need special params from settings.json. + for interface in switch_interfaces: + if interface["vlanId"] != settings["vlans"]["native"]["id"]: + response = dashboard.switch.createDeviceSwitchRoutingInterface( + settings["switchSerial"], + interface["name"], + interface["interfaceIp"], + interface["vlanId"], + subnet=interface["subnet"], + ) + else: + response = dashboard.switch.createDeviceSwitchRoutingInterface( + settings["switchSerial"], + interface["name"], + interface["interfaceIp"], + interface["vlanId"], + subnet=interface["subnet"], + defaultGateway=interface["defaultGateway"], + ) + responses.append(response) + return responses + + +# DEFINE a function to update the DHCP config for each interface +def configure_interface_dhcp( + dashboard, serial, switch_interfaces_with_dhcp, created_interfaces +): + responses = [] + # Iterate through all the + for interface in switch_interfaces_with_dhcp: + created_dhcp_interface = [ + created_interface + for created_interface in created_interfaces + if created_interface["vlanId"] == interface["vlanId"] + ].pop() + if "dnsCustomNameservers" in interface: + response = dashboard.switch.updateDeviceSwitchRoutingInterfaceDhcp( + serial, + created_dhcp_interface["interfaceId"], + dhcpMode=interface["dhcpMode"], + dhcpLeaseTime=interface["dhcpLeaseTime"], + dnsNameserversOption=interface["dnsNameserversOption"], + dnsCustomNameservers=interface["dnsCustomNameservers"], + dhcpOptions=interface["dhcpOptions"], + reservedIpRanges=interface["reservedIpRanges"], + fixedIpAssignments=interface["fixedIpAssignments"], + ) + else: + response = dashboard.switch.updateDeviceSwitchRoutingInterfaceDhcp( + serial, + created_dhcp_interface["interfaceId"], + dhcpMode=interface["dhcpMode"], + dhcpLeaseTime=interface["dhcpLeaseTime"], + dnsNameserversOption=interface["dnsNameserversOption"], + dhcpOptions=interface["dhcpOptions"], + reservedIpRanges=interface["reservedIpRanges"], + fixedIpAssignments=interface["fixedIpAssignments"], + ) + responses.append(response) + return responses + + +# DEFINE a main method that will drive the config through all necessary param and mode changes, and push the change to Dashboard. +def main(): + # INGEST settings and mappings--we need some of these to start the connection + settings, mappings, tagged_vlan_ids, interface_ips = ingest( + SETTINGS_FILENAME, MAPPINGS_FILENAME + ) + + # START A MERAKI DASHBOARD API SESSION + # Initialize the Dashboard connection. + dashboard = meraki.DashboardAPI(suppress_logging=True) + + # SHOW the user the ingested settings + print(STATIC_SETTINGS_REVIEW) + for key, value in settings.items(): + print(f"The setting {key} has the following value(s):") + printj(value) + + # CONFIRM the operation + if input(f"{CONFIRM} {CHOICES}") != "y": + sys.exit() + + # GET appliance VLANs from Meraki Dashboard + appliance_vlans = dashboard.appliance.getNetworkApplianceVlans( + networkId=settings["networkId"] + ) + + # BUILD working configs that we can manipulate. + switch_interfaces, switch_interfaces_with_dhcp = build_working_configs( + appliance_vlans + ) + + # REMOVE, RENAME, REUSE, or TRANSFORM each param + task_list = build_task_list(old_configs=switch_interfaces, mappings=mappings) + + # MIGRATE the settings + task_list_types = dict() + task_list_types["remove"] = [task["remove"] for task in task_list] + task_list_types["rename"] = [task["rename"] for task in task_list] + task_list_types["transform"] = [task["transform"] for task in task_list] + + # REMOVE params + switch_interfaces = remove_params(task_list_types["remove"], switch_interfaces) + + # RENAME params + switch_interfaces = rename_params(task_list_types["rename"], switch_interfaces) + + # TRANSFORM params. One of these transformations requires the list of interface IPs. + switch_interfaces = transform_params( + task_list_types["transform"], switch_interfaces, interface_ips=interface_ips + ) + + # ASSIGN statics (see settings.json) + switch_interfaces = assign_statics(tagged_vlan_ids, settings, switch_interfaces) + + # We've now replaced all the params and modes necessary. However, unlike appliances, switches + # offer different endpoints for interface settings and interface DHCP settings. Therefore we + # will push some settings to the interface endpoint, and the DHCP settings to the DHCP endpoint. + + # Let's review what we've done: + print("I created these new configs:") + for interface in switch_interfaces: + printj(interface) + + # CREATE the interfaces + created_interfaces = create_interfaces(dashboard, settings, switch_interfaces) + + # CONFIGURE DHCP on the DHCP interfaces + configured_dhcp = configure_interface_dhcp( + dashboard, + settings["switchSerial"], + switch_interfaces_with_dhcp, + created_interfaces, + ) + + # CONFIRM + printj(created_interfaces) + printj(configured_dhcp) + print(GOODBYE) + + +if __name__ == "__main__": + main() diff --git a/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/settings.json b/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/settings.json new file mode 100644 index 00000000..f667ff37 --- /dev/null +++ b/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/settings.json @@ -0,0 +1,41 @@ +{ + "organizationId": "YOUR ORG ID", + "networkId": "YOUR NETWORK ID", + "switchSerial": "YOUR SWITCH SERIAL", + "applianceSerial": "YOUR APPLIANCE SERIAL", + "vlans": { + "native": { + "id": 1, + "defaultGateway": "10.1.1.1", + "interfaceIp": "10.1.24.1" + }, + "others": [ + { + "id": 9, + "interfaceIp": "192.168.9.2" + }, + { + "id": 10, + "interfaceIp": "192.168.10.2" + }, + { + "id": 100, + "interfaceIp": "192.168.100.2" + } + ] + }, + "fallbacks": [ + { + "knownParams": [ + { + "name": "dnsCustomNameservers", + "mode": [ + "10.1.5.1", + "208.67.222.222", + "208.67.220.220" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/org_wide_clients_v0.py b/examples/org_wide_clients_v0.py deleted file mode 100644 index 5c1a3510..00000000 --- a/examples/org_wide_clients_v0.py +++ /dev/null @@ -1,106 +0,0 @@ -import csv -from datetime import datetime -import os - -import meraki - -# Either input your API key below by uncommenting line 10 and changing line 16 to api_key=API_KEY, -# or set an environment variable (preferred) to define your API key. The former is insecure and not recommended. -# For example, in Linux/macOS: export MERAKI_DASHBOARD_API_KEY=093b24e85df15a3e66f1fc359f4c48493eaa1b73 -# API_KEY = '093b24e85df15a3e66f1fc359f4c48493eaa1b73' - - -def main(): - # Instantiate a Meraki dashboard API session - dashboard = meraki.DashboardAPI( - api_key='', - base_url='https://api-mp.meraki.com/api/v0/', - output_log=True, - log_file_prefix=os.path.basename(__file__)[:-3], - log_path='', - print_console=False - ) - - # Get list of organizations to which API key has access - organizations = dashboard.organizations.getOrganizations() - - # Iterate through list of orgs - for org in organizations: - print(f'\nAnalyzing organization {org["name"]}:') - org_id = org['id'] - - # Get list of networks in organization - try: - networks = dashboard.networks.getOrganizationNetworks(org_id) - except meraki.APIError as e: - print(f'Meraki API error: {e}') - print(f'status code = {e.status}') - print(f'reason = {e.reason}') - print(f'error = {e.message}') - continue - except Exception as e: - print(f'some other error: {e}') - continue - - # Create local folder - todays_date = f'{datetime.now():%Y-%m-%d}' - folder_name = f'Org {org_id} clients {todays_date}' - if folder_name not in os.listdir(): - os.mkdir(folder_name) - - # Iterate through networks - total = len(networks) - counter = 1 - print(f' - iterating through {total} networks in organization {org_id}') - for net in networks: - print(f'Finding clients in network {net["name"]} ({counter} of {total})') - try: - # Get list of clients on network, filtering on timespan of last 14 days - clients = dashboard.clients.getNetworkClients(net['id'], timespan=60*60*24*14, perPage=1000, total_pages='all') - except meraki.APIError as e: - print(f'Meraki API error: {e}') - print(f'status code = {e.status}') - print(f'reason = {e.reason}') - print(f'error = {e.message}') - except Exception as e: - print(f'some other error: {e}') - else: - if clients: - # Write to file - file_name = f'{net["name"]}.csv' - output_file = open(f'{folder_name}/{file_name}', mode='w', newline='\n') - field_names = clients[0].keys() - csv_writer = csv.DictWriter(output_file, field_names, delimiter=',', quotechar='"', - quoting=csv.QUOTE_ALL) - csv_writer.writeheader() - csv_writer.writerows(clients) - output_file.close() - print(f' - found {len(clients)}') - - counter += 1 - - # Stitch together one consolidated CSV per org - output_file = open(f'{folder_name}.csv', mode='w', newline='\n') - field_names = ['id', 'mac', 'description', 'ip', 'ip6', 'ip6Local', 'user', 'firstSeen', 'lastSeen', 'manufacturer', 'os', 'recentDeviceSerial', 'recentDeviceName', 'recentDeviceMac', 'ssid', 'vlan', 'switchport', 'usage', 'status', 'notes', 'smInstalled', 'groupPolicy8021x'] - field_names.insert(0, "Network Name") - field_names.insert(1, "Network ID") - - csv_writer = csv.DictWriter(output_file, field_names, delimiter=',', quotechar='"', quoting=csv.QUOTE_ALL) - csv_writer.writeheader() - for net in networks: - file_name = f'{net["name"]}.csv' - if file_name in os.listdir(folder_name): - with open(f'{folder_name}/{file_name}') as input_file: - csv_reader = csv.DictReader(input_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_ALL) - next(csv_reader) - for row in csv_reader: - row['Network Name'] = net['name'] - row['Network ID'] = net['id'] - csv_writer.writerow(row) - - -if __name__ == '__main__': - start_time = datetime.now() - main() - end_time = datetime.now() - print(f'\nScript complete, total runtime {end_time - start_time}') diff --git a/examples/org_wide_clients_v1.py b/examples/org_wide_clients_v1.py index 2457388a..b5f41642 100644 --- a/examples/org_wide_clients_v1.py +++ b/examples/org_wide_clients_v1.py @@ -1,106 +1,152 @@ import csv -from datetime import datetime import os +from datetime import datetime import meraki + # Either input your API key below by uncommenting line 10 and changing line 16 to api_key=API_KEY, # or set an environment variable (preferred) to define your API key. The former is insecure and not recommended. -# For example, in Linux/macOS: export MERAKI_DASHBOARD_API_KEY=093b24e85df15a3e66f1fc359f4c48493eaa1b73 -# API_KEY = '093b24e85df15a3e66f1fc359f4c48493eaa1b73' +# For example, in Linux/macOS: export MERAKI_DASHBOARD_API_KEY=your-key-here +# API_KEY = 'your-key-here' def main(): # Instantiate a Meraki dashboard API session dashboard = meraki.DashboardAPI( - api_key='', - base_url='https://api-mp.meraki.com/api/v1/', + api_key="", + base_url="https://api.meraki.com/api/v1/", output_log=True, log_file_prefix=os.path.basename(__file__)[:-3], - log_path='', - print_console=False + log_path="", + print_console=False, ) # Get list of organizations to which API key has access organizations = dashboard.organizations.getOrganizations() - + # Iterate through list of orgs for org in organizations: - print(f'\nAnalyzing organization {org["name"]}:') - org_id = org['id'] + print(f"\nAnalyzing organization {org['name']}:") + org_id = org["id"] # Get list of networks in organization try: networks = dashboard.organizations.getOrganizationNetworks(org_id) except meraki.APIError as e: - print(f'Meraki API error: {e}') - print(f'status code = {e.status}') - print(f'reason = {e.reason}') - print(f'error = {e.message}') + print(f"Meraki API error: {e}") + print(f"status code = {e.status}") + print(f"reason = {e.reason}") + print(f"error = {e.message}") continue except Exception as e: - print(f'some other error: {e}') + print(f"some other error: {e}") continue - + # Create local folder - todays_date = f'{datetime.now():%Y-%m-%d}' - folder_name = f'Org {org_id} clients {todays_date}' + todays_date = f"{datetime.now():%Y-%m-%d}" + folder_name = f"Org {org_id} clients {todays_date}" if folder_name not in os.listdir(): os.mkdir(folder_name) # Iterate through networks total = len(networks) counter = 1 - print(f' - iterating through {total} networks in organization {org_id}') + print(f" - iterating through {total} networks in organization {org_id}") for net in networks: - print(f'Finding clients in network {net["name"]} ({counter} of {total})') + print(f"Finding clients in network {net['name']} ({counter} of {total})") try: # Get list of clients on network, filtering on timespan of last 14 days - clients = dashboard.networks.getNetworkClients(net['id'], timespan=60*60*24*14, perPage=1000, total_pages='all') + clients = dashboard.networks.getNetworkClients( + net["id"], + timespan=60 * 60 * 24 * 14, + perPage=1000, + total_pages="all", + ) except meraki.APIError as e: - print(f'Meraki API error: {e}') - print(f'status code = {e.status}') - print(f'reason = {e.reason}') - print(f'error = {e.message}') + print(f"Meraki API error: {e}") + print(f"status code = {e.status}") + print(f"reason = {e.reason}") + print(f"error = {e.message}") except Exception as e: - print(f'some other error: {e}') + print(f"some other error: {e}") else: if clients: # Write to file - file_name = f'{net["name"]}.csv' - output_file = open(f'{folder_name}/{file_name}', mode='w', newline='\n') + file_name = f"{net['name']}.csv" + output_file = open( + f"{folder_name}/{file_name}", mode="w", newline="\n" + ) field_names = clients[0].keys() - csv_writer = csv.DictWriter(output_file, field_names, delimiter=',', quotechar='"', - quoting=csv.QUOTE_ALL) + csv_writer = csv.DictWriter( + output_file, + field_names, + delimiter=",", + quotechar='"', + quoting=csv.QUOTE_ALL, + extrasaction="ignore", + ) csv_writer.writeheader() csv_writer.writerows(clients) output_file.close() - print(f' - found {len(clients)}') + print(f" - found {len(clients)}") counter += 1 # Stitch together one consolidated CSV per org - output_file = open(f'{folder_name}.csv', mode='w', newline='\n') - field_names = ['id', 'mac', 'description', 'ip', 'ip6', 'ip6Local', 'user', 'firstSeen', 'lastSeen', 'manufacturer', 'os', 'recentDeviceSerial', 'recentDeviceName', 'recentDeviceMac', 'ssid', 'vlan', 'switchport', 'usage', 'status', 'notes', 'smInstalled', 'groupPolicy8021x'] + output_file = open(f"{folder_name}.csv", mode="w", newline="\n") + field_names = [ + "id", + "mac", + "description", + "ip", + "ip6", + "ip6Local", + "user", + "firstSeen", + "lastSeen", + "manufacturer", + "os", + "recentDeviceSerial", + "recentDeviceName", + "recentDeviceMac", + "ssid", + "vlan", + "switchport", + "usage", + "status", + "notes", + "smInstalled", + "groupPolicy8021x", + ] field_names.insert(0, "Network Name") field_names.insert(1, "Network ID") - - csv_writer = csv.DictWriter(output_file, field_names, delimiter=',', quotechar='"', quoting=csv.QUOTE_ALL) + + csv_writer = csv.DictWriter( + output_file, + field_names, + delimiter=",", + quotechar='"', + quoting=csv.QUOTE_ALL, + extrasaction="ignore", + ) csv_writer.writeheader() for net in networks: - file_name = f'{net["name"]}.csv' + file_name = f"{net['name']}.csv" if file_name in os.listdir(folder_name): - with open(f'{folder_name}/{file_name}') as input_file: - csv_reader = csv.DictReader(input_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_ALL) + with open(f"{folder_name}/{file_name}") as input_file: + csv_reader = csv.DictReader( + input_file, delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL + ) next(csv_reader) for row in csv_reader: - row['Network Name'] = net['name'] - row['Network ID'] = net['id'] + row["Network Name"] = net["name"] + row["Network ID"] = net["id"] csv_writer.writerow(row) -if __name__ == '__main__': +if __name__ == "__main__": start_time = datetime.now() main() end_time = datetime.now() - print(f'\nScript complete, total runtime {end_time - start_time}') + print(f"\nScript complete, total runtime {end_time - start_time}") diff --git a/examples/organization_deleter.py b/examples/organization_deleter.py new file mode 100644 index 00000000..27fbc18a --- /dev/null +++ b/examples/organization_deleter.py @@ -0,0 +1,93 @@ +# organization_deleter.py +# A script to clean up and delete obsolete organizations, especially for lab testing scenarios. +# Deletes all networks, config templates, extra admins, and releases from inventory all devices +# in the organizations being deleted. +############################### +# WARNING +# This script is highly destructive. It should not be used in production environments. +############################### + +import sys + +import meraki + +# This should be a list of string organization IDs you want to delete +# E.g., ["1","2","3"] +LIST_OF_ORGANIZATIONS_TO_DELETE = [] + +# This should be the single email address of the owner of the API key you're using to run this script. +# E.g., "tam@shan.ter" +EMAIL_ADDRESS_OF_API_KEY_OWNER = "" + +print( + f"This script will delete all orgs in this list: {LIST_OF_ORGANIZATIONS_TO_DELETE}" +) +confirmed = input("Are you sure you'd like to proceed? (yes/N)") + +# User will need to type yes to continue +if confirmed != "yes": + print("Aborting") + sys.exit() + +# Init dashboard session +d = meraki.DashboardAPI(suppress_logging=True) + +for organization in LIST_OF_ORGANIZATIONS_TO_DELETE: + print(f"Deleting networks in organization (id: {organization})...") + + # get networks + org_networks = d.organizations.getOrganizationNetworks(organization) + count_networks = len(org_networks) + print(f"There are {count_networks} networks to delete.") + for network in org_networks: + print(f"Deleting network (id: {network['id']})...") + delete_network = d.networks.deleteNetwork(network["id"]) + count_networks -= 1 + print(f"{count_networks} remaining.") + print("Done deleting networks.") + + # get config templates + org_templates = d.organizations.getOrganizationConfigTemplates(organization) + count_templates = len(org_templates) + for template in org_templates: + print(f"Deleting config template (id: {template['id']})...") + delete_network = d.organizations.deleteOrganizationConfigTemplate( + organization, template["id"] + ) + count_templates -= 1 + print(f"{count_templates} remaining.") + print("Done deleting config templates.") + + # get org inventory devices + org_devices = d.organizations.getOrganizationInventoryDevices(organization) + count_devices = len(org_devices) + print(f"There are {count_devices} devices to release from inventory.") + device_serials = [device["serial"] for device in org_devices] + if len(device_serials) > 0: + release_devices = d.organizations.releaseFromOrganizationInventory( + organization, serials=device_serials + ) + print(f"Released {count_devices} devices from inventory.") + print("Done releasing devices.") + + # get org admins + org_admins = d.organizations.getOrganizationAdmins(organization) + for admin in org_admins: + if admin["email"] != EMAIL_ADDRESS_OF_API_KEY_OWNER: + delete_admin = d.organizations.deleteOrganizationAdmin( + organization, admin["id"] + ) + + print(f"Done deleting networks and admins in organization (id: {organization}).") + +confirmed = input("Would you like to proceed with deleting the organizations? (yes/N)") + +if confirmed != "yes": + print("Aborting") + sys.exit() + +for organization in LIST_OF_ORGANIZATIONS_TO_DELETE: + delete_organization = d.organizations.deleteOrganization(organization) + print(f"Deleted organization (id: {organization}).") + +print("Done deleting organizations.") diff --git a/examples/ssid_tool/defaults.json b/examples/ssid_tool/defaults.json new file mode 100644 index 00000000..cae4aab9 --- /dev/null +++ b/examples/ssid_tool/defaults.json @@ -0,0 +1,2743 @@ +{ + "0": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "deny", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": false + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 0, + "name": "Default Settings - wireless WiFi", + "enabled": true, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "PSK (WPA2)" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": true, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "psk", + "psk": ")S}8ol]=rj8h\\;OODWm3N'+wozGvan", + "dot11w": { + "enabled": false, + "required": false + }, + "dot11r": { + "enabled": false, + "adaptive": false + }, + "encryptionMode": "wpa", + "wpaEncryptionMode": "WPA2 only", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "1": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 1, + "name": "Unconfigured SSID 2", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "2": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 2, + "name": "Unconfigured SSID 3", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "3": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 3, + "name": "Unconfigured SSID 4", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "4": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 4, + "name": "Unconfigured SSID 5", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "5": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 5, + "name": "Unconfigured SSID 6", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "6": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 6, + "name": "Unconfigured SSID 7", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "7": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 7, + "name": "Unconfigured SSID 8", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "8": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 8, + "name": "Unconfigured SSID 9", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "9": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 9, + "name": "Unconfigured SSID 10", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "10": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 10, + "name": "Unconfigured SSID 11", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "11": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 11, + "name": "Unconfigured SSID 12", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "12": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 12, + "name": "Unconfigured SSID 13", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "13": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 13, + "name": "Unconfigured SSID 14", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + }, + "14": { + "bonjourForwarding": { + "enabled": false, + "rules": [], + "exception": { + "enabled": false + } + }, + "deviceTypeGroupPolicies": { + "enabled": false, + "deviceTypePolicies": [] + }, + "eapOverride": {}, + "firewallL3": { + "rules": [ + { + "comment": "Wireless clients accessing LAN", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Local LAN" + }, + { + "comment": "Default rule", + "ipVer": "ipv4", + "policy": "allow", + "protocol": "Any", + "destPort": "Any", + "destCidr": "Any" + } + ], + "allowLanAccess": true + }, + "firewallL7": { + "rules": [] + }, + "hotspot20": { + "enabled": false, + "operator": { + "name": null + }, + "venue": { + "name": null, + "type": null + }, + "networkAccessType": null, + "domains": [], + "roamConsortOis": [], + "mccMncs": [], + "naiRealms": [] + }, + "schedules": { + "enabled": false, + "ranges": [], + "rangesInSeconds": [] + }, + "splashSettings": { + "splashMethod": "None", + "splashUrl": null, + "useSplashUrl": false, + "splashTimeout": 1440, + "redirectUrl": null, + "useRedirectUrl": false, + "themeId": null, + "language": "en", + "guestSponsorship": { + "durationInMinutes": null, + "guestCanRequestTimeframe": false + }, + "blockAllTrafficBeforeSignOn": true, + "controllerDisconnectionBehavior": "default", + "allowSimultaneousLogins": true, + "billing": { + "freeAccess": { + "enabled": false, + "durationInMinutes": 20 + }, + "prepaidAccessFastLoginEnabled": null, + "replyToEmailAddress": "meraki@chir.us" + }, + "welcomeMessage": null, + "userConsent": { + "required": false, + "message": null + }, + "splashLogo": { + "md5": null + }, + "splashImage": { + "md5": null + }, + "splashPrepaidFront": { + "md5": null + }, + "sentryEnrollment": { + "systemsManagerNetwork": { + "id": null, + "name": null + }, + "strength": "focused", + "enforcedSystems": [ + "iOS", + "Android" + ] + }, + "selfRegistration": { + "enabled": true, + "authorizationType": "admin" + } + }, + "trafficShapingRules": { + "trafficShapingEnabled": true, + "defaultRulesEnabled": true, + "rules": [] + }, + "vpn": { + "concentrator": {}, + "failover": { + "requestIp": null, + "heartbeatInterval": 10, + "idleTimeout": 30 + }, + "splitTunnel": { + "enabled": false, + "rules": [] + } + }, + "identityPsks": [], + "main": { + "number": 14, + "name": "Unconfigured SSID 15", + "enabled": false, + "splashPage": "None", + "ssidAdminAccessible": false, + "accessControl": { + "encryption": { + "mode": "Open" + }, + "bandwidth": { + "limit": "unlimited" + }, + "clientIpAssignment": { + "mode": "Local LAN" + }, + "clientsBlockedFromUsingLan": false, + "wiredClientsPartOfWifiNetwork": false, + "tunnel": { + "enabled": false, + "summary": "Disabled" + }, + "splashPage": { + "enabled": false, + "theme": "n/a" + } + }, + "authMode": "open", + "ipAssignmentMode": "Bridge mode", + "useVlanTagging": false, + "minBitrate": 11, + "bandSelection": "Dual band operation", + "perClientBandwidthLimitUp": 0, + "perClientBandwidthLimitDown": 0, + "perSsidBandwidthLimitUp": 0, + "perSsidBandwidthLimitDown": 0, + "mandatoryDhcpEnabled": false, + "enterpriseAdminAccess": "access enabled", + "lanIsolationEnabled": false, + "visible": true, + "availableOnAllAps": true, + "availabilityTags": [], + "wifiPersonalNetworkEnabled": false, + "speedBurst": { + "enabled": false + }, + "namedVlans": { + "tagging": { + "enabled": false + } + } + } + } +} \ No newline at end of file diff --git a/examples/ssid_tool/ssid_tool.py b/examples/ssid_tool/ssid_tool.py new file mode 100644 index 00000000..029d1cf8 --- /dev/null +++ b/examples/ssid_tool/ssid_tool.py @@ -0,0 +1,534 @@ +import argparse +import asyncio +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +import meraki.aio +from meraki.exceptions import AsyncAPIError + +TOOL_VERSION = "0.1.0b" +DEFAULT_NETWORK_ID = "" +DEFAULT_ORG_ID = "" +BACKUP_DIR = Path(__file__).parent / "ssid_backups" +DEFAULTS_FILE = Path(__file__).parent / "defaults.json" + +SSID_SUB_RESOURCES = { + "main": { + "get": "getNetworkWirelessSsid", + "update": "updateNetworkWirelessSsid", + }, + "bonjourForwarding": { + "get": "getNetworkWirelessSsidBonjourForwarding", + "update": "updateNetworkWirelessSsidBonjourForwarding", + }, + "deviceTypeGroupPolicies": { + "get": "getNetworkWirelessSsidDeviceTypeGroupPolicies", + "update": "updateNetworkWirelessSsidDeviceTypeGroupPolicies", + }, + "eapOverride": { + "get": "getNetworkWirelessSsidEapOverride", + "update": "updateNetworkWirelessSsidEapOverride", + }, + "firewallL3": { + "get": "getNetworkWirelessSsidFirewallL3FirewallRules", + "update": "updateNetworkWirelessSsidFirewallL3FirewallRules", + }, + "firewallL7": { + "get": "getNetworkWirelessSsidFirewallL7FirewallRules", + "update": "updateNetworkWirelessSsidFirewallL7FirewallRules", + }, + "hotspot20": { + "get": "getNetworkWirelessSsidHotspot20", + "update": "updateNetworkWirelessSsidHotspot20", + }, + "schedules": { + "get": "getNetworkWirelessSsidSchedules", + "update": "updateNetworkWirelessSsidSchedules", + }, + "splashSettings": { + "get": "getNetworkWirelessSsidSplashSettings", + "update": "updateNetworkWirelessSsidSplashSettings", + }, + "trafficShapingRules": { + "get": "getNetworkWirelessSsidTrafficShapingRules", + "update": "updateNetworkWirelessSsidTrafficShapingRules", + }, + "vpn": { + "get": "getNetworkWirelessSsidVpn", + "update": "updateNetworkWirelessSsidVpn", + }, +} + + +class Color: + RESET = "\033[0m" + RED = "\033[91m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + CYAN = "\033[96m" + BOLD = "\033[1m" + DIM = "\033[2m" + + +def cprint(msg: str, color: str = Color.RESET) -> None: + print(f"{color}{msg}{Color.RESET}") + + +class AsyncSpinner: + FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + def __init__(self, message: str = "Working"): + self.message = message + self._task: asyncio.Task | None = None + + async def _spin(self) -> None: + i = 0 + try: + while True: + frame = self.FRAMES[i % len(self.FRAMES)] + print( + f"\r{Color.CYAN}{frame} {self.message}...{Color.RESET}", + end="", + flush=True, + ) + i += 1 + await asyncio.sleep(0.1) + except asyncio.CancelledError: + print(f"\r{' ' * (len(self.message) + 10)}\r", end="", flush=True) + + async def __aenter__(self): + self._task = asyncio.create_task(self._spin()) + return self + + async def __aexit__(self, *_): + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + +IDENTITY_PSK_CREATE_PARAMS = ["name", "passphrase", "groupPolicyId", "expiresAt"] + +# Keys that are path parameters or read-only identifiers returned by GET +PATH_PARAMS = {"networkId", "number", "ssidNumber"} + +WRITE_EXCLUDE_KEYS = { + "splashSettings": { + "sentryEnrollment": "References network-specific Systems Manager network ID", + "guestSponsorship": "API returns null for durationInMinutes (expects integer)", + "billing": "API returns null for prepaidAccessFastLoginEnabled (expects boolean)", + }, + "hotspot20": { + "operator": "API returns null for name (expects string)", + "venue": "API returns null for name/type (expects strings)", + }, + "vpn": { + "failover": "API returns null for requestIp (expects string)", + "concentrator": "Requires a VPN concentrator configured on the network", + "splitTunnel": "Depends on concentrator; fails if VPN topology not present", + }, +} + + +def strip_nulls(obj: dict) -> dict: + def _recurse(val: object) -> object: + if isinstance(val, dict): + return {k: _recurse(v) for k, v in val.items() if v is not None} + if isinstance(val, list): + return [_recurse(item) for item in val] + return val + + result = _recurse(obj) + assert isinstance(result, dict) + return result + + +def prepare_payload(resource_name: str, raw: dict, apply_exclusions: bool = True) -> dict: + payload = strip_nulls({k: v for k, v in raw.items() if k not in PATH_PARAMS}) + if apply_exclusions: + exclude = WRITE_EXCLUDE_KEYS.get(resource_name) + if exclude: + payload = {k: v for k, v in payload.items() if k not in exclude} + if resource_name == "firewallL3" and "rules" in payload: + payload["rules"] = [r for r in payload["rules"] if r.get("destCidr") not in ("Local LAN", "local_lan")] + return payload + + +async def fetch_identity_psks(api: meraki.aio.AsyncDashboardAPI, network_id: str, ssid_number: int) -> list[dict]: + try: + return await api.wireless.getNetworkWirelessSsidIdentityPsks(network_id, str(ssid_number)) + except AsyncAPIError as e: + if e.status in (400, 404): + return [] + raise + + +async def swap_identity_psks( + api: meraki.aio.AsyncDashboardAPI, + network_id: str, + slot_a: int, + slot_b: int, + psks_a: list[dict], + psks_b: list[dict], +) -> tuple[list[str], list[str]]: + success = [] + failed = [] + + async def _delete(slot, psk): + try: + await api.wireless.deleteNetworkWirelessSsidIdentityPsk(network_id, str(slot), psk["id"]) + return None + except AsyncAPIError as e: + return f"identityPsks: delete {psk['name']} from slot {slot} ({e.status})" + + delete_tasks = [_delete(slot_a, p) for p in psks_a] + [_delete(slot_b, p) for p in psks_b] + if delete_tasks: + delete_results = await asyncio.gather(*delete_tasks) + failed.extend(r for r in delete_results if r) + + async def _create(slot, psk): + payload = {k: v for k, v in psk.items() if k in IDENTITY_PSK_CREATE_PARAMS and v is not None} + if "name" not in payload or "groupPolicyId" not in payload: + return None + try: + await api.wireless.createNetworkWirelessSsidIdentityPsk(network_id, str(slot), **payload) + return ("success", f"identityPsks: {psk['name']} -> slot {slot}") + except AsyncAPIError as e: + return ("failed", f"identityPsks: create {psk['name']} on slot {slot} ({e.status})") + + create_tasks = [_create(slot_b, p) for p in psks_a] + [_create(slot_a, p) for p in psks_b] + if create_tasks: + create_results = await asyncio.gather(*create_tasks) + for r in create_results: + if r: + (success if r[0] == "success" else failed).append(r[1]) + + return success, failed + + +async def fetch_ssid_sub_resources(api: meraki.aio.AsyncDashboardAPI, network_id: str, ssid_number: int) -> tuple[dict, list]: + """Fetch all sub-resources for a single SSID slot (excludes main config).""" + config = {} + errors = [] + + async def _fetch_one(resource_name: str, resource_info: dict): + getter = getattr(api.wireless, resource_info["get"]) + try: + return resource_name, await getter(network_id, str(ssid_number)), None + except AsyncAPIError as e: + if e.status in (400, 404): + return resource_name, None, str(e) + raise + + tasks = [_fetch_one(name, info) for name, info in SSID_SUB_RESOURCES.items() if name != "main"] + tasks.append(_fetch_psk_wrapper(api, network_id, ssid_number)) + + results = await asyncio.gather(*tasks) + + for result in results: + name, data, err = result + config[name] = data + if err: + errors.append((name, err)) + + return config, errors + + +async def _fetch_psk_wrapper(api: meraki.aio.AsyncDashboardAPI, network_id: str, ssid_number: int): + data = await fetch_identity_psks(api, network_id, ssid_number) + return "identityPsks", data, None + + +async def fetch_ssid_full_config(api: meraki.aio.AsyncDashboardAPI, network_id: str, ssid_number: int) -> tuple[dict, list]: + """Fetch main config + all sub-resources for a single SSID slot.""" + + async def _fetch_main(): + try: + return await api.wireless.getNetworkWirelessSsid(network_id, str(ssid_number)) + except AsyncAPIError as e: + if e.status in (400, 404): + return None + raise + + main, (sub, errors) = await asyncio.gather( + _fetch_main(), + fetch_ssid_sub_resources(api, network_id, ssid_number), + ) + sub["main"] = main + return sub, errors + + +async def backup_all_ssids(api: meraki.aio.AsyncDashboardAPI, network_id: str) -> tuple[Path, list]: + timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") + filename = f"{timestamp}-{network_id}-ssids.jsonc" + filepath = BACKUP_DIR / filename + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + + all_ssids = {} + all_errors = [] + + async with AsyncSpinner("Backing up SSIDs"): + ssids = await api.wireless.getNetworkWirelessSsids(network_id) + + async def _backup_slot(ssid: dict): + slot = ssid["number"] + sub, errors = await fetch_ssid_sub_resources(api, network_id, slot) + sub["main"] = ssid + return slot, sub, errors + + results = await asyncio.gather(*[_backup_slot(s) for s in ssids]) + for slot, sub, errors in results: + all_ssids[str(slot)] = sub + all_errors.extend(errors) + + header = f"// SSID Backup - ssid_tool v{TOOL_VERSION}\n// {datetime.now().isoformat()}\n// Network: {network_id}\n" + with open(filepath, "w", encoding="utf-8") as f: + f.write(header) + json.dump(all_ssids, f, indent=2, ensure_ascii=False) + + return filepath, all_errors + + +async def swap_ssid_slots( + api: meraki.aio.AsyncDashboardAPI, + network_id: str, + slot_a: int, + slot_b: int, +) -> dict: + async with AsyncSpinner(f"Reading slots {slot_a} and {slot_b}"): + (config_a, _), (config_b, _) = await asyncio.gather( + fetch_ssid_full_config(api, network_id, slot_a), + fetch_ssid_full_config(api, network_id, slot_b), + ) + + results = {"success": [], "failed": []} + + # Free up SSID names to avoid uniqueness constraint during swap + async with AsyncSpinner("Preparing swap"): + await asyncio.gather( + api.wireless.updateNetworkWirelessSsid(network_id, str(slot_a), name=f"_swap_tmp_{slot_a}"), + api.wireless.updateNetworkWirelessSsid(network_id, str(slot_b), name=f"_swap_tmp_{slot_b}"), + ) + + async with AsyncSpinner("Swapping configurations"): + + async def _apply(resource_name, src_slot, dst_slot, config): + updater = getattr(api.wireless, SSID_SUB_RESOURCES[resource_name]["update"]) + payload = prepare_payload(resource_name, config.get(resource_name) or {}, apply_exclusions=False) + if not payload: + return None + try: + await updater(network_id, str(dst_slot), **payload) + return ("success", f"{resource_name}: slot {src_slot} -> slot {dst_slot}") + except AsyncAPIError as e: + return ("failed", f"{resource_name}: slot {src_slot} -> slot {dst_slot} ({e.status})") + + swap_tasks = [] + for resource_name in SSID_SUB_RESOURCES: + if resource_name in ("vpn", "hotspot20"): + continue + swap_tasks.append(_apply(resource_name, slot_a, slot_b, config_a)) + swap_tasks.append(_apply(resource_name, slot_b, slot_a, config_b)) + + swap_results = await asyncio.gather(*swap_tasks) + for r in swap_results: + if r: + results[r[0]].append(r[1]) + + psks_a = config_a.get("identityPsks", []) + psks_b = config_b.get("identityPsks", []) + if psks_a or psks_b: + psk_success, psk_failed = await swap_identity_psks(api, network_id, slot_a, slot_b, psks_a, psks_b) + results["success"].extend(psk_success) + results["failed"].extend(psk_failed) + + return results + + +def load_defaults() -> dict: + with open(DEFAULTS_FILE, encoding="utf-8") as f: + return json.load(f) + + +def display_ssid_summary(ssids: list[dict]) -> None: + cprint(f"\n {'Slot':<5} {'Name':<34} {'Auth':<22} {'Status'}", Color.DIM) + cprint(f" {'─' * 72}", Color.DIM) + for ssid in sorted(ssids, key=lambda s: s["number"]): + status = f"{Color.GREEN}ON{Color.RESET}" if ssid["enabled"] else f"{Color.DIM}OFF{Color.RESET}" + name = ssid.get("name", "?") + auth = ssid.get("authMode", "?") + cprint(f" {ssid['number']:<5} {name:<34} {auth:<22} {status}", Color.RESET) + print() + + +async def reset_ssid_slot(api: meraki.aio.AsyncDashboardAPI, network_id: str, slot: int) -> dict: + defaults = load_defaults() + default_config = defaults.get(str(slot)) + if not default_config: + return {"success": [], "failed": [f"No default config for slot {slot}"]} + + cprint(" Creating safety backup...", Color.YELLOW) + backup_path, _ = await backup_all_ssids(api, network_id) + cprint(f" Saved: {backup_path.name}", Color.GREEN) + + results = {"success": [], "failed": []} + + async with AsyncSpinner(f"Resetting slot {slot} to defaults"): + + async def _reset_resource(resource_name, resource_info): + updater = getattr(api.wireless, resource_info["update"]) + payload = prepare_payload(resource_name, default_config.get(resource_name) or {}) + if not payload: + return None + try: + await updater(network_id, str(slot), **payload) + return ("success", f"{resource_name}: reset to default") + except AsyncAPIError as e: + return ("failed", f"{resource_name}: ({e.status})") + + reset_results = await asyncio.gather(*[_reset_resource(name, info) for name, info in SSID_SUB_RESOURCES.items()]) + for r in reset_results: + if r: + results[r[0]].append(r[1]) + + current_psks = await fetch_identity_psks(api, network_id, slot) + + async def _delete_psk(psk): + try: + await api.wireless.deleteNetworkWirelessSsidIdentityPsk(network_id, str(slot), psk["id"]) + return ("success", f"identityPsks: deleted {psk['name']}") + except AsyncAPIError as e: + return ("failed", f"identityPsks: delete {psk['name']} ({e.status})") + + if current_psks: + psk_results = await asyncio.gather(*[_delete_psk(p) for p in current_psks]) + for r in psk_results: + results[r[0]].append(r[1]) + + return results + + +def display_caveats() -> None: + cprint("\n Write Exclusions (not restored during swap/reset):", Color.YELLOW) + cprint(f" {'─' * 70}", Color.DIM) + for resource, keys in WRITE_EXCLUDE_KEYS.items(): + for key, reason in keys.items(): + cprint(f" {resource}.{key}", Color.RESET) + cprint(f" {reason}", Color.DIM) + cprint(f" {'─' * 70}\n", Color.DIM) + + +def display_menu() -> None: + cprint("\n╔══════════════════════════════════════╗", Color.CYAN) + cprint("║ Meraki SSID Management Tool ║", Color.CYAN) + cprint("╠══════════════════════════════════════╣", Color.CYAN) + cprint("║ 1. Backup SSIDs ║", Color.CYAN) + cprint("║ 2. Swap SSID Slots ║", Color.CYAN) + cprint("║ 3. Reset Slot to Default ║", Color.CYAN) + cprint("║ 4. See Caveats ║", Color.CYAN) + cprint("║ 0. Exit ║", Color.CYAN) + cprint("╚══════════════════════════════════════╝", Color.CYAN) + + +def display_report(title: str, results: dict) -> None: + cprint(f"\n{'─' * 40}", Color.DIM) + cprint(f" {title}", Color.BOLD) + cprint(f"{'─' * 40}", Color.DIM) + for item in results.get("success", []): + cprint(f" ✓ {item}", Color.GREEN) + for item in results.get("failed", []): + cprint(f" ✗ {item}", Color.RED) + total = len(results.get("success", [])) + len(results.get("failed", [])) + passed = len(results.get("success", [])) + cprint(f" {passed}/{total} operations succeeded", Color.DIM) + cprint(f"{'─' * 40}\n", Color.DIM) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Meraki SSID Management Tool") + parser.add_argument("-n", "--network-id", default=None, help="Network ID") + parser.add_argument("-o", "--org-id", default=None, help="Organization ID") + return parser.parse_args() + + +async def main() -> None: + args = parse_args() + network_id = args.network_id or os.environ.get("MERAKI_NETWORK_ID") or DEFAULT_NETWORK_ID + if not network_id: + cprint("Error: Network ID required (--network-id or MERAKI_NETWORK_ID)", Color.RED) + sys.exit(1) + + async with meraki.aio.AsyncDashboardAPI( + output_log=True, print_console=False, maximum_retries=10, smart_flow=True, smart_flow_logging=True + ) as api: + while True: + display_menu() + choice = input(f"{Color.YELLOW} Select option: {Color.RESET}").strip() + + if choice == "1": + filepath, errors = await backup_all_ssids(api, network_id) + results = {"success": [f"Saved to {filepath.name}"], "failed": []} + if errors: + for name, msg in errors: + results["failed"].append(f"{name}: {msg}") + display_report("Backup Complete", results) + + elif choice == "2": + async with AsyncSpinner("Fetching current SSIDs"): + ssids = await api.wireless.getNetworkWirelessSsids(network_id) + display_ssid_summary(ssids) + + backup_task = asyncio.create_task(backup_all_ssids(api, network_id)) + + try: + slot_a = int(input(f"{Color.YELLOW} First SSID slot (0-14): {Color.RESET}")) + slot_b = int(input(f"{Color.YELLOW} Second SSID slot (0-14): {Color.RESET}")) + except ValueError: + cprint(" Invalid input.", Color.RED) + backup_task.cancel() + continue + if not (0 <= slot_a <= 14 and 0 <= slot_b <= 14 and slot_a != slot_b): + cprint(" Slots must be 0-14 and different.", Color.RED) + backup_task.cancel() + continue + + cprint(" Waiting for backup to complete...", Color.DIM) + backup_path, _ = await backup_task + cprint(f" Saved: {backup_path.name}", Color.GREEN) + + results = await swap_ssid_slots(api, network_id, slot_a, slot_b) + display_report(f"Swap: Slot {slot_a} <-> Slot {slot_b}", results) + + elif choice == "3": + async with AsyncSpinner("Fetching current SSIDs"): + ssids = await api.wireless.getNetworkWirelessSsids(network_id) + display_ssid_summary(ssids) + try: + slot = int(input(f"{Color.YELLOW} Slot to reset (0-14): {Color.RESET}")) + except ValueError: + cprint(" Invalid input.", Color.RED) + continue + if not 0 <= slot <= 14: + cprint(" Slot must be 0-14.", Color.RED) + continue + results = await reset_ssid_slot(api, network_id, slot) + display_report(f"Reset: Slot {slot}", results) + + elif choice == "4": + display_caveats() + + elif choice == "0": + cprint("Done.", Color.GREEN) + break + + else: + cprint(" Invalid choice.", Color.RED) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/wireless_rf_profiles_overview.py b/examples/wireless_rf_profiles_overview.py new file mode 100644 index 00000000..f9ef555d --- /dev/null +++ b/examples/wireless_rf_profiles_overview.py @@ -0,0 +1,72 @@ +import meraki + +### work in progress + +print( + "To use this tool, you must supply your organization ID. Your organization ID should be an integer." +) +organization_id = "" +# organization_id = input(f"Enter your organization ID:") + +all_rf_profiles = list() + +d = meraki.DashboardAPI(suppress_logging=True) + +# fetch RF profiles assignments by device +print("Fetching RF profile assignments for the organization") +rf_profiles_assignments = ( + d.wireless.getOrganizationWirelessRfProfilesAssignmentsByDevice( + organization_id, total_pages=all + )["items"] +) + +# Find the top five profiles by how many devices are assigned to them +counts = list() +profiles = list() +profiles_and_counts = list() + +profile_ids = list() +for assignment in rf_profiles_assignments: + if assignment["rfProfile"]["id"] not in profile_ids: + profile_ids.append(assignment["rfProfile"]["id"]) + assignment["rfProfile"]["count"] = 0 + profiles.append(assignment["rfProfile"]) + +# assemble the profiles +for assignment in rf_profiles_assignments: + # build the list of relevant profiles + if assignment["rfProfile"]["id"] not in profiles: + profiles.append(assignment["rfProfile"]) + +# create a count for each profile +for profile in profiles: + profile["count"] = 0 + +# count up the number of times something's assigned +# for assignment in rf_profiles_assignments: + + +# fetch wireless networks +print("Fetching wireless networks") +networks = d.organizations.getOrganizationNetworks( + organization_id, productTypes=["wireless"], total_pages=all +) +wireless_networks = [ + network for network in networks if "wireless" in network["productTypes"] +] + +print("Fetching RF profiles per network") +rf_profiles_by_network = [ + d.wireless.getNetworkWirelessRfProfiles(network["id"]) + for network in wireless_networks +] + +# flatten the list +for network in rf_profiles_by_network: + for profile in network: + all_rf_profiles.append(profile) + +print("Fetching RF profiles per network") +print("Fetching RF profiles per network") + +print(1) diff --git a/generator/async_class_template.jinja2 b/generator/async_class_template.jinja2 index c2ac1ebf..8dc9b9dc 100644 --- a/generator/async_class_template.jinja2 +++ b/generator/async_class_template.jinja2 @@ -1,3 +1,6 @@ +import urllib + + class Async{{ class_name }}: def __init__(self, session): super().__init__() diff --git a/generator/async_function_template.jinja2 b/generator/async_function_template.jinja2 index b2cc616b..99a30c84 100644 --- a/generator/async_function_template.jinja2 +++ b/generator/async_function_template.jinja2 @@ -2,7 +2,7 @@ """ **{{ description }}** {{ doc_url }} - + {% for d in descriptions %} - {{ d }} {% endfor %} @@ -11,37 +11,58 @@ {% if kwarg_line|length > 0 %} {{ kwarg_line }} + {% if renamed_params|length > 0 %} + {% for safe, orig in renamed_params.items() %} + if "{{ safe }}" in kwargs: + kwargs["{{ orig }}"] = kwargs.pop("{{ safe }}") + {% endfor %} + + {% endif %} {% endif %} {% if assert_blocks|length > 0 %} - {% for param, values in assert_blocks %} - if '{{ param }}' in kwargs: + {% for param, values, nullable in assert_blocks %} + if "{{ param }}" in kwargs{% if nullable %} and kwargs["{{ param }}"] is not None{% endif %}: options = {{ values }} - assert kwargs['{{ param }}'] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}''' + assert kwargs["{{ param }}"] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}''' {% endfor %} {% endif %} metadata = { - 'tags': {{ tags }}, - 'operation': '{{ operation }}' + "tags": {{ tags|to_double_quote_list }}, + "operation": "{{ operation }}", } - resource = f'{{ resource }}' + {% for param in path_params %} + {{ param }} = urllib.parse.quote(str({{ param }}), safe="") + {% endfor %} + resource = f"{{ resource }}" {% if query_params|length > 0 %} - query_params = [{% for param in query_params %}'{{ param }}', {% endfor %}] + query_params = [{% for param in query_params %}"{{ param }}", {% endfor %}] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} {% endif %} {% if array_params|length > 0 %} - array_params = [{% for param in array_params %}'{{ param }}', {% endfor %}] + array_params = [{% for param in array_params %}"{{ param }}", {% endfor %}] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) {% endif %} {% if body_params|length > 0 %} - body_params = [{% for param in body_params %}'{{ param }}', {% endfor %}] + body_params = [{% for param in body_params %}"{{ param }}", {% endfor %}] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + {% endif %} + {% if query_params|length > 0 or body_params|length > 0 %} + if self._session._validate_kwargs: + all_params = {% if query_params|length > 0 %}query_params{% else %}[]{% endif %}{% if array_params|length > 0 %} + array_params{% endif %}{% if body_params|length > 0 %} + body_params{% endif %} + + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"{{ operation }}: ignoring unrecognized kwargs: {invalid}" + ) + {% endif %} {{ call_line }} diff --git a/generator/batch_class_template.jinja2 b/generator/batch_class_template.jinja2 new file mode 100644 index 00000000..e60c320d --- /dev/null +++ b/generator/batch_class_template.jinja2 @@ -0,0 +1,8 @@ +import urllib + +import httpx + + +class ActionBatch{{ class_name }}(object): + def __init__(self): + super(ActionBatch{{ class_name }}, self).__init__() diff --git a/generator/batch_function_template.jinja2 b/generator/batch_function_template.jinja2 new file mode 100644 index 00000000..d53d1ce3 --- /dev/null +++ b/generator/batch_function_template.jinja2 @@ -0,0 +1,64 @@ + def {{ operation }}(self{% if function_definition|length > 0 %}{{ function_definition }}{% endif %}): + """ + **{{ description }}** + {{ doc_url }} + + {% for d in descriptions %} + - {{ d }} + {% endfor %} + """ + + {% if kwarg_line|length > 0 %} + {{ kwarg_line }} + + {% if renamed_params|length > 0 %} + {% for safe, orig in renamed_params.items() %} + if "{{ safe }}" in kwargs: + kwargs["{{ orig }}"] = kwargs.pop("{{ safe }}") + {% endfor %} + + {% endif %} + {% endif %} + {% if assert_blocks|length > 0 %} + {% for param, values, nullable in assert_blocks %} + if "{{ param }}" in kwargs{% if nullable %} and kwargs["{{ param }}"] is not None{% endif %}: + options = {{ values }} + assert kwargs["{{ param }}"] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}''' + {% endfor %} + + {% endif %} + {% for param in path_params %} + {{ param }} = urllib.parse.quote(str({{ param }}), safe="") + {% endfor %} + resource = f"{{ resource }}" + + {% if query_params|length > 0 %} + query_params = [{% for param in query_params %}"{{ param }}", {% endfor %}] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + {% endif %} + {% if array_params|length > 0 %} + array_params = [{% for param in array_params %}"{{ param }}", {% endfor %}] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + {% endif %} + {% if query_params|length > 0 %} + if params: + resource += f"?{httpx.QueryParams(params)}" + + {% endif %} + {% if body_params|length > 0 and batch_operation != 'destroy' %} + body_params = [{% for param in body_params %}"{{ param }}", {% endfor %}] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + {% endif %} + action = { + "resource": resource, + "operation": "{{ batch_operation }}", + {% if body_params|length > 0 and batch_operation != 'destroy'%} + "body": payload, + {% endif %} + } + {{ call_line }} diff --git a/generator/class_template.jinja2 b/generator/class_template.jinja2 index 09a18270..2d29baaf 100644 --- a/generator/class_template.jinja2 +++ b/generator/class_template.jinja2 @@ -1,3 +1,6 @@ +import urllib + + class {{ class_name }}(object): def __init__(self, session): super({{ class_name }}, self).__init__() diff --git a/generator/common.py b/generator/common.py new file mode 100644 index 00000000..38b1d1e2 --- /dev/null +++ b/generator/common.py @@ -0,0 +1,157 @@ +import platform +import sys + + +REVERSE_PAGINATION = ["getNetworkEvents", "getOrganizationConfigurationChanges"] + + +def generate_pagination_parameters(operation: str): + ret = { + "total_pages": { + "type": "integer or string", + "description": 'use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages', + }, + "direction": { + "type": "string", + "description": 'direction to paginate, either "next" or "prev" (default) page' + if operation in REVERSE_PAGINATION + else 'direction to paginate, either "next" (default) or "prev" page', + }, + } + if operation == "getNetworkEvents": + ret["event_log_end_time"] = { + "type": "string", + "description": "ISO8601 Zulu/UTC time, to use in conjunction with startingAfter, " + "to retrieve events within a time window", + } + return ret + + +def check_python_version(): + version_warning_string = ( + f"This library generator requires Python 3.10 at minimum. " + f"Your interpreter version is: {platform.python_version()}. " + f"Please consult the readme at your convenience: " + f"https://github.com/meraki/dashboard-api-python/blob/main/generator/readme.md " + f"Additional details: " + f"python_version_tuple()[0] = {platform.python_version_tuple()[0]}; " + f"python_version_tuple()[1] = {platform.python_version_tuple()[1]} " + ) + + if not (int(platform.python_version_tuple()[0]) == 3 and int(platform.python_version_tuple()[1]) >= 10): + sys.exit(version_warning_string) + + +def docs_url(operation: str): + base_url = "https://developer.cisco.com/meraki/api-v1/#!" + ret = "" + for letter in operation: + if letter.islower(): + ret += letter + else: + ret += f"-{letter.lower()}" + return base_url + ret + + +def return_params(operation: str, params: dict, param_filters): + if not param_filters: + return params + else: + ret = {} + if "required" in param_filters: + ret.update({k: v for k, v in params.items() if "required" in v and v["required"]}) + if "pagination" in param_filters: + ret.update(generate_pagination_parameters(operation) if "perPage" in params else {}) + if "optional" in param_filters: + ret.update({k: v for k, v in params.items() if "required" in v and not v["required"]}) + if "path" in param_filters: + ret.update({k: v for k, v in params.items() if "in" in v and v["in"] == "path"}) + if "query" in param_filters: + ret.update({k: v for k, v in params.items() if "in" in v and v["in"] == "query"}) + if "body" in param_filters: + ret.update({k: v for k, v in params.items() if "in" in v and v["in"] == "body"}) + if "array" in param_filters: + ret.update({k: v for k, v in params.items() if "in" in v and v["type"] == "array"}) + if "enum" in param_filters: + ret.update({k: v for k, v in params.items() if "enum" in v}) + return ret + + +def unpack_param_without_schema(all_params: dict, this_param: dict, name: str, is_required: bool): + all_params[name] = {"required": is_required} + + for attribute in ("in", "type"): + all_params[name][attribute] = this_param[attribute] + + if "enum" in this_param: + all_params[name]["enum"] = this_param["enum"] + + if "description" in this_param: + all_params[name]["description"] = this_param["description"] + elif is_required: + all_params[name]["description"] = "(required)" + else: + all_params[name]["description"] = this_param.get("description", "") + + return all_params + + +def unpack_param_with_schema(all_params: dict, this_param: dict): + keys = this_param["schema"]["properties"] + + for k in keys: + if "required" in this_param["schema"] and k in this_param["schema"]["required"]: + all_params[k] = {"required": True} + else: + all_params[k] = {"required": False} + + all_params[k]["in"] = this_param["in"] + + for attribute in ("type", "description"): + all_params[k][attribute] = keys[k][attribute] + + if "enum" in keys[k]: + all_params[k]["enum"] = keys[k]["enum"] + + if "example" in this_param["schema"] and k in this_param["schema"]["example"]: + all_params[k]["example"] = this_param["schema"]["example"][k] + + return all_params + + +def organize_spec(paths, scopes): + operations = list() # list of operation IDs + for path, methods in paths.items(): + # method is the HTTP action, e.g., get, put, etc. + for method in methods: + # endpoint is the method for that specific path + endpoint = paths[path][method] + + # the endpoint has tags + tags = endpoint["tags"] + + # the endpoint has an operationId + operation = endpoint["operationId"] + + # add the operation ID to the list + operations.append(operation) + + # The endpoint has a scope defined by the first tag + # There are a handful of operations that are currently mistagged + # This helps ensure they are scoped to the correct module + if len(tags) > 2: + match tags[2]: + case "spaces": + scope = "spaces" + case _: + scope = tags[0] + else: + scope = tags[0] + + # Needs documentation + if path not in scopes[scope]: + scopes[scope][path] = {method: endpoint} + # Needs documentation + else: + scopes[scope][path][method] = endpoint + return operations, scopes diff --git a/generator/function_template.jinja2 b/generator/function_template.jinja2 index ec5f537d..14b51eb9 100644 --- a/generator/function_template.jinja2 +++ b/generator/function_template.jinja2 @@ -2,7 +2,7 @@ """ **{{ description }}** {{ doc_url }} - + {% for d in descriptions %} - {{ d }} {% endfor %} @@ -11,37 +11,58 @@ {% if kwarg_line|length > 0 %} {{ kwarg_line }} + {% if renamed_params|length > 0 %} + {% for safe, orig in renamed_params.items() %} + if "{{ safe }}" in kwargs: + kwargs["{{ orig }}"] = kwargs.pop("{{ safe }}") + {% endfor %} + + {% endif %} {% endif %} {% if assert_blocks|length > 0 %} - {% for param, values in assert_blocks %} - if '{{ param }}' in kwargs: + {% for param, values, nullable in assert_blocks %} + if "{{ param }}" in kwargs{% if nullable %} and kwargs["{{ param }}"] is not None{% endif %}: options = {{ values }} - assert kwargs['{{ param }}'] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}''' + assert kwargs["{{ param }}"] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}''' {% endfor %} {% endif %} metadata = { - 'tags': {{ tags }}, - 'operation': '{{ operation }}' + "tags": {{ tags|to_double_quote_list }}, + "operation": "{{ operation }}", } - resource = f'{{ resource }}' + {% for param in path_params %} + {{ param }} = urllib.parse.quote(str({{ param }}), safe="") + {% endfor %} + resource = f"{{ resource }}" {% if query_params|length > 0 %} - query_params = [{% for param in query_params %}'{{ param }}', {% endfor %}] + query_params = [{% for param in query_params %}"{{ param }}", {% endfor %}] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} {% endif %} {% if array_params|length > 0 %} - array_params = [{% for param in array_params %}'{{ param }}', {% endfor %}] + array_params = [{% for param in array_params %}"{{ param }}", {% endfor %}] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) {% endif %} {% if body_params|length > 0 %} - body_params = [{% for param in body_params %}'{{ param }}', {% endfor %}] + body_params = [{% for param in body_params %}"{{ param }}", {% endfor %}] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + {% endif %} + {% if query_params|length > 0 or body_params|length > 0 %} + if self._session._validate_kwargs: + all_params = {% if query_params|length > 0 %}query_params{% else %}[]{% endif %}{% if array_params|length > 0 %} + array_params{% endif %}{% if body_params|length > 0 %} + body_params{% endif %} + + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"{{ operation }}: ignoring unrecognized kwargs: {invalid}" + ) + {% endif %} {{ call_line }} diff --git a/generator/generate_library.py b/generator/generate_library.py index eff89445..f210a446 100644 --- a/generator/generate_library.py +++ b/generator/generate_library.py @@ -1,365 +1,818 @@ -READ_ME = """ -=== PREREQUISITES === -Include the jinja2 files in same directory as this script, and install Python requests -pip[3] install requests +import getopt +import json +import keyword +import os +import re +import subprocess +import sys -=== DESCRIPTION === -This script generates the Meraki Python library using either the public OpenAPI specification, or, with an API key & org -ID as inputs, a specific dashboard org's OpenAPI spec. +import jinja2 +import httpx -=== USAGE === -python[3] generate_libirary.py -o [-k ] -API key can, and is recommended to, be set as an environment variable named MERAKI_DASHBOARD_API_KEY. -""" +import common as common +from common import REVERSE_PAGINATION, docs_url, check_python_version, return_params +from parser_v3 import parse_params_v3, clear_cache +from generate_stubs import generate_stub_modules -import getopt -import jinja2 -import os -import sys +_keyword_param_violations = [] -import requests +def safe_param_name(name: str) -> str: + if keyword.iskeyword(name): + return name + "_" + return name -VERSION_NUMBER = 'custom' -REVERSE_PAGINATION = ['getNetworkEvents', 'getOrganizationConfigurationChanges'] +def _write_generation_report(version_number: str, api_version_number: str, is_github_action: bool): + from datetime import date -# Helper function to return pagination parameters depending on endpoint -def generate_pagination_parameters(operation): - ret = { - 'total_pages': { - 'type': 'integer or string', - 'description': 'total number of pages to retrieve, -1 or "all" for all pages', - }, - 'direction': { - 'type': 'string', - 'description': 'use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages',' - } - } - return ret + report_path = "docs/generation-report.md" + seen = set() + unique_violations = [] + for v in _keyword_param_violations: + key = (v["operation"], v["param"]) + if key not in seen: + seen.add(key) + unique_violations.append(v) -# Returns full link to endpoint's documentation on Developer Hub -def docs_url(operation): - base_url = 'https://developer.cisco.com/meraki/api-v1/#!' - ret = '' - for letter in operation: - if letter.islower(): - ret += letter - else: - ret += f'-{letter.lower()}' - return base_url + ret - - -# Helper function to return parameters within OAS spec, optionally based on list of input filters -def parse_params(operation, parameters, param_filters=[]): - if parameters is None: - return {} - - # Create dict with information on endpoint's parameters - params = {} - for p in parameters: - name = p['name'] - if 'schema' in p: - keys = p['schema']['properties'] - for k in keys: - if 'required' in p['schema'] and k in p['schema']['required']: - params[k] = {'required': True} - else: - params[k] = {'required': False} - params[k]['in'] = p['in'] - params[k]['type'] = keys[k]['type'] - params[k]['description'] = keys[k]['description'] - if 'enum' in keys[k]: - params[k]['enum'] = keys[k]['enum'] - if 'example' in p['schema'] and k in p['schema']['example']: - params[k]['example'] = p['schema']['example'][k] - elif 'required' in p and p['required']: - params[name] = {'required': True} - params[name]['in'] = p['in'] - params[name]['type'] = p['type'] - if 'description' in p: - params[name]['description'] = p['description'] - else: - params[name]['description'] = '(required)' - if 'enum' in p: - params[name]['enum'] = p['enum'] - else: - params[name] = {'required': False} - params[name]['in'] = p['in'] - params[name]['type'] = p['type'] - params[name]['description'] = p['description'] - if 'enum' in p: - params[name]['enum'] = p['enum'] - - # Add custom library parameters to handle pagination - if 'perPage' in params: - params.update(generate_pagination_parameters(operation)) - - # Return parameters based on matching input filters - if not param_filters: - return params + lines = [] + lines.append(f"## {date.today().isoformat()} | Library v{version_number} | API {api_version_number}\n") + lines.append("") + + if unique_violations: + lines.append("### Python keyword parameter conflicts\n") + lines.append("") + lines.append("The following operations have parameters whose names are Python reserved keywords.") + lines.append("The generator renames them with a trailing underscore (e.g., `from` -> `from_`).") + lines.append("These should be reported to the owning teams for resolution in the API spec.\n") + lines.append("") + lines.append("| Scope | Operation | Location | Param |") + lines.append("| --- | --- | --- | --- |") + for v in sorted(unique_violations, key=lambda x: (x["scope"], x["operation"])): + lines.append(f"| {v['scope']} | `{v['operation']}` | {v['location']} | `{v['param']}` |") + lines.append("") + else: + lines.append("No Python keyword parameter conflicts detected.\n") + lines.append("") + + new_entry = "\n".join(lines) + + existing = "" + header = "# Generation Report\n\n" + if os.path.isfile(report_path): + with open(report_path, "r", encoding="utf-8") as f: + existing = f.read() + if existing.startswith("# Generation Report"): + existing = existing[existing.index("\n") + 1 :].lstrip("\n") + + os.makedirs(os.path.dirname(report_path), exist_ok=True) + with open(report_path, "w", encoding="utf-8", newline=None) as f: + f.write(header + new_entry + "\n" + existing) + + print(f"Generation report written to {report_path}") + + +READ_ME = """ +=== PREREQUISITES === +Include the jinja2 files in same directory as this script, and install Python httpx +pip[3] install httpx + +=== DESCRIPTION === +This script generates the Meraki Python library from the OpenAPI v3 specification. + +=== USAGE === +python[3] generate_library_v3.py [-o ] [-k ] [-v ] [-a ] [-g ] [-s] + +-s generates .pyi type stub files for static analysis + +API key can, and is recommended to, be set as an environment variable named MERAKI_DASHBOARD_API_KEY.""" + + +def generate_library( + spec: dict, + version_number: str, + api_version_number: str, + is_github_action: bool, + generate_stubs: bool = False, + local_source: bool = False, + source_branch: str = "master", +): + # Clear parser cache at entry + clear_cache() + + # Supported scopes list will include organizations, networks, devices, and all product types. + supported_scopes = [ + "organizations", + "networks", + "devices", + "appliance", + "camera", + "cellularGateway", + "insight", + "sm", + "switch", + "wireless", + "sensor", + "administered", + "licensing", + "secureConnect", + "wirelessController", + "campusGateway", + "spaces", + "nac", + "users", + "support", + "assistant", + ] + + tags = spec["tags"] + paths = spec["paths"] + scopes = {tag["name"]: {} for tag in tags if tag["name"] in supported_scopes} + + used_scopes = set() + for path_item in paths.values(): + for method, endpoint in path_item.items(): + if method not in ("get", "post", "put", "delete", "patch"): + continue + ep_tags = endpoint.get("tags", []) + if len(ep_tags) > 2 and ep_tags[2] == "spaces": + used_scopes.add("spaces") + elif ep_tags: + used_scopes.add(ep_tags[0]) + unsupported = used_scopes - set(supported_scopes) + if unsupported: + sys.exit(f"ERROR: spec contains scopes not in supported_scopes: {sorted(unsupported)}") + + batchable_actions = spec["x-batchable-actions"] + + # Set template_dir if a GitHub action is invoking it + if is_github_action: + template_dir = "generator/" else: - ret = {} - if 'required' in param_filters: - ret.update({k: v for k, v in params.items() if 'required' in v and v['required']}) - if 'pagination' in param_filters: - ret.update(generate_pagination_parameters(operation) if 'perPage' in params else {}) - if 'optional' in param_filters: - ret.update({k: v for k, v in params.items() if 'required' in v and not v['required']}) - if 'path' in param_filters: - ret.update({k: v for k, v in params.items() if 'in' in v and v['in'] == 'path'}) - if 'query' in param_filters: - ret.update({k: v for k, v in params.items() if 'in' in v and v['in'] == 'query'}) - if 'body' in param_filters: - ret.update({k: v for k, v in params.items() if 'in' in v and v['in'] == 'body'}) - if 'array' in param_filters: - ret.update({k: v for k, v in params.items() if 'in' in v and v['type'] == 'array'}) - if 'enum' in param_filters: - ret.update({k: v for k, v in params.items() if 'enum' in v}) - return ret - - -def generate_library(spec): - # Only care about the first 10 tags, which are the 10 scopes for organizations, networks, devices, & 7 products - # scopes = ['organizations', 'networks', 'devices', - # 'appliance', 'camera', 'cellularGateway', 'insight', 'sm', 'switch', 'wireless'] - tags = spec['tags'] - paths = spec['paths'] - scopes = {tag['name']: {} for tag in tags[:10]} - - # Check paths and create sub-directories if needed - subdirs = ['meraki', 'meraki/api', 'meraki/aio', 'meraki/aio/api'] - for dir in subdirs: - if not os.path.isdir(dir): - os.mkdir(dir) + template_dir = "" + + # Check paths and create directories if needed + directories = [ + "meraki", + "meraki/session", + "meraki/api", + "meraki/api/batch", + "meraki/aio", + "meraki/aio/api", + ] + for directory in directories: + if not os.path.isdir(directory): + os.mkdir(directory) # Files that are not generated - non_generated = ['__init__.py', 'config.py', 'exceptions.py', 'rest_session.py', 'api/__init__.py', - 'aio/__init__.py', 'aio/rest_session.py', 'aio/api/__init__.py'] - base_url = 'https://raw.githubusercontent.com/meraki/dashboard-api-python/master/meraki/' + non_generated = [ + "__init__.py", + "_version.py", + "config.py", + "common.py", + "encoding.py", + "exceptions.py", + "response_handler.py", + "session/__init__.py", + "session/base.py", + "session/sync.py", + "session/async_.py", + "api/__init__.py", + "aio/__init__.py", + "aio/api/__init__.py", + "api/batch/__init__.py", + "smart_flow.py", + ] + if local_source: + # MERAKI_SOURCE_DIR lets the caller point at a preserved copy of meraki/ + # (the regen workflow stashes it before `rm -rf meraki`), avoiding the + # rate-limited raw.githubusercontent fetch. Falls back to the checkout. + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + local_meraki = os.environ.get("MERAKI_SOURCE_DIR") or os.path.join(repo_root, "meraki") + else: + base_url = f"https://raw.githubusercontent.com/meraki/dashboard-api-python/{source_branch}/meraki/" for file in non_generated: - response = requests.get(f'{base_url}{file}') - with open(f'meraki/{file}', 'w+') as fp: + if local_source: + with open(os.path.join(local_meraki, file), encoding="utf-8") as src: + contents = src.read() + else: + response = httpx.get(f"{base_url}{file}") + response.raise_for_status() # 429/5xx from raw.githubusercontent → fail loud, don't write error page into source contents = response.text - if file == '__init__.py': - start = contents.find('__version__ = ') - end = contents.find('\n', start) - contents = f'{contents[:start]}__version__ = \'{VERSION_NUMBER}\'{contents[end:]}' + with open(f"meraki/{file}", "w+", encoding="utf-8", newline=None) as fp: + if file == "_version.py": + start = contents.find("__version__ = ") + end = contents.find("\n", start) + contents = f"{contents[:start]}__version__ = '{version_number}'{contents[end:]}" + elif file == "__init__.py": + start = contents.find("__api_version__ = ") + end = contents.find("\n", start) + contents = f"{contents[:start]}__api_version__ = '{api_version_number}'{contents[end:]}" fp.write(contents) + # Filter paths to remove "parameters" key before passing to organize_spec + # organize_spec expects only HTTP method keys (get, post, put, delete) + filtered_paths = {} + for path, path_item in paths.items(): + filtered_paths[path] = {k: v for k, v in path_item.items() if k in ["get", "post", "put", "delete", "patch"]} + # Organize data from OpenAPI specification - operations = [] # list of operation IDs - for path, methods in paths.items(): - for method in methods: - endpoint = paths[path][method] - tags = endpoint['tags'] - operation = endpoint['operationId'] - operations.append(operation) - scope = tags[0] - if path not in scopes[scope]: - scopes[scope][path] = {method: endpoint} - else: - scopes[scope][path][method] = endpoint - print(f'Total of {len(operations)} endpoints found from OpenAPI spec...') + operations, scopes = common.organize_spec(filtered_paths, scopes) + + # Inform the user how many operations were found + print(f"Total of {len(operations)} endpoints found from OpenAPI spec...") # Generate API libraries - jinja_env = jinja2.Environment(trim_blocks=True, lstrip_blocks=True) + jinja_env = jinja2.Environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True) + jinja_env.filters["to_double_quote_list"] = lambda lst: json.dumps(lst) + + # Iterate through the scopes creating standard, asyncio and batch modules for each + generate_modules(spec, batchable_actions, jinja_env, scopes, template_dir) + + # Generate type stubs if requested + if generate_stubs: + print("Generating .pyi type stubs...") + generate_stub_modules(spec, scopes, jinja_env, template_dir) + # Write py.typed marker for PEP 561 (cwd-relative, like the module writers) + open("meraki/py.typed", "w").close() + print("Type stubs and py.typed marker created.") + + # Format generated code with ruff + print("Formatting generated code with ruff...") + subprocess.run(["ruff", "check", "--fix", "--quiet", "meraki/"], check=False) + subprocess.run(["ruff", "format", "--quiet", "meraki/"], check=False) + + # Write generation report + _write_generation_report(version_number, api_version_number, is_github_action) + + +def generate_modules(spec, batchable_actions, jinja_env, scopes, template_dir): for scope in scopes: - print(f'...generating {scope}') + print(f"...generating {scope}") section = scopes[scope] - with open(f'meraki/api/{scope}.py', 'w') as output: - with open('class_template.jinja2') as fp: - class_template = fp.read() - template = jinja_env.from_string(class_template) - output.write( - template.render( - class_name=scope[0].upper() + scope[1:], - ) - ) - - async_output = open(f'meraki/aio/api/{scope}.py', 'w') - with open('async_class_template.jinja2') as fp: - class_template = fp.read() - template = jinja_env.from_string(class_template) - async_output.write( - template.render( - class_name=scope[0].upper() + scope[1:], - ) + # Generate the standard module + with ( + open(f"meraki/api/{scope}.py", "w", encoding="utf-8", newline=None) as output, + open(f"meraki/aio/api/{scope}.py", "w", encoding="utf-8", newline=None) as async_output, + open(f"meraki/api/batch/{scope}.py", "w", encoding="utf-8", newline=None) as batch_output, + ): + modules = [ + {"template_name": "class_template.jinja2", "module_output": output}, + { + "template_name": "async_class_template.jinja2", + "module_output": async_output, + }, + { + "template_name": "batch_class_template.jinja2", + "module_output": batch_output, + }, + ] + + # Generate modules + for module in modules: + render_class_template( + jinja_env, + template_dir, + module["template_name"], + module["module_output"], + scope, ) - for path, methods in section.items(): - for method, endpoint in methods.items(): - # Get metadata - tags = endpoint['tags'] - operation = endpoint['operationId'] - description = endpoint['summary'] - parameters = endpoint['parameters'] if 'parameters' in endpoint else None - responses = endpoint['responses'] # not actually used here for library generation - - # Function definition - definition = '' - if parameters: - for p, values in parse_params(operation, parameters, 'required').items(): - if values['type'] == 'array': - definition += f', {p}: list' - elif values['type'] == 'number': - definition += f', {p}: float' - elif values['type'] == 'integer': - definition += f', {p}: int' - elif values['type'] == 'boolean': - definition += f', {p}: bool' - elif values['type'] == 'object': - definition += f', {p}: dict' - elif values['type'] == 'string': - definition += f', {p}: str' - - if 'perPage' in parse_params(operation, parameters): - if operation in REVERSE_PAGINATION: - definition += ", total_pages=1, direction='prev'" - else: - definition += ", total_pages=1, direction='next'" - - if parse_params(operation, parameters, ['optional']): - definition += f', **kwargs' - - # Docstring - param_descriptions = [] - all_params = parse_params(operation, parameters, ['required', 'pagination', 'optional']) - if all_params: - for p, values in all_params.items(): - param_descriptions.append(f'{p} ({values["type"]}): {values["description"]}') - - # Combine keyword args with locals - kwarg_line = '' - if parse_params(operation, parameters, ['optional']): - kwarg_line = 'kwargs.update(locals())' - elif parse_params(operation, parameters, ['query', 'array', 'body']): - kwarg_line = 'kwargs = locals()' - - # Assert valid values for enum - enum_params = parse_params(operation, parameters, ['enum']) - assert_blocks = [] - if enum_params: - for p, values in enum_params.items(): - assert_blocks.append((p, values['enum'])) - - # Function body for GET endpoints - query_params = array_params = body_params = {} - if method == 'get': - query_params = parse_params(operation, parameters, 'query') - array_params = parse_params(operation, parameters, 'array') - pagination_params = parse_params(operation, parameters, 'pagination') - if query_params or array_params: - if pagination_params: - call_line = 'return self._session.get_pages(metadata, resource, params, total_pages, direction)' - else: - call_line = 'return self._session.get(metadata, resource, params)' + # Generate API & Asyncio API functions + generate_standard_and_async_functions(jinja_env, template_dir, section, output, async_output, spec) + + # Generate API action batch functions + generate_action_batch_functions( + jinja_env, + template_dir, + section, + batch_output, + batchable_actions, + spec, + ) + + +def render_class_template( + jinja_env: jinja2.Environment, + template_dir: str, + template_name: str, + output: open, + scope: str, +): + with open(f"{template_dir}{template_name}", encoding="utf-8", newline=None) as fp: + class_template = fp.read() + template = jinja_env.from_string(class_template) + output.write( + template.render( + class_name=scope[0].upper() + scope[1:], + ) + ) + + +def generate_standard_and_async_functions( + jinja_env: jinja2.Environment, + template_dir: str, + section: dict, + output: open, + async_output: open, + spec: dict, +): + for path, methods in section.items(): + for method, endpoint in methods.items(): + # Get metadata + tags = endpoint["tags"] + operation = endpoint["operationId"] + description = endpoint.get("summary", endpoint.get("description", "")) + + # Get path_item from spec for parse_params_v3 + path_item = spec["paths"][path] + + # Parse params using v3 parser + all_params, metadata = parse_params_v3(endpoint, path_item, spec) + + # Function definition + definition = "" + defined_params = set() + renamed_params = {} + + # Add required params to definition + for p, values in return_params(operation, all_params, ["required"]).items(): + defined_params.add(p) + safe_p = safe_param_name(p) + if safe_p != p: + renamed_params[safe_p] = p + if values["type"] == "array": + definition += f", {safe_p}: list" + elif values["type"] == "number": + definition += f", {safe_p}: float" + elif values["type"] == "integer": + definition += f", {safe_p}: int" + elif values["type"] == "boolean": + definition += f", {safe_p}: bool" + elif values["type"] == "object": + definition += f", {safe_p}: dict" + elif values["type"] == "string": + definition += f", {safe_p}: str" + + # Path params must be in the signature for URL construction + for p, values in return_params(operation, all_params, ["path"]).items(): + if p not in defined_params: + defined_params.add(p) + safe_p = safe_param_name(p) + if safe_p != p: + renamed_params[safe_p] = p + if values["type"] == "array": + definition += f", {safe_p}: list" + elif values["type"] == "number": + definition += f", {safe_p}: float" + elif values["type"] == "integer": + definition += f", {safe_p}: int" + elif values["type"] == "boolean": + definition += f", {safe_p}: bool" + elif values["type"] == "object": + definition += f", {safe_p}: dict" + elif values["type"] == "string": + definition += f", {safe_p}: str" + + # Catch params referenced in the URL but not declared as path params + for p in re.findall(r"\{(\w+)\}", path): + if p not in defined_params: + defined_params.add(p) + safe_p = safe_param_name(p) + if safe_p != p: + renamed_params[safe_p] = p + definition += f", {safe_p}: str" + + # Add pagination params if perPage exists + if "perPage" in all_params: + if operation in REVERSE_PAGINATION: + definition += ", total_pages=1, direction='prev'" + else: + definition += ", total_pages=1, direction='next'" + if operation == "getNetworkEvents": + definition += ", event_log_end_time=None" + + # call_line is bound per-method below; initialize to fail loudly if a + # method slips through without setting it (rather than raising NameError). + call_line = None + + query_params = return_params(operation, all_params, ["query"]) + array_params = {k: v for k, v in query_params.items() if v["type"] == "array"} + body_params = path_params = {} + + # Function body for GET endpoints + if method == "get": + path_params = return_params(operation, all_params, ["path"]) + + # Function body for POST/PUT/PATCH endpoints + elif method == "post" or method == "put" or method == "patch": + body_params = return_params(operation, all_params, ["body"]) + path_params = return_params(operation, all_params, ["path"]) + + # Function body for DELETE endpoints + elif method == "delete": + path_params = return_params(operation, all_params, ["path"]) + + # Add **kwargs if optional params OR body/query/array params exist (templates use kwargs.items()) + if return_params(operation, all_params, ["optional"]) or body_params or query_params or array_params: + definition += ", **kwargs" + + # Docstring + param_descriptions = list() + all_params_for_doc = return_params(operation, all_params, ["required", "pagination", "optional"]) + if all_params_for_doc: + for p, values in all_params_for_doc.items(): + param_descriptions.append(f"{p} ({values['type']}): {values['description']}") + + kwarg_line = "" + if return_params(operation, all_params, ["optional"]): + kwarg_line = "kwargs.update(locals())" + elif return_params(operation, all_params, ["query", "array", "body"]): + kwarg_line = "kwargs = locals()" + + # Assert valid values for enum + enum_params = return_params(operation, all_params, ["enum"]) + assert_blocks = list() + if enum_params: + for p, values in enum_params.items(): + assert_blocks.append((p, values["enum"], values.get("nullable", False))) + + # Generate call_line based on method + if method == "get": + pagination_params = return_params(operation, all_params, ["pagination"]) + if query_params or array_params: + if pagination_params: + if operation == "getNetworkEvents": + call_line = "return self._session.get_pages(metadata, resource, params, total_pages, direction, event_log_end_time)" else: - call_line = 'return self._session.get(metadata, resource)' + call_line = "return self._session.get_pages(metadata, resource, params, total_pages, direction)" + else: + call_line = "return self._session.get(metadata, resource, params)" + else: + call_line = "return self._session.get(metadata, resource)" + + elif method == "post" or method == "put" or method == "patch": + args = "metadata, resource" + if body_params: + args += ", payload" + if query_params: + args += ", params=params" + call_line = f"return self._session.{method}({args})" + + elif method == "delete": + if query_params: + call_line = "return self._session.delete(metadata, resource, params)" + else: + call_line = "return self._session.delete(metadata, resource)" + + assert call_line is not None, f"call_line was not set for {operation} (method={method})" + + # Ensure all URL-referenced params get quote lines in the template + for p in re.findall(r"\{(\w+)\}", path): + if p not in path_params: + path_params[p] = {"type": "string", "in": "path"} + + # Sanitize path_params keys and resource path for Python keywords + safe_path_params = {} + safe_resource = path + for p, v in path_params.items(): + safe_p = safe_param_name(p) + safe_path_params[safe_p] = v + if safe_p != p: + safe_resource = safe_resource.replace("{" + p + "}", "{" + safe_p + "}") + + # Record keyword param violations for the generation report + if renamed_params: + for safe_p, orig_p in renamed_params.items(): + location = all_params.get(orig_p, {}).get("in", "unknown") + _keyword_param_violations.append( + { + "operation": operation, + "param": orig_p, + "location": location, + "scope": tags[0] if tags else "unknown", + } + ) - # Function body for POST/PUT endpoints - elif method == 'post' or method == 'put': - body_params = parse_params(operation, parameters, 'body') - if body_params: - call_line = f'return self._session.{method}(metadata, resource, payload)' - else: - call_line = f'return self._session.{method}(metadata, resource)' - - # Function body for DELETE endpoints - elif method == 'delete': - call_line = 'return self._session.delete(metadata, resource)' - - # Add function to files - with open('function_template.jinja2') as fp: - function_template = fp.read() - template = jinja_env.from_string(function_template) - output.write( - '\n\n' + - template.render( - operation=operation, - function_definition=definition, - description=description, - doc_url=docs_url(operation), - descriptions=param_descriptions, - kwarg_line=kwarg_line, - all_params=list(all_params.keys()), - assert_blocks=assert_blocks, - tags=tags, - resource=path, - query_params=query_params, - array_params=array_params, - body_params=body_params, - call_line=call_line, - ) + # Add function to files + with open( + f"{template_dir}function_template.jinja2", + encoding="utf-8", + newline=None, + ) as fp: + function_template = fp.read() + template = jinja_env.from_string(function_template) + rendered = template.render( + operation=operation, + function_definition=definition, + description=description, + doc_url=docs_url(operation), + descriptions=param_descriptions, + kwarg_line=kwarg_line, + all_params=list(all_params_for_doc.keys()) if all_params_for_doc else [], + assert_blocks=assert_blocks, + tags=tags, + resource=safe_resource, + query_params=query_params, + array_params=array_params, + body_params=body_params, + path_params=safe_path_params, + call_line=call_line, + renamed_params=renamed_params, + ) + output.write("\n\n" + rendered) + async_output.write("\n\n" + rendered) + + +def generate_action_batch_functions( + jinja_env: jinja2.Environment, + template_dir: str, + section: dict, + batch_output: open, + batchable_actions: list, + spec: dict, +): + # Build list of batchable action summaries for matching + batchable_action_summaries = [action["summary"] for action in batchable_actions] + + for path, methods in section.items(): + for method, endpoint in methods.items(): + # Match by description OR summary (v3 inconsistency) + endpoint_desc = endpoint.get("description", "") + endpoint_summary = endpoint.get("summary", "") + + if endpoint_desc in batchable_action_summaries or endpoint_summary in batchable_action_summaries: + # Get metadata + tags = endpoint["tags"] + operation = endpoint["operationId"] + description = endpoint.get("description", endpoint_summary) + summary = endpoint.get("summary", endpoint_desc) + + # Find matching action + this_action = None + for action in batchable_actions: + if action["summary"] == description or action["summary"] == summary: + this_action = action + break + + if not this_action: + continue + + batch_operation = this_action["operation"] + + # Get path_item from spec for parse_params_v3 + path_item = spec["paths"][path] + + # Parse params using v3 parser + all_params, metadata = parse_params_v3(endpoint, path_item, spec) + + # Initialize param collections + query_params = return_params(operation, all_params, ["query"]) + array_params = {k: v for k, v in query_params.items() if v["type"] == "array"} + body_params = {} + path_params = return_params(operation, all_params, ["path"]) + + # Ensure all URL-referenced params get quote lines in the template + for p in re.findall(r"\{(\w+)\}", path): + if p not in path_params: + path_params[p] = {"type": "string", "in": "path"} + + # Sanitize path_params keys and resource path for Python keywords + safe_path_params = {} + safe_resource = path + for p, v in path_params.items(): + safe_p = safe_param_name(p) + safe_path_params[safe_p] = v + if safe_p != p: + safe_resource = safe_resource.replace("{" + p + "}", "{" + safe_p + "}") + + # Function definition + definition = "" + defined_params = set() + renamed_params = {} + + for p, values in return_params(operation, all_params, ["required"]).items(): + defined_params.add(p) + safe_p = safe_param_name(p) + if safe_p != p: + renamed_params[safe_p] = p + match values["type"]: + case "array": + definition += f", {safe_p}: list" + case "number": + definition += f", {safe_p}: float" + case "integer": + definition += f", {safe_p}: int" + case "boolean": + definition += f", {safe_p}: bool" + case "object": + definition += f", {safe_p}: dict" + case "string": + definition += f", {safe_p}: str" + + # Path params must be in the signature for URL construction + for p, values in return_params(operation, all_params, ["path"]).items(): + if p not in defined_params: + defined_params.add(p) + safe_p = safe_param_name(p) + if safe_p != p: + renamed_params[safe_p] = p + match values["type"]: + case "array": + definition += f", {safe_p}: list" + case "number": + definition += f", {safe_p}: float" + case "integer": + definition += f", {safe_p}: int" + case "boolean": + definition += f", {safe_p}: bool" + case "object": + definition += f", {safe_p}: dict" + case "string": + definition += f", {safe_p}: str" + + # Catch params referenced in the URL but not declared as path params + for p in re.findall(r"\{(\w+)\}", path): + if p not in defined_params: + defined_params.add(p) + safe_p = safe_param_name(p) + if safe_p != p: + renamed_params[safe_p] = p + definition += f", {safe_p}: str" + + # Add pagination params if perPage exists + if "perPage" in all_params: + if operation in REVERSE_PAGINATION: + definition += ", total_pages=1, direction='prev'" + else: + definition += ", total_pages=1, direction='next'" + if operation == "getNetworkEvents": + definition += ", event_log_end_time=None" + + # Function body for POST/PUT endpoints + if method == "post" or method == "put": + body_params = return_params(operation, all_params, ["body"]) + + # Add **kwargs if optional params OR body/query/array params exist (batch template uses kwargs.items()) + if return_params(operation, all_params, ["optional"]) or body_params or query_params or array_params: + definition += ", **kwargs" + + # Docstring + param_descriptions = list() + all_params_for_doc = return_params(operation, all_params, ["required", "pagination", "optional"]) + if all_params_for_doc: + for p, values in all_params_for_doc.items(): + param_descriptions.append(f"{p} ({values['type']}): {values['description']}") + + kwarg_line = "" + if return_params(operation, all_params, ["optional"]): + kwarg_line = "kwargs.update(locals())" + elif return_params(operation, all_params, ["query", "array", "body"]): + kwarg_line = "kwargs = locals()" + + # Assert valid values for enum + enum_params = return_params(operation, all_params, ["enum"]) + assert_blocks = list() + if enum_params: + for p, values in enum_params.items(): + assert_blocks.append((p, values["enum"], values.get("nullable", False))) + + # Function return statement + call_line = "return action" + + # Record keyword param violations for the generation report + if renamed_params: + for safe_p, orig_p in renamed_params.items(): + location = all_params.get(orig_p, {}).get("in", "unknown") + _keyword_param_violations.append( + { + "operation": operation, + "param": orig_p, + "location": location, + "scope": tags[0] if tags else "unknown", + } ) - async_output.write( - '\n\n' + - template.render( - operation=operation, - function_definition=definition, - description=description, - doc_url=docs_url(operation), - descriptions=param_descriptions, - kwarg_line=kwarg_line, - all_params=list(all_params.keys()), - assert_blocks=assert_blocks, - tags=tags, - resource=path, - query_params=query_params, - array_params=array_params, - body_params=body_params, - call_line=call_line, - ) + + # Add function to files + with open( + f"{template_dir}batch_function_template.jinja2", + encoding="utf-8", + newline=None, + ) as fp: + function_template = fp.read() + template = jinja_env.from_string(function_template) + batch_output.write( + "\n\n" + + template.render( + operation=operation, + function_definition=definition, + description=description, + doc_url=docs_url(operation), + descriptions=param_descriptions, + kwarg_line=kwarg_line, + all_params=list(all_params_for_doc.keys()) if all_params_for_doc else [], + assert_blocks=assert_blocks, + tags=tags, + resource=safe_resource, + query_params=query_params, + array_params=array_params, + body_params=body_params, + path_params=safe_path_params, + call_line=call_line, + batch_operation=batch_operation, + renamed_params=renamed_params, ) + ) -# Prints READ_ME help message for user to read +# Prints a READ_ME help message for user to read def print_help(): - lines = READ_ME.split('\n') + lines = READ_ME.split("\n") for line in lines: - print(f'# {line}') + print(f"# {line}") # Parse command line arguments def main(inputs): - api_key = os.environ.get('MERAKI_DASHBOARD_API_KEY') + api_key = os.environ.get("MERAKI_DASHBOARD_API_KEY") org_id = None + version_number = "custom" + api_version_number = "custom" + is_github_action = False + generate_stubs_flag = False + local_source = False + source_branch = "master" try: - opts, args = getopt.getopt(inputs, 'ho:k:') + opts, args = getopt.getopt(inputs, "ho:k:v:a:g:slb:") except getopt.GetoptError: print_help() sys.exit(2) for opt, arg in opts: - if opt == '-h': + if opt == "-h": print_help() sys.exit(2) - elif opt == '-o': + elif opt == "-o": org_id = arg - elif opt == '-k' and api_key is None: + elif opt == "-k" and api_key is None: api_key = arg - - # Retrieve latest OpenAPI specification + elif opt == "-v": + version_number = arg + elif opt == "-a": + api_version_number = arg + elif opt == "-g": + if arg.lower() == "true": + is_github_action = True + elif opt == "-s": + generate_stubs_flag = True + elif opt == "-l": + local_source = True + elif opt == "-b": + source_branch = arg + + check_python_version() + + # Retrieve the latest OpenAPI v3 specification if org_id: if not api_key: print_help() sys.exit(2) else: - response = requests.get(f'https://api-mp.meraki.com/api/v1/organizations/{org_id}/openapiSpec', - headers={'Authorization': f'Bearer {api_key}'}) - if response.ok: + response = httpx.get( + f"https://api.meraki.com/api/v1/organizations/{org_id}/openapiSpec", + headers={"Authorization": f"Bearer {api_key}"}, + params={"version": 3}, + ) + if response.status_code == 200: spec = response.json() else: print_help() - sys.exit(f'API key provided does not have access to org {org_id}') + sys.exit(f"API key provided does not have access to org {org_id}") else: - spec = requests.get('https://api.meraki.com/api/v1/openapiSpec').json() - - generate_library(spec) - - -if __name__ == '__main__': + response = httpx.get("https://api.meraki.com/api/v1/openapiSpec", params={"version": 3}) + if response.status_code == 200: + spec = response.json() + print("Successfully pulled Meraki dashboard API OpenAPI v3 spec.") + else: + print_help() + sys.exit( + "There was an HTTP error pulling the OpenAPI v3 specification. Please try again in a few minutes. " + "If this continues for more than an hour, please contact Meraki support." + ) + + generate_library( + spec, + version_number, + api_version_number, + is_github_action, + generate_stubs=generate_stubs_flag, + local_source=local_source, + source_branch=source_branch, + ) + + +if __name__ == "__main__": main(sys.argv[1:]) diff --git a/generator/generate_snippets.py b/generator/generate_snippets.py index f2f84663..12cfc183 100644 --- a/generator/generate_snippets.py +++ b/generator/generate_snippets.py @@ -1,267 +1,223 @@ -import json -import re -import shutil +import os import sys -import requests +import httpx from jinja2 import Template +import common as common +from parser_v3 import parse_params_v3, clear_cache +CALL_TEMPLATE = Template( + """import meraki -CALL_TEMPLATE = Template('''import meraki - -# Defining your API key as a variable in source code is not recommended -API_KEY = '6bec40cf957de430a6f1f2baa056b99a4fac9ea0' -# Instead, use an environment variable as shown under the Usage section +# Defining your API key as a variable in source code is discouraged. +# This API key is for a read-only docs-specific environment. +# In your own code, use an environment variable as shown under the Usage section # @ https://github.com/meraki/dashboard-api-python/ +API_KEY = 'your-key-here' + dashboard = meraki.DashboardAPI(API_KEY) {{ parameter_assignments }} -response = dashboard.{{ section }}.{{ operation}}({{ parameters }}) +response = dashboard.{{ section }}.{{ operation }}({{ parameters }}) print(response) -''') +""" +) -REVERSE_PAGINATION = ['getNetworkEvents', 'getOrganizationConfigurationChanges'] +REVERSE_PAGINATION = ["getNetworkEvents", "getOrganizationConfigurationChanges"] # Helper function to convert camel case parameter name to snake case def snakify(param): - ret = '' + ret = "" for s in param: if s.islower(): ret += s - elif s == '_': - ret += '_' + elif s == "_": + ret += "_" else: - ret += '_' + s.lower() - return ret - - -# Helper function to return pagination parameters depending on endpoint -def generate_pagination_parameters(operation): - ret = { - 'total_pages': { - 'type': 'integer or string', - 'description': 'total number of pages to retrieve, -1 or "all" for all pages', - }, - 'direction': { - 'type': 'string', - 'description': 'direction to paginate, either "next" or "prev" (default) page' if operation in - REVERSE_PAGINATION else 'direction to paginate, either "next" (default) or "prev" page', - } - } + ret += "_" + s.lower() return ret -# Helper function to return parameters within OAS spec, optionally based on list of input filters -def parse_params(operation, parameters, param_filters=[]): - if parameters is None: - return {} - - # Create dict with information on endpoint's parameters - params = {} - for p in parameters: - name = p['name'] - if 'schema' in p: - keys = p['schema']['properties'] - for k in keys: - if 'required' in p['schema'] and k in p['schema']['required']: - params[k] = {'required': True} - else: - params[k] = {'required': False} - params[k]['in'] = p['in'] - params[k]['type'] = keys[k]['type'] - params[k]['description'] = keys[k]['description'] - if 'enum' in keys[k]: - params[k]['enum'] = keys[k]['enum'] - if 'example' in p['schema'] and k in p['schema']['example']: - params[k]['example'] = p['schema']['example'][k] - elif 'required' in p and p['required']: - params[name] = {'required': True} - params[name]['in'] = p['in'] - params[name]['type'] = p['type'] - if 'description' in p: - params[name]['description'] = p['description'] - else: - params[name]['description'] = '(required)' - if 'enum' in p: - params[name]['enum'] = p['enum'] - else: - params[name] = {'required': False} - params[name]['in'] = p['in'] - params[name]['type'] = p['type'] - params[name]['description'] = p['description'] - if 'enum' in p: - params[name]['enum'] = p['enum'] - - # Add custom library parameters to handle pagination - if 'perPage' in params: - params.update(generate_pagination_parameters(operation)) - - # Return parameters based on matching input filters - if not param_filters: - return params - else: - ret = {} - if 'required' in param_filters: - ret.update({k: v for k, v in params.items() if 'required' in v and v['required']}) - if 'pagination' in param_filters: - ret.update(generate_pagination_parameters(operation) if 'perPage' in params else {}) - if 'optional' in param_filters: - ret.update({k: v for k, v in params.items() if 'required' in v and not v['required']}) - if 'path' in param_filters: - ret.update({k: v for k, v in params.items() if 'in' in v and v['in'] == 'path'}) - if 'query' in param_filters: - ret.update({k: v for k, v in params.items() if 'in' in v and v['in'] == 'query'}) - if 'body' in param_filters: - ret.update({k: v for k, v in params.items() if 'in' in v and v['in'] == 'body'}) - if 'array' in param_filters: - ret.update({k: v for k, v in params.items() if 'in' in v and v['type'] == 'array'}) - if 'enum' in param_filters: - ret.update({k: v for k, v in params.items() if 'enum' in v}) - return ret +# Helper function to return parameters within the OASv3 spec, optionally based on +# a list of input filters. Delegates parsing/$ref-resolution/requestBody handling to +# parser_v3.parse_params_v3 (the same parser used by the library/stub generators) so +# snippets stay consistent with generated code. parse_params_v3 applies the filters via +# common.return_params and injects pagination params when perPage is present. +def parse_params(endpoint, path_item, spec, param_filters=None): + if param_filters is None: + param_filters = [] + params, _metadata = parse_params_v3(endpoint, path_item, spec, param_filters) + return params # Generate text for parameter assignments def process_assignments(parameters): - text = '\n' + text = "\n" for k, v in parameters.items(): param_name = snakify(k) - if param_name == 'id': - param_name = 'id_' - - if v == 'list': - text += f'{param_name} = []\n' - elif v == 'float': - text += f'{param_name} = 0.0\n' - elif v == 'int': - text += f'{param_name} = 0\n' - elif v == 'bool': - text += f'{param_name} = False\n' - elif v == 'dict': - text += f'{param_name} = {{}}\n' - elif v == 'str': - text += f'{param_name} = \'\'\n' + if param_name == "id": + param_name = "id_" + + if v == "list": + text += f"{param_name} = []\n" + elif v == "float": + text += f"{param_name} = 0.0\n" + elif v == "int": + text += f"{param_name} = 0\n" + elif v == "bool": + text += f"{param_name} = False\n" + elif v == "dict": + text += f"{param_name} = {{}}\n" + elif v == "str": + text += f"{param_name} = ''\n" else: - if type(v) == str: - value = f'\'{v}\'' + if isinstance(v, str): + value = f"'{v}'" else: value = v - text += f'{param_name} = {value}\n' + text += f"{param_name} = {value}\n" return text def main(): - # Get latest OpenAPI specification - spec = requests.get('https://api.meraki.com/api/v1/openapiSpec').json() - - # Only care about the first 10 tags, which are the 10 scopes for organizations, networks, devices, & 7 products - # scopes = ['organizations', 'networks', 'devices', + # Get the latest OpenAPI v3 specification (version=3 selects OASv3 over the legacy v2 shape) + spec = httpx.get("https://api.meraki.com/api/v1/openapiSpec", params={"version": 3}).json() + + # Reset parser_v3's $ref cache at entry, matching the library generator + clear_cache() + + # Supported scopes list will include organizations, networks, devices, and all product types. + supported_scopes = [ + "administered", + "organizations", + "networks", + "devices", + "appliance", + "camera", + "cellularGateway", + "insight", + "sm", + "switch", + "wireless", + "sensor", + "licensing", + "secureConnect", + "wirelessController", + "campusGateway", + "spaces", + "nac", + ] + # legacy scopes = ['organizations', 'networks', 'devices', # 'appliance', 'camera', 'cellularGateway', 'insight', 'sm', 'switch', 'wireless'] - tags = spec['tags'] - paths = spec['paths'] - scopes = {tag['name']: {} for tag in tags[:10]} - - # Organize data - operations = [] - for path, methods in paths.items(): - for method in methods: - endpoint = paths[path][method] - tags = endpoint['tags'] - operation = endpoint['operationId'] - operations.append(operation) - scope = tags[0] - if path not in scopes[scope]: - scopes[scope][path] = {method: endpoint} - else: - scopes[scope][path][method] = endpoint + tags = spec["tags"] + paths = spec["paths"] + # Scopes used when generating the library will depend on the provided version of the API spec. + scopes = {tag["name"]: {} for tag in tags if tag["name"] in supported_scopes} + + # Filter paths to remove the path-level "parameters" key before organize_spec, which + # expects only HTTP method keys. The original path_item (incl. path-level params) is + # passed to parse_params_v3 separately via spec["paths"][path]. + filtered_paths = {} + for path, path_item in paths.items(): + filtered_paths[path] = {k: v for k, v in path_item.items() if k in ["get", "post", "put", "delete", "patch"]} + + # Organize data from OpenAPI specification + operations, scopes = common.organize_spec(filtered_paths, scopes) # Generate API libraries for scope in scopes: - print(f'...generating {scope}') + print(f"...generating {scope}") section = scopes[scope] for path, methods in section.items(): for method, endpoint in methods.items(): # Get metadata - tags = endpoint['tags'] - operation = endpoint['operationId'] - description = endpoint['summary'] - parameters = endpoint['parameters'] if 'parameters' in endpoint else None - responses = endpoint['responses'] # not actually used here for library generation + tags = endpoint["tags"] + operation = endpoint["operationId"] + + # Full path_item (incl. path-level parameters) for parse_params_v3 + path_item = spec["paths"][path] + + # An endpoint contributes params if it declares parameters or a requestBody + has_params = "parameters" in endpoint or "requestBody" in endpoint required = {} optional = {} - if parameters: - if 'perPage' in parse_params(operation, parameters): + if has_params: + if "perPage" in parse_params(endpoint, path_item, spec): pagination = True else: pagination = False - for p, values in parse_params(operation, parameters, 'required').items(): - if 'example' in values: - required[p] = values['example'] - elif p == 'organizationId': - required[p] = '549236' - elif p == 'networkId': - required[p] = 'L_646829496481105433' # DevNet Sandbox ALWAYS ON network @ https://n149.meraki.com/o/-t35Mb/manage/organization/overview - elif p == 'serial': - required[p] = 'Q2QN-9J8L-SLPD' - elif values['type'] == 'array': - required[p] = 'list' - elif values['type'] == 'number': - required[p] = 'float' - elif values['type'] == 'integer': - required[p] = 'int' - elif values['type'] == 'boolean': - required[p] = 'bool' - elif values['type'] == 'object': - required[p] = 'dict' - elif values['type'] == 'string': - required[p] = 'str' + for p, values in parse_params(endpoint, path_item, spec, ["required"]).items(): + if "example" in values: + required[p] = values["example"] + elif p == "organizationId": + required[p] = "549236" + elif p == "networkId": + # DevNet Sandbox ALWAYS ON network @ https://n149.meraki.com/o/-t35Mb/manage/organization/overview + required[p] = "L_646829496481105433" + elif p == "serial": + required[p] = "Q2QN-9J8L-SLPD" + elif values["type"] == "array": + required[p] = "list" + elif values["type"] == "number": + required[p] = "float" + elif values["type"] == "integer": + required[p] = "int" + elif values["type"] == "boolean": + required[p] = "bool" + elif values["type"] == "object": + required[p] = "dict" + elif values["type"] == "string": + required[p] = "str" else: - sys.exit(p, values) + sys.exit(f"Unhandled param type for {p}: {values}") if pagination: if operation not in REVERSE_PAGINATION: - optional['total_pages'] = 'all' + optional["total_pages"] = "all" else: - optional['total_pages'] = 3 + optional["total_pages"] = 3 - for p, values in parse_params(operation, parameters, 'optional').items(): - if 'example' in values: - optional[p] = values['example'] + for p, values in parse_params(endpoint, path_item, spec, ["optional"]).items(): + if "example" in values: + optional[p] = values["example"] - if operation == 'createNetworkGroupPolicy': + if operation == "createNetworkGroupPolicy": print(required) print(optional) - with open(f'code_snippets/{operation}.py', 'w') as fp: + if "code_snippets" not in os.listdir(): + os.mkdir("code_snippets") + + with open(f"code_snippets/{operation}.py", "w", encoding="utf-8") as fp: if required.items(): - parameters_text = '\n ' + parameters_text = "\n " for k, v in required.items(): param_name = snakify(k) - if param_name == 'id': - param_name = 'id_' - parameters_text += f'{param_name}, ' + if param_name == "id": + param_name = "id_" + parameters_text += f"{param_name}, " for k, v in optional.items(): - if k == 'total_pages' and v == 'all': - parameters_text += f'total_pages=\'all\'' - elif k == 'total_pages' and v == 1: - parameters_text += f'total_pages=1' - elif type(v) == str: - parameters_text += f'\n {k}=\'{v}\', ' + if k == "total_pages" and v == "all": + parameters_text += "total_pages='all'" + elif k == "total_pages" and v == 1: + parameters_text += "total_pages=1" + elif isinstance(v, str): + parameters_text += f"\n {k}='{v}', " else: - parameters_text += f'\n {k}={v}, ' - if parameters_text[-2:] == ', ': + parameters_text += f"\n {k}={v}, " + if parameters_text[-2:] == ", ": parameters_text = parameters_text[:-2] - parameters_text += '\n' + parameters_text += "\n" else: - parameters_text = '' - + parameters_text = "" fp.write( CALL_TEMPLATE.render( @@ -273,5 +229,5 @@ def main(): ) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/generator/generate_stubs.py b/generator/generate_stubs.py new file mode 100644 index 00000000..2619410e --- /dev/null +++ b/generator/generate_stubs.py @@ -0,0 +1,141 @@ +""" +Type stub (.pyi) generation for Meraki Dashboard API SDK. + +Produces .pyi files with typed method signatures from parsed OASv3 params. +""" + +import keyword +import re +import jinja2 +from parser_v3 import parse_params_v3 +from common import return_params, REVERSE_PAGINATION + + +def _safe_param_name(name: str) -> str: + if keyword.iskeyword(name): + return name + "_" + return name + + +def _python_type_annotation(param_dict: dict) -> str: + """ + Convert param dict to Python type annotation string. + + Args: + param_dict: Param entry with keys: type, nullable, required + + Returns: + Type annotation string (e.g., "str", "str | None", "str | dict | None") + """ + param_type = param_dict.get("type", "object") + + # Map OAS type to Python type + type_map = { + "string": "str", + "integer": "int", + "number": "float", + "boolean": "bool", + "array": "list", + "object": "dict", + } + + # Handle oneOf case (type = "string or object") + if " or " in param_type: + parts = param_type.split(" or ") + python_types = sorted([type_map.get(p.strip(), "Any") for p in parts]) + base_type = " | ".join(python_types) + else: + base_type = type_map.get(param_type, "Any") + + # Apply nullable annotation + if param_dict.get("nullable", False): + return f"{base_type} | None" + + # Apply optional annotation (not required) + if not param_dict.get("required", False): + return f"{base_type} | None" + + return base_type + + +def generate_stub_modules(spec: dict, scopes: dict, jinja_env: jinja2.Environment, template_dir: str): + """ + Generate .pyi stub modules for each scope. + + Args: + spec: Full OpenAPI spec dict + scopes: Dict of scope name -> {path: {method: endpoint}} + jinja_env: Jinja2 environment + template_dir: Directory containing templates (empty string if cwd) + """ + for scope in scopes: + section = scopes[scope] + + # cwd-relative, matching the .py writers in generate_modules() + with open(f"meraki/api/{scope}.pyi", "w", encoding="utf-8", newline=None) as stub_output: + # Render class header + with open(f"{template_dir}stub_template.jinja2", encoding="utf-8", newline=None) as fp: + class_template = fp.read() + template = jinja_env.from_string(class_template) + stub_output.write( + template.render( + class_name=scope[0].upper() + scope[1:], + ) + ) + + # Generate method signatures + for path, methods in section.items(): + for method, endpoint in methods.items(): + operation = endpoint["operationId"] + + # Get path_item from spec + path_item = spec["paths"][path] + + # Parse params using v3 parser + all_params, metadata = parse_params_v3(endpoint, path_item, spec) + + # Build method signature + signature_parts = ["self"] + defined_params = set() + + # Add required params + for p, values in return_params(operation, all_params, ["required"]).items(): + defined_params.add(p) + annotation = _python_type_annotation(values) + signature_parts.append(f"{_safe_param_name(p)}: {annotation}") + + # Add path params (if not already in required) + for p, values in return_params(operation, all_params, ["path"]).items(): + if p not in defined_params: + defined_params.add(p) + annotation = _python_type_annotation(values) + signature_parts.append(f"{_safe_param_name(p)}: {annotation}") + + # Catch params referenced in URL but not declared + for p in re.findall(r"\{(\w+)\}", path): + if p not in defined_params: + defined_params.add(p) + signature_parts.append(f"{_safe_param_name(p)}: str") + + # Add pagination params if perPage exists + if "perPage" in all_params: + if operation in REVERSE_PAGINATION: + signature_parts.append("total_pages: int = 1") + signature_parts.append("direction: str = 'prev'") + else: + signature_parts.append("total_pages: int = 1") + signature_parts.append("direction: str = 'next'") + if operation == "getNetworkEvents": + signature_parts.append("event_log_end_time: Any | None = None") + + # Add optional params explicitly (stubs need full signatures, not **kwargs) + optional_params = return_params(operation, all_params, ["optional"]) + for p, values in optional_params.items(): + if p not in defined_params: + annotation = _python_type_annotation(values) + signature_parts.append(f"{_safe_param_name(p)}: {annotation} = None") + + signature = ", ".join(signature_parts) + + # Write method stub + stub_output.write(f"\n def {operation}({signature}) -> Any: ...\n") diff --git a/generator/parser_v3.py b/generator/parser_v3.py new file mode 100644 index 00000000..7bb7bbd5 --- /dev/null +++ b/generator/parser_v3.py @@ -0,0 +1,283 @@ +""" +OpenAPI v3 parser for Meraki Dashboard API SDK. + +Provides $ref resolution (with caching and cycle detection) and requestBody +parsing that normalizes v3 structures into v2-compatible param dicts. + +Module-level functions (not class-based) per project convention. +Spec passed as argument to all functions. +""" + +from common import generate_pagination_parameters, return_params + +# Module-level cache for resolved $ref pointers (D-01) +_ref_cache: dict[str, dict] = {} + + +def clear_cache() -> None: + """Clear ref cache between generator runs. Call at generator entry point.""" + _ref_cache.clear() + + +def resolve_ref(spec: dict, ref: str, _visited: set | None = None) -> dict: + """ + Resolve a JSON pointer ($ref) within an OpenAPI spec. + + Uses module-level cache (D-01) and visited-set cycle detection (D-02). + Raises on unresolvable refs (D-03). + + Args: + spec: Full OpenAPI spec dict. + ref: JSON pointer string (must start with "#/"). + _visited: Internal; tracks pointers in current resolution chain. + + Returns: + Resolved schema dict. Empty dict {} if circular reference detected. + + Raises: + ValueError: If ref is not an internal reference (doesn't start with "#/"). + KeyError: If pointer path cannot be resolved in spec. + """ + # Reject external refs + if not ref.startswith("#/"): + raise ValueError(f"Only internal refs supported: {ref}") + + # Check cache first (D-01) + if ref in _ref_cache: + return _ref_cache[ref] + + # Cycle detection (D-02) + if _visited is None: + _visited = set() + + if ref in _visited: + return {} # Sentinel for circular reference + + _visited.add(ref) + + # Parse JSON pointer (RFC 6901) + parts = ref[2:].split("/") + result = spec + for part in parts: + # Unescape JSON pointer tokens per RFC 6901 + part = part.replace("~1", "/").replace("~0", "~") + if isinstance(result, dict) and part in result: + result = result[part] + else: + raise KeyError(f"Unresolvable $ref: {ref}") + + # If resolved value itself contains $ref, resolve recursively + if isinstance(result, dict) and "$ref" in result: + result = resolve_ref(spec, result["$ref"], _visited) + + # Cache resolved value (D-01) + _ref_cache[ref] = result + return result + + +def parse_request_body(operation: dict, spec: dict) -> tuple[dict, str | None]: + """ + Parse requestBody into normalized param dict and content type. + + Handles application/json, multipart/form-data, and application/octet-stream. + Output matches v2 param dict format with added nullable field (D-04, D-05, D-06, D-10). + + Args: + operation: Single operation dict from OpenAPI spec (e.g., paths["/foo"]["post"]). + spec: Full OpenAPI spec dict (for $ref resolution). + + Returns: + Tuple of (params_dict, content_type). + params_dict: {param_name: {required, in, type, description, nullable, ...}} + content_type: String like "application/json" or None if no requestBody. + """ + if "requestBody" not in operation: + return {}, None + + request_body = operation["requestBody"] + content = request_body.get("content", {}) + + # Priority order: json > multipart > octet-stream (D-04) + if "application/json" in content: + content_type = "application/json" + schema = content["application/json"].get("schema", {}) + elif "multipart/form-data" in content: + content_type = "multipart/form-data" + schema = content["multipart/form-data"].get("schema", {}) + elif "application/octet-stream" in content: + # Octet-stream: single 'file' param (D-05) + return { + "file": { + "required": True, + "in": "body", + "type": "file", + "description": "Binary file content", + "nullable": False, + } + }, "application/octet-stream" + else: + return {}, None + + if not schema: + return {}, content_type + + # Resolve $ref if schema itself is a reference + if "$ref" in schema: + schema = resolve_ref(spec, schema["$ref"]) + + # Extract properties and required list + properties = schema.get("properties", {}) + required_list = schema.get("required", []) + + params = {} + for prop_name, prop_schema in properties.items(): + # Resolve nested $ref in property + if "$ref" in prop_schema: + prop_schema = resolve_ref(spec, prop_schema["$ref"]) + + entry = { + "required": prop_name in required_list, + "in": "body", + "type": prop_schema.get("type", "object"), + "description": prop_schema.get("description", ""), + "nullable": prop_schema.get("nullable", False), # D-10 + } + + # Include enum if present + if "enum" in prop_schema: + entry["enum"] = prop_schema["enum"] + + # Include array items if present + if prop_schema.get("type") == "array" and "items" in prop_schema: + entry["items"] = prop_schema["items"] + + params[prop_name] = entry + + return params, content_type + + +def _document_oneof(schema: dict) -> tuple[str, str]: + """Document oneOf schema as type string with property details.""" + if "oneOf" not in schema: + return schema.get("type", "object"), "" + + oneof_types = [s.get("type") for s in schema["oneOf"] if "type" in s] + + # Extract object properties + object_props = [] + for s in schema["oneOf"]: + if s.get("type") == "object" and "properties" in s: + object_props = sorted(s["properties"].keys()) + break + + type_str = " or ".join(sorted(set(oneof_types))) + + desc_add = "" + if object_props: + desc_add = f" (object supports: {', '.join(object_props)})" + + return type_str, desc_add + + +def _extract_param_entry(p: dict, spec: dict) -> dict: + """Convert OASv3 parameter to v2-compatible param dict entry.""" + schema = p.get("schema", {}) + if "$ref" in schema: + schema = resolve_ref(spec, schema["$ref"]) + + # Handle oneOf schemas + if "oneOf" in schema: + type_str, desc_add = _document_oneof(schema) + elif schema.get("type") == "array": + type_str = "array" + else: + type_str = schema.get("type", "string") + + description = p.get("description", schema.get("description", "")) + if "oneOf" in schema: + _, desc_add = _document_oneof(schema) + if desc_add: + description = description + desc_add if description else desc_add + + entry = { + "required": p.get("required", False), + "in": p.get("in", "query"), + "type": type_str, + "description": description, + "nullable": schema.get("nullable", False), + } + + # Include enum if present + if "enum" in schema: + entry["enum"] = schema["enum"] + + # Array params: include items and style/explode defaults + if schema.get("type") == "array": + if "items" in schema: + entry["items"] = schema["items"] + # OASv3 defaults for query arrays: style=form, explode=true + if p.get("in", "query") == "query": + entry["style"] = schema.get("style", "form") + entry["explode"] = schema.get("explode", True) + + return entry + + +def parse_params_v3(operation: dict, path_item: dict, spec: dict, param_filters=None) -> tuple[dict, dict]: + """ + Parse and merge parameters from path-level and operation-level sources. + + Merges path_item.parameters + operation.parameters (op overrides on name+in match), + then merges requestBody params via parse_request_body. Detects perPage for pagination injection. + Applies param_filters via return_params if provided (per CONTEXT.md: reuse v2 filter logic). + + Args: + operation: Operation dict (e.g., paths["/foo"]["get"]). + path_item: PathItem dict (e.g., paths["/foo"]) for path-level params. + spec: Full OpenAPI spec dict (for $ref resolution). + param_filters: Optional list of filter strings (e.g., ["required", "query"]). + Passed to return_params() from generate_library.py. None = return all. + + Returns: + Tuple of (params_dict, metadata_dict). + params_dict: {param_name: {required, in, type, description, nullable, ...}} + metadata_dict: {"content_type": str | None} + """ + if param_filters is None: + param_filters = [] + + merged_params = {} + + # Step 1: Inherit path-level parameters + for p in path_item.get("parameters", []): + if "$ref" in p: + p = resolve_ref(spec, p["$ref"]) + key = (p["name"], p.get("in", "query")) + merged_params[key] = p + + # Step 2: Add/override with operation-level parameters + for p in operation.get("parameters", []): + if "$ref" in p: + p = resolve_ref(spec, p["$ref"]) + key = (p["name"], p.get("in", "query")) + merged_params[key] = p # Overrides path-level on name+in match + + # Step 3: Convert to v2-compatible param dict format + params = {} + for (name, location), p in merged_params.items(): + params[name] = _extract_param_entry(p, spec) + + # Step 4: Merge requestBody params + body_params, content_type = parse_request_body(operation, spec) + params.update(body_params) + + # Step 5: Detect perPage and inject pagination params + operation_id = operation.get("operationId", "") + if "perPage" in params: + params.update(generate_pagination_parameters(operation_id)) + + # Step 6: Apply param_filters via return_params (per CONTEXT.md decision) + params = return_params(operation_id, params, param_filters) + + metadata = {"content_type": content_type} + return params, metadata diff --git a/generator/readme.md b/generator/readme.md new file mode 100644 index 00000000..8689ef6a --- /dev/null +++ b/generator/readme.md @@ -0,0 +1,35 @@ +# Generating the Meraki Dashboard API Python Library + +Most users don't need this: just `pip install --upgrade meraki`, or a [published beta +release](https://github.com/meraki/dashboard-api-python#releases) for early-access operations. + +Generate the library yourself only when you need it matched to your own org's spec. Follow along below. + +> **NB:** The generator requires Python 3.11 or later. + +1. Clone this repo locally. +2. Open a terminal in this `generator` folder. +3. Install dependencies using [uv](https://docs.astral.sh/uv/): + + ```shell + uv sync --group generator + ``` + +4. *Optional:* To work with beta operations, first [review the warnings and opt one of your orgs into the Early API + Access program](https://community.meraki.com/t5/Developers-APIs/UPDATED-Beta-testing-with-the-Meraki-Developer-Early-Access/m-p/145344#M5808). +5. Run the generator: + + ```shell + uv run --group generator python generate_library.py -v locally_generated -o YOUR_ORG_ID -k YOUR_API_KEY + ``` + + Making these replacements: + * Replace `YOUR_ORG_ID` with the org ID you want to use as reference. Use the one opted into Early API access if you + want the beta operations. + * Replace `YOUR_API_KEY` with an API key that has org admin privileges on that org. + * NB: Your local system may require minor syntax tweaks (e.g. Windows may require you prepend `generate_library.py` + with `.\`) + +6. You now have a `meraki` module folder inside `generator`. Copy it into any project that needs it. +7. If the official `meraki` package is also installed, your scripts may import it instead, and early-access calls will + fail. Uninstall the official package (or replace it with the one you generated) to avoid this. diff --git a/generator/stub_template.jinja2 b/generator/stub_template.jinja2 new file mode 100644 index 00000000..09364395 --- /dev/null +++ b/generator/stub_template.jinja2 @@ -0,0 +1,4 @@ +from typing import Any + +class {{ class_name }}: + def __init__(self, session: Any) -> None: ... diff --git a/meraki/__init__.py b/meraki/__init__.py index a382e07f..114dcfe0 100644 --- a/meraki/__init__.py +++ b/meraki/__init__.py @@ -1,32 +1,82 @@ -from datetime import datetime import logging import os -from .rest_session import * -from .api.organizations import Organizations -from .api.networks import Networks -from .api.devices import Devices -from .api.appliance import Appliance -from .api.camera import Camera -from .api.cellularGateway import CellularGateway -from .api.insight import Insight -from .api.sm import Sm -from .api.switch import Switch -from .api.wireless import Wireless -from .config import ( - API_KEY_ENVIRONMENT_VARIABLE, DEFAULT_BASE_URL, SINGLE_REQUEST_TIMEOUT, CERTIFICATE_PATH, REQUESTS_PROXY, - WAIT_ON_RATE_LIMIT, NGINX_429_RETRY_WAIT_TIME, ACTION_BATCH_RETRY_WAIT_TIME, RETRY_4XX_ERROR, - RETRY_4XX_ERROR_WAIT_TIME, MAXIMUM_RETRIES, OUTPUT_LOG, LOG_PATH, LOG_FILE_PREFIX, PRINT_TO_CONSOLE, - SUPPRESS_LOGGING, SIMULATE_API_CALLS, BE_GEO_ID, MERAKI_PYTHON_SDK_CALLER +from meraki.api.administered import Administered +from meraki.api.appliance import Appliance + +# Batch class imports +from meraki.api.batch import Batch +from meraki.api.camera import Camera +from meraki.api.campusGateway import CampusGateway +from meraki.api.cellularGateway import CellularGateway +from meraki.api.devices import Devices +from meraki.api.insight import Insight +from meraki.api.licensing import Licensing +from meraki.api.networks import Networks +from meraki.api.organizations import Organizations +from meraki.api.sensor import Sensor +from meraki.api.sm import Sm +from meraki.api.spaces import Spaces +from meraki.api.switch import Switch +from meraki.api.wireless import Wireless +from meraki.api.wirelessController import WirelessController + +# Config import +from meraki.config import ( + API_KEY_ENVIRONMENT_VARIABLE, + CUSTOM_HEADERS, + DEFAULT_BASE_URL, + SINGLE_REQUEST_TIMEOUT, + CERTIFICATE_PATH, + REQUESTS_PROXY, + WAIT_ON_RATE_LIMIT, + NGINX_429_RETRY_WAIT_TIME, + ACTION_BATCH_RETRY_WAIT_TIME, + NETWORK_DELETE_RETRY_WAIT_TIME, + RETRY_4XX_ERROR, + RETRY_4XX_ERROR_WAIT_TIME, + MAXIMUM_RETRIES, + OUTPUT_LOG, + LOG_PATH, + LOG_FILE_PREFIX, + PRINT_TO_CONSOLE, + SUPPRESS_LOGGING, + INHERIT_LOGGING_CONFIG, + SIMULATE_API_CALLS, + BE_GEO_ID, + MERAKI_PYTHON_SDK_CALLER, + USE_ITERATOR_FOR_GET_PAGES, + VALIDATE_KWARGS, + SMART_FLOW_ENABLED, + SMART_FLOW_ORG_RATE, + SMART_FLOW_GLOBAL_RATE, + SMART_FLOW_CACHE_MODE, + SMART_FLOW_CACHE_PATH, + SMART_FLOW_CACHE_TTL, + SMART_FLOW_LOGGING, ) +from meraki.session.sync import RestSession +from meraki.exceptions import APIError, APIKeyError, APIResponseError, AsyncAPIError +from meraki._version import __version__ # noqa: F401 +from datetime import datetime + +__api_version__ = "1.72.0" + +__all__ = [ + "APIError", + "APIKeyError", + "APIResponseError", + "AsyncAPIError", + "DashboardAPI", +] -__version__ = '1.0.0b18' class DashboardAPI(object): """ **Creates a persistent Meraki dashboard API session** - api_key (string): API key generated in dashboard; can also be set as an environment variable MERAKI_DASHBOARD_API_KEY + - custom_headers (dict): optional extra HTTP headers appended to every request; SDK-managed headers (Authorization, Content-Type, User-Agent) take precedence on collision - base_url (string): preceding all endpoint resources - single_request_timeout (integer): maximum number of seconds for each API call - certificate_path (string): path for TLS/SSL certificate verification if behind local proxy @@ -34,6 +84,7 @@ class DashboardAPI(object): - wait_on_rate_limit (boolean): retry if 429 rate limit error encountered? - nginx_429_retry_wait_time (integer): Nginx 429 retry wait time - action_batch_retry_wait_time (integer): action batch concurrency error retry wait time + - network_delete_retry_wait_time (integer): network deletion concurrency error retry wait time - retry_4xx_error (boolean): retry if encountering other 4XX error (besides 429)? - retry_4xx_error_wait_time (integer): other 4XX error retry wait time - maximum_retries (integer): retry up to this many times when encountering 429s or other server-side errors @@ -42,53 +93,92 @@ class DashboardAPI(object): - log_file_prefix (string): log file name appended with date and timestamp - print_console (boolean): print logging output to console? - suppress_logging (boolean): disable all logging? you're on your own then! + - inherit_logging_config (boolean): Inherits your own logger instance - simulate (boolean): simulate POST/PUT/DELETE calls to prevent changes? - be_geo_id (string): optional partner identifier for API usage tracking; can also be set as an environment variable BE_GEO_ID - caller (string): optional identifier for API usage tracking; can also be set as an environment variable MERAKI_PYTHON_SDK_CALLER + - use_iterator_for_get_pages (boolean): list* methods will return an iterator with each object instead of a complete list with all items + - validate_kwargs (boolean): log warnings when unrecognized kwargs are passed to API methods + - smart_flow_enabled (boolean): enable per-org proactive smart flow via token buckets? + - smart_flow_org_rate (float): max requests per second per org (Meraki default: 10) + - smart_flow_global_rate (float): max requests per second across all orgs (source IP limit, Meraki default: 100) + - smart_flow_cache_mode (string): "lazy" (default) or "eager" - how org/network/device mappings are loaded + - smart_flow_cache_path (string): path to persist smart flow mapping cache across sessions """ - def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, single_request_timeout=SINGLE_REQUEST_TIMEOUT, - certificate_path=CERTIFICATE_PATH, requests_proxy=REQUESTS_PROXY, - wait_on_rate_limit=WAIT_ON_RATE_LIMIT, nginx_429_retry_wait_time=NGINX_429_RETRY_WAIT_TIME, - action_batch_retry_wait_time=ACTION_BATCH_RETRY_WAIT_TIME, retry_4xx_error=RETRY_4XX_ERROR, - retry_4xx_error_wait_time=RETRY_4XX_ERROR_WAIT_TIME, maximum_retries=MAXIMUM_RETRIES, - output_log=OUTPUT_LOG, log_path=LOG_PATH, log_file_prefix=LOG_FILE_PREFIX, - print_console=PRINT_TO_CONSOLE, suppress_logging=SUPPRESS_LOGGING, simulate=SIMULATE_API_CALLS, - be_geo_id=BE_GEO_ID, caller=MERAKI_PYTHON_SDK_CALLER): + def __init__( + self, + api_key=None, + custom_headers=CUSTOM_HEADERS, + base_url=DEFAULT_BASE_URL, + single_request_timeout=SINGLE_REQUEST_TIMEOUT, + certificate_path=CERTIFICATE_PATH, + requests_proxy=REQUESTS_PROXY, + wait_on_rate_limit=WAIT_ON_RATE_LIMIT, + nginx_429_retry_wait_time=NGINX_429_RETRY_WAIT_TIME, + action_batch_retry_wait_time=ACTION_BATCH_RETRY_WAIT_TIME, + network_delete_retry_wait_time=NETWORK_DELETE_RETRY_WAIT_TIME, + retry_4xx_error=RETRY_4XX_ERROR, + retry_4xx_error_wait_time=RETRY_4XX_ERROR_WAIT_TIME, + maximum_retries=MAXIMUM_RETRIES, + output_log=OUTPUT_LOG, + log_path=LOG_PATH, + log_file_prefix=LOG_FILE_PREFIX, + print_console=PRINT_TO_CONSOLE, + suppress_logging=SUPPRESS_LOGGING, + simulate=SIMULATE_API_CALLS, + be_geo_id=BE_GEO_ID, + caller=MERAKI_PYTHON_SDK_CALLER, + use_iterator_for_get_pages=USE_ITERATOR_FOR_GET_PAGES, + inherit_logging_config=INHERIT_LOGGING_CONFIG, + validate_kwargs=VALIDATE_KWARGS, + smart_flow_enabled=SMART_FLOW_ENABLED, + smart_flow_org_rate=SMART_FLOW_ORG_RATE, + smart_flow_global_rate=SMART_FLOW_GLOBAL_RATE, + smart_flow_cache_mode=SMART_FLOW_CACHE_MODE, + smart_flow_cache_path=SMART_FLOW_CACHE_PATH, + smart_flow_cache_ttl=SMART_FLOW_CACHE_TTL, + smart_flow_logging=SMART_FLOW_LOGGING, + ): # Check API key api_key = api_key or os.environ.get(API_KEY_ENVIRONMENT_VARIABLE) if not api_key: raise APIKeyError() # Pull the BE GEO ID from an environment variable if present - be_geo_id = be_geo_id or os.environ.get('BE_GEO_ID') + be_geo_id = be_geo_id or os.environ.get("BE_GEO_ID") # Pull the caller from an environment variable if present - caller = caller or os.environ.get('MERAKI_PYTHON_SDK_CALLER') + caller = caller or os.environ.get("MERAKI_PYTHON_SDK_CALLER") # Configure logging if not suppress_logging: self._logger = logging.getLogger(__name__) - if log_path and log_path[-1] != '/': - log_path += '/' - self._log_file = f'{log_path}{log_file_prefix}_log__{datetime.now():%Y-%m-%d_%H-%M-%S}.log' - if output_log: - logging.basicConfig( - filename=self._log_file, - level=logging.DEBUG, - format='%(asctime)s %(name)12s: %(levelname)8s > %(message)s', - datefmt='%Y-%m-%d %H:%M:%S') - if print_console: - console = logging.StreamHandler() - console.setLevel(logging.INFO) - formatter = logging.Formatter('%(name)12s: %(levelname)8s > %(message)s') - console.setFormatter(formatter) - logging.getLogger('').addHandler(console) - elif print_console: - logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s %(name)12s: %(levelname)8s > %(message)s', - datefmt='%Y-%m-%d %H:%M:%S') + + if not inherit_logging_config: + self._logger.setLevel(logging.DEBUG) + + formatter = logging.Formatter( + fmt="%(asctime)s %(name)12s: %(levelname)8s > %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + handler_console = logging.StreamHandler() + handler_console.setFormatter(formatter) + + if output_log: + if log_path and log_path[-1] != "/": + log_path += "/" + self._log_file = f"{log_path}{log_file_prefix}_log__{datetime.now():%Y-%m-%d_%H-%M-%S}.log" + handler_log = logging.FileHandler(filename=self._log_file) + handler_log.setFormatter(formatter) + + if output_log and not self._logger.hasHandlers(): + self._logger.addHandler(handler_log) + if print_console: + handler_console.setLevel(logging.INFO) + self._logger.addHandler(handler_console) + elif print_console and not self._logger.hasHandlers(): + self._logger.addHandler(handler_console) else: self._logger = None @@ -96,6 +186,7 @@ def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, single_request_timeo self._session = RestSession( logger=self._logger, api_key=api_key, + custom_headers=custom_headers, base_url=base_url, single_request_timeout=single_request_timeout, certificate_path=certificate_path, @@ -103,15 +194,26 @@ def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, single_request_timeo wait_on_rate_limit=wait_on_rate_limit, nginx_429_retry_wait_time=nginx_429_retry_wait_time, action_batch_retry_wait_time=action_batch_retry_wait_time, + network_delete_retry_wait_time=network_delete_retry_wait_time, retry_4xx_error=retry_4xx_error, retry_4xx_error_wait_time=retry_4xx_error_wait_time, maximum_retries=maximum_retries, simulate=simulate, be_geo_id=be_geo_id, caller=caller, + use_iterator_for_get_pages=use_iterator_for_get_pages, + validate_kwargs=validate_kwargs, + smart_flow_enabled=smart_flow_enabled, + smart_flow_org_rate=smart_flow_org_rate, + smart_flow_global_rate=smart_flow_global_rate, + smart_flow_cache_mode=smart_flow_cache_mode, + smart_flow_cache_path=smart_flow_cache_path, + smart_flow_cache_ttl=smart_flow_cache_ttl, + smart_flow_logging=smart_flow_logging, ) # API endpoints by section + self.administered = Administered(self._session) self.organizations = Organizations(self._session) self.networks = Networks(self._session) self.devices = Devices(self._session) @@ -119,6 +221,52 @@ def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, single_request_timeo self.camera = Camera(self._session) self.cellularGateway = CellularGateway(self._session) self.insight = Insight(self._session) + self.licensing = Licensing(self._session) + self.sensor = Sensor(self._session) self.sm = Sm(self._session) self.switch = Switch(self._session) self.wireless = Wireless(self._session) + self.spaces = Spaces(self._session) + self.wirelessController = WirelessController(self._session) + self.campusGateway = CampusGateway(self._session) + + # Batch definitions + self.batch = Batch() + + # Eager load smart limit cache if enabled (skip if disk cache was fresh) + if smart_flow_enabled and smart_flow_cache_mode == "eager": + limiter = self._session._smart_flow + if limiter and not limiter.cache_fresh: + self._eager_load_rate_limit_cache() + + def _eager_load_rate_limit_cache(self) -> None: + """Populate the smart flow's org/network/device cache at startup.""" + rate_limiter = self._session._smart_flow + if not rate_limiter: + return + + try: + orgs = self.organizations.getOrganizations() + except Exception: + return + + for org in orgs: + org_id = org["id"] + rate_limiter.register_org(org_id) + + try: + networks = self.organizations.getOrganizationNetworks(org_id, total_pages="all", perPage=1000) + for net in networks: + rate_limiter.register_network(net["id"], org_id) + except Exception: + pass + + try: + devices = self.organizations.getOrganizationInventoryDevices(org_id, total_pages="all", perPage=1000) + for device in devices: + if device.get("serial"): + rate_limiter.register_device(device["serial"], org_id) + except Exception: + pass + + rate_limiter.save_cache() diff --git a/meraki/_version.py b/meraki/_version.py new file mode 100644 index 00000000..ed48cdab --- /dev/null +++ b/meraki/_version.py @@ -0,0 +1 @@ +__version__ = "4.3.1" diff --git a/meraki/aio/__init__.py b/meraki/aio/__init__.py index 6f4ccd5f..3ac462b6 100644 --- a/meraki/aio/__init__.py +++ b/meraki/aio/__init__.py @@ -1,23 +1,63 @@ -from datetime import datetime import logging import os -from .rest_session import * -from .api.organizations import AsyncOrganizations -from .api.networks import AsyncNetworks -from .api.devices import AsyncDevices -from .api.appliance import AsyncAppliance -from .api.camera import AsyncCamera -from .api.cellularGateway import AsyncCellularGateway -from .api.insight import AsyncInsight -from .api.sm import AsyncSm -from .api.switch import AsyncSwitch -from .api.wireless import AsyncWireless -from ..config import ( - API_KEY_ENVIRONMENT_VARIABLE, DEFAULT_BASE_URL, SINGLE_REQUEST_TIMEOUT, CERTIFICATE_PATH, REQUESTS_PROXY, - WAIT_ON_RATE_LIMIT, NGINX_429_RETRY_WAIT_TIME, ACTION_BATCH_RETRY_WAIT_TIME, RETRY_4XX_ERROR, - RETRY_4XX_ERROR_WAIT_TIME, MAXIMUM_RETRIES, OUTPUT_LOG, LOG_PATH, LOG_FILE_PREFIX, PRINT_TO_CONSOLE, - SUPPRESS_LOGGING, SIMULATE_API_CALLS, AIO_MAXIMUM_CONCURRENT_REQUESTS, BE_GEO_ID, MERAKI_PYTHON_SDK_CALLER +from meraki.aio.api.administered import AsyncAdministered +from meraki.aio.api.appliance import AsyncAppliance +from meraki.aio.api.camera import AsyncCamera +from meraki.aio.api.campusGateway import AsyncCampusGateway +from meraki.aio.api.cellularGateway import AsyncCellularGateway +from meraki.aio.api.devices import AsyncDevices +from meraki.aio.api.insight import AsyncInsight +from meraki.aio.api.licensing import AsyncLicensing +from meraki.aio.api.networks import AsyncNetworks +from meraki.aio.api.organizations import AsyncOrganizations +from meraki.aio.api.sensor import AsyncSensor +from meraki.aio.api.sm import AsyncSm +from meraki.aio.api.spaces import AsyncSpaces +from meraki.aio.api.switch import AsyncSwitch +from meraki.aio.api.wireless import AsyncWireless +from meraki.aio.api.wirelessController import AsyncWirelessController +from meraki.session.async_ import AsyncRestSession +from meraki.exceptions import APIKeyError +from datetime import datetime + +# Batch class imports +from meraki.api.batch import Batch + +# Config import +from meraki.config import ( + API_KEY_ENVIRONMENT_VARIABLE, + CUSTOM_HEADERS, + DEFAULT_BASE_URL, + SINGLE_REQUEST_TIMEOUT, + CERTIFICATE_PATH, + REQUESTS_PROXY, + WAIT_ON_RATE_LIMIT, + NGINX_429_RETRY_WAIT_TIME, + ACTION_BATCH_RETRY_WAIT_TIME, + NETWORK_DELETE_RETRY_WAIT_TIME, + RETRY_4XX_ERROR, + RETRY_4XX_ERROR_WAIT_TIME, + MAXIMUM_RETRIES, + OUTPUT_LOG, + LOG_PATH, + LOG_FILE_PREFIX, + PRINT_TO_CONSOLE, + SUPPRESS_LOGGING, + INHERIT_LOGGING_CONFIG, + SIMULATE_API_CALLS, + BE_GEO_ID, + MERAKI_PYTHON_SDK_CALLER, + USE_ITERATOR_FOR_GET_PAGES, + AIO_MAXIMUM_CONCURRENT_REQUESTS, + VALIDATE_KWARGS, + SMART_FLOW_ENABLED, + SMART_FLOW_ORG_RATE, + SMART_FLOW_GLOBAL_RATE, + SMART_FLOW_CACHE_MODE, + SMART_FLOW_CACHE_PATH, + SMART_FLOW_CACHE_TTL, + SMART_FLOW_LOGGING, ) @@ -26,6 +66,7 @@ class AsyncDashboardAPI: **Creates a persistent Meraki dashboard API session** - api_key (string): API key generated in dashboard; can also be set as an environment variable MERAKI_DASHBOARD_API_KEY + - custom_headers (dict): optional extra HTTP headers appended to every request; SDK-managed headers (Authorization, Content-Type, User-Agent) take precedence on collision - base_url (string): preceding all endpoint resources - single_request_timeout (integer): maximum number of seconds for each API call - certificate_path (string): path for TLS/SSL certificate verification if behind local proxy @@ -33,6 +74,7 @@ class AsyncDashboardAPI: - wait_on_rate_limit (boolean): retry if 429 rate limit error encountered? - nginx_429_retry_wait_time (integer): Nginx 429 retry wait time - action_batch_retry_wait_time (integer): action batch concurrency error retry wait time + - network_delete_retry_wait_time (integer): network deletion concurrency error retry wait time - retry_4xx_error (boolean): retry if encountering other 4XX error (besides 429)? - retry_4xx_error_wait_time (integer): other 4XX error retry wait time - maximum_retries (integer): retry up to this many times when encountering 429s or other server-side errors @@ -41,54 +83,94 @@ class AsyncDashboardAPI: - log_file_prefix (string): log file name appended with date and timestamp - print_console (boolean): print logging output to console? - suppress_logging (boolean): disable all logging? you're on your own then! + - inherit_logging_config (boolean): Inherits your own logger instance - simulate (boolean): simulate POST/PUT/DELETE calls to prevent changes? - maximum_concurrent_requests (integer): number of concurrent API requests for asynchronous class - be_geo_id (string): optional partner identifier for API usage tracking; can also be set as an environment variable BE_GEO_ID - caller (string): optional identifier for API usage tracking; can also be set as an environment variable MERAKI_PYTHON_SDK_CALLER + - use_iterator_for_get_pages (boolean): list* methods will return an iterator with each object instead of a complete list with all items + - validate_kwargs (boolean): log warnings when unrecognized kwargs are passed to API methods + - smart_flow_enabled (boolean): enable per-org proactive smart flow via token buckets? + - smart_flow_org_rate (float): max requests per second per org (Meraki default: 10) + - smart_flow_global_rate (float): max requests per second across all orgs (source IP limit, Meraki default: 100) + - smart_flow_cache_mode (string): "lazy" (default) or "eager" - how org/network/device mappings are loaded + - smart_flow_cache_path (string): path to persist smart flow mapping cache across sessions """ - def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, single_request_timeout=SINGLE_REQUEST_TIMEOUT, - certificate_path=CERTIFICATE_PATH, requests_proxy=REQUESTS_PROXY, - wait_on_rate_limit=WAIT_ON_RATE_LIMIT, nginx_429_retry_wait_time=NGINX_429_RETRY_WAIT_TIME, - action_batch_retry_wait_time=ACTION_BATCH_RETRY_WAIT_TIME, retry_4xx_error=RETRY_4XX_ERROR, - retry_4xx_error_wait_time=RETRY_4XX_ERROR_WAIT_TIME, maximum_retries=MAXIMUM_RETRIES, - output_log=OUTPUT_LOG, log_path=LOG_PATH, log_file_prefix=LOG_FILE_PREFIX, - print_console=PRINT_TO_CONSOLE, suppress_logging=SUPPRESS_LOGGING, simulate=SIMULATE_API_CALLS, - maximum_concurrent_requests=AIO_MAXIMUM_CONCURRENT_REQUESTS, be_geo_id=BE_GEO_ID, caller=MERAKI_PYTHON_SDK_CALLER): + def __init__( + self, + api_key=None, + custom_headers=CUSTOM_HEADERS, + base_url=DEFAULT_BASE_URL, + single_request_timeout=SINGLE_REQUEST_TIMEOUT, + certificate_path=CERTIFICATE_PATH, + requests_proxy=REQUESTS_PROXY, + wait_on_rate_limit=WAIT_ON_RATE_LIMIT, + nginx_429_retry_wait_time=NGINX_429_RETRY_WAIT_TIME, + action_batch_retry_wait_time=ACTION_BATCH_RETRY_WAIT_TIME, + network_delete_retry_wait_time=NETWORK_DELETE_RETRY_WAIT_TIME, + retry_4xx_error=RETRY_4XX_ERROR, + retry_4xx_error_wait_time=RETRY_4XX_ERROR_WAIT_TIME, + maximum_retries=MAXIMUM_RETRIES, + output_log=OUTPUT_LOG, + log_path=LOG_PATH, + log_file_prefix=LOG_FILE_PREFIX, + print_console=PRINT_TO_CONSOLE, + suppress_logging=SUPPRESS_LOGGING, + simulate=SIMULATE_API_CALLS, + be_geo_id=BE_GEO_ID, + caller=MERAKI_PYTHON_SDK_CALLER, + use_iterator_for_get_pages=USE_ITERATOR_FOR_GET_PAGES, + inherit_logging_config=INHERIT_LOGGING_CONFIG, + maximum_concurrent_requests=AIO_MAXIMUM_CONCURRENT_REQUESTS, + validate_kwargs=VALIDATE_KWARGS, + smart_flow_enabled=SMART_FLOW_ENABLED, + smart_flow_org_rate=SMART_FLOW_ORG_RATE, + smart_flow_global_rate=SMART_FLOW_GLOBAL_RATE, + smart_flow_cache_mode=SMART_FLOW_CACHE_MODE, + smart_flow_cache_path=SMART_FLOW_CACHE_PATH, + smart_flow_cache_ttl=SMART_FLOW_CACHE_TTL, + smart_flow_logging=SMART_FLOW_LOGGING, + ): # Check API key api_key = api_key or os.environ.get(API_KEY_ENVIRONMENT_VARIABLE) if not api_key: raise APIKeyError() - + # Pull the BE GEO ID from an environment variable if present - be_geo_id = be_geo_id or os.environ.get('BE_GEO_ID') + be_geo_id = be_geo_id or os.environ.get("BE_GEO_ID") # Pull the caller from an environment variable if present - caller = caller or os.environ.get('MERAKI_PYTHON_SDK_CALLER') + caller = caller or os.environ.get("MERAKI_PYTHON_SDK_CALLER") # Configure logging if not suppress_logging: self._logger = logging.getLogger(__name__) - if log_path and log_path[-1] != '/': - log_path += '/' - self._log_file = f'{log_path}{log_file_prefix}_log__{datetime.now():%Y-%m-%d_%H-%M-%S}.log' - if output_log: - logging.basicConfig( - filename=self._log_file, - level=logging.DEBUG, - format='%(asctime)s %(name)12s: %(levelname)8s > %(message)s', - datefmt='%Y-%m-%d %H:%M:%S') - if print_console: - console = logging.StreamHandler() - console.setLevel(logging.INFO) - formatter = logging.Formatter('%(name)12s: %(levelname)8s > %(message)s') - console.setFormatter(formatter) - logging.getLogger('').addHandler(console) - elif print_console: - logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s %(name)12s: %(levelname)8s > %(message)s', - datefmt='%Y-%m-%d %H:%M:%S') + + if not inherit_logging_config: + self._logger.setLevel(logging.DEBUG) + + formatter = logging.Formatter( + fmt="%(asctime)s %(name)12s: %(levelname)8s > %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + handler_console = logging.StreamHandler() + handler_console.setFormatter(formatter) + + if output_log: + if log_path and log_path[-1] != "/": + log_path += "/" + self._log_file = f"{log_path}{log_file_prefix}_log__{datetime.now():%Y-%m-%d_%H-%M-%S}.log" + handler_log = logging.FileHandler(filename=self._log_file) + handler_log.setFormatter(formatter) + + if output_log and not self._logger.hasHandlers(): + self._logger.addHandler(handler_log) + if print_console: + handler_console.setLevel(logging.INFO) + self._logger.addHandler(handler_console) + elif print_console and not self._logger.hasHandlers(): + self._logger.addHandler(handler_console) else: self._logger = None @@ -96,6 +178,7 @@ def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, single_request_timeo self._session = AsyncRestSession( logger=self._logger, api_key=api_key, + custom_headers=custom_headers, base_url=base_url, single_request_timeout=single_request_timeout, certificate_path=certificate_path, @@ -103,16 +186,31 @@ def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, single_request_timeo wait_on_rate_limit=wait_on_rate_limit, nginx_429_retry_wait_time=nginx_429_retry_wait_time, action_batch_retry_wait_time=action_batch_retry_wait_time, + network_delete_retry_wait_time=network_delete_retry_wait_time, retry_4xx_error=retry_4xx_error, retry_4xx_error_wait_time=retry_4xx_error_wait_time, maximum_retries=maximum_retries, simulate=simulate, - maximum_concurrent_requests=maximum_concurrent_requests, be_geo_id=be_geo_id, caller=caller, + use_iterator_for_get_pages=use_iterator_for_get_pages, + maximum_concurrent_requests=maximum_concurrent_requests, + validate_kwargs=validate_kwargs, + smart_flow_enabled=smart_flow_enabled, + smart_flow_org_rate=smart_flow_org_rate, + smart_flow_global_rate=smart_flow_global_rate, + smart_flow_cache_mode=smart_flow_cache_mode, + smart_flow_cache_path=smart_flow_cache_path, + smart_flow_cache_ttl=smart_flow_cache_ttl, + smart_flow_logging=smart_flow_logging, ) + # Store for eager load access + self._smart_flow_enabled = smart_flow_enabled + self._smart_flow_cache_mode = smart_flow_cache_mode + # API endpoints by section + self.administered = AsyncAdministered(self._session) self.organizations = AsyncOrganizations(self._session) self.networks = AsyncNetworks(self._session) self.devices = AsyncDevices(self._session) @@ -120,12 +218,63 @@ def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, single_request_timeo self.camera = AsyncCamera(self._session) self.cellularGateway = AsyncCellularGateway(self._session) self.insight = AsyncInsight(self._session) + self.licensing = AsyncLicensing(self._session) + self.sensor = AsyncSensor(self._session) self.switch = AsyncSwitch(self._session) self.sm = AsyncSm(self._session) self.wireless = AsyncWireless(self._session) + self.spaces = AsyncSpaces(self._session) + self.wirelessController = AsyncWirelessController(self._session) + self.campusGateway = AsyncCampusGateway(self._session) + + # Batch definitions + self.batch = Batch() async def __aenter__(self): + if self._smart_flow_enabled and self._smart_flow_cache_mode == "eager": + limiter = self._session._smart_flow + if limiter and not limiter.cache_fresh: + await self._eager_load_rate_limit_cache() return self async def __aexit__(self, exc_type, exc, tb): + # Drain/cancel in-flight background resolve/hydrate + flush tasks and + # persist the cache BEFORE closing the httpx client. Closing first would + # cause those background tasks to fail with "client has been closed". + # shutdown() performs the final save itself, so it is called instead of + # save_cache() to keep the persist exactly once. + if self._session._smart_flow: + await self._session._smart_flow.shutdown() await self._session.close() + + async def _eager_load_rate_limit_cache(self) -> None: + """Populate the smart flow's org/network/device cache at startup.""" + rate_limiter = self._session._smart_flow + if not rate_limiter: + return + + try: + orgs = await self.organizations.getOrganizations() + except Exception: + return + + for org in orgs: + org_id = org["id"] + rate_limiter.register_org(org_id) + + try: + networks = await self.organizations.getOrganizationNetworks(org_id, total_pages="all", perPage=1000) + for net in networks: + rate_limiter.register_network(net["id"], org_id) + except Exception: + pass + + try: + devices = await self.organizations.getOrganizationInventoryDevices(org_id, total_pages="all", perPage=1000) + for device in devices: + if device.get("serial"): + rate_limiter.register_device(device["serial"], org_id) + except Exception: + pass + + await rate_limiter.save_cache() diff --git a/meraki/aio/api/administered.py b/meraki/aio/api/administered.py new file mode 100644 index 00000000..86a44296 --- /dev/null +++ b/meraki/aio/api/administered.py @@ -0,0 +1,69 @@ +import urllib + + +class AsyncAdministered: + def __init__(self, session): + super().__init__() + self._session = session + + def getAdministeredIdentitiesMe(self): + """ + **Returns the identity of the current user.** + https://developer.cisco.com/meraki/api-v1/#!get-administered-identities-me + + """ + + metadata = { + "tags": ["administered", "monitor", "identities", "me"], + "operation": "getAdministeredIdentitiesMe", + } + resource = "/administered/identities/me" + + return self._session.get(metadata, resource) + + def getAdministeredIdentitiesMeApiKeys(self): + """ + **List the non-sensitive metadata associated with the API keys that belong to the user** + https://developer.cisco.com/meraki/api-v1/#!get-administered-identities-me-api-keys + + """ + + metadata = { + "tags": ["administered", "configure", "identities", "me", "api", "keys"], + "operation": "getAdministeredIdentitiesMeApiKeys", + } + resource = "/administered/identities/me/api/keys" + + return self._session.get(metadata, resource) + + def generateAdministeredIdentitiesMeApiKeys(self): + """ + **Generates an API key for an identity** + https://developer.cisco.com/meraki/api-v1/#!generate-administered-identities-me-api-keys + + """ + + metadata = { + "tags": ["administered", "configure", "identities", "me", "api", "keys"], + "operation": "generateAdministeredIdentitiesMeApiKeys", + } + resource = "/administered/identities/me/api/keys/generate" + + return self._session.post(metadata, resource) + + def revokeAdministeredIdentitiesMeApiKeys(self, suffix: str): + """ + **Revokes an identity's API key, using the last four characters of the key** + https://developer.cisco.com/meraki/api-v1/#!revoke-administered-identities-me-api-keys + + - suffix (string): Suffix + """ + + metadata = { + "tags": ["administered", "configure", "identities", "me", "api", "keys"], + "operation": "revokeAdministeredIdentitiesMeApiKeys", + } + suffix = urllib.parse.quote(str(suffix), safe="") + resource = f"/administered/identities/me/api/keys/{suffix}/revoke" + + return self._session.post(metadata, resource) diff --git a/meraki/aio/api/appliance.py b/meraki/aio/api/appliance.py index 7b974d6c..09c98051 100644 --- a/meraki/aio/api/appliance.py +++ b/meraki/aio/api/appliance.py @@ -1,3 +1,6 @@ +import urllib + + class AsyncAppliance: def __init__(self, session): super().__init__() @@ -8,40 +11,254 @@ def getDeviceApplianceDhcpSubnets(self, serial: str): **Return the DHCP subnet information for an appliance** https://developer.cisco.com/meraki/api-v1/#!get-device-appliance-dhcp-subnets - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['appliance', 'monitor', 'dhcp', 'subnets'], - 'operation': 'getDeviceApplianceDhcpSubnets' + "tags": ["appliance", "monitor", "dhcp", "subnets"], + "operation": "getDeviceApplianceDhcpSubnets", } - resource = f'/devices/{serial}/appliance/dhcp/subnets' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/dhcp/subnets" return self._session.get(metadata, resource) - def getDeviceAppliancePerformance(self, serial: str): + def createDeviceApplianceInterfacesPortsUpdate(self, serial: str, **kwargs): + """ + **Update configurations for an appliance's specified port** + https://developer.cisco.com/meraki/api-v1/#!create-device-appliance-interfaces-ports-update + + - serial (string): Serial + - interface (object): The interface tuple used to identify the port + - enabled (boolean): Indicates whether the port is enabled + - personality (object): Describes the port's configurability + - uplink (object): The port's settings when in WAN mode + - downlink (object): The port's VLAN settings when in LAN mode + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "interfaces", "ports", "update"], + "operation": "createDeviceApplianceInterfacesPortsUpdate", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/interfaces/ports/update" + + body_params = [ + "interface", + "enabled", + "personality", + "uplink", + "downlink", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createDeviceApplianceInterfacesPortsUpdate: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getDeviceAppliancePerformance(self, serial: str, **kwargs): """ - **Return the performance score for a single device. Only primary MX devices supported. If no data is available, a 204 error code is returned.** + **Return the performance score for a single Secure Appliance or Secure Router** https://developer.cisco.com/meraki/api-v1/#!get-device-appliance-performance - - serial (string): (required) + - serial (string): Serial + - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 30 days from today. + - t1 (string): The end of the timespan for the data. t1 can be a maximum of 14 days after t0. + - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be greater than or equal to 30 minutes and be less than or equal to 14 days. The default is 30 minutes. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "monitor", "performance"], + "operation": "getDeviceAppliancePerformance", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/performance" + + query_params = [ + "t0", + "t1", + "timespan", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getDeviceAppliancePerformance: ignoring unrecognized kwargs: {invalid}") + + return self._session.get(metadata, resource, params) + + def getDeviceAppliancePrefixesDelegated(self, serial: str): + """ + **Return current delegated IPv6 prefixes on an appliance.** + https://developer.cisco.com/meraki/api-v1/#!get-device-appliance-prefixes-delegated + + - serial (string): Serial + """ + + metadata = { + "tags": ["appliance", "monitor", "prefixes", "delegated"], + "operation": "getDeviceAppliancePrefixesDelegated", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/prefixes/delegated" + + return self._session.get(metadata, resource) + + def getDeviceAppliancePrefixesDelegatedVlanAssignments(self, serial: str): + """ + **Return prefixes assigned to all IPv6 enabled VLANs on an appliance.** + https://developer.cisco.com/meraki/api-v1/#!get-device-appliance-prefixes-delegated-vlan-assignments + + - serial (string): Serial + """ + + metadata = { + "tags": ["appliance", "monitor", "prefixes", "delegated", "vlanAssignments"], + "operation": "getDeviceAppliancePrefixesDelegatedVlanAssignments", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/prefixes/delegated/vlanAssignments" + + return self._session.get(metadata, resource) + + def getDeviceApplianceRadioSettings(self, serial: str): + """ + **Return the radio settings of an appliance** + https://developer.cisco.com/meraki/api-v1/#!get-device-appliance-radio-settings + + - serial (string): Serial + """ + + metadata = { + "tags": ["appliance", "configure", "radio", "settings"], + "operation": "getDeviceApplianceRadioSettings", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/radio/settings" + + return self._session.get(metadata, resource) + + def updateDeviceApplianceRadioSettings(self, serial: str, **kwargs): + """ + **Update the radio settings of an appliance** + https://developer.cisco.com/meraki/api-v1/#!update-device-appliance-radio-settings + + - serial (string): Serial + - rfProfileId (string): The ID of an RF profile to assign to the device. If the value of this parameter is null, the appropriate basic RF profile (indoor or outdoor) will be assigned to the device. Assigning an RF profile will clear ALL manually configured overrides on the device (channel width, channel, power). + - twoFourGhzSettings (object): Manual radio settings for 2.4 GHz. + - fiveGhzSettings (object): Manual radio settings for 5 GHz. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "radio", "settings"], + "operation": "updateDeviceApplianceRadioSettings", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/radio/settings" + + body_params = [ + "rfProfileId", + "twoFourGhzSettings", + "fiveGhzSettings", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDeviceApplianceRadioSettings: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def getDeviceApplianceUplinksSettings(self, serial: str): + """ + **Return the uplink settings for a secure router or security appliance** + https://developer.cisco.com/meraki/api-v1/#!get-device-appliance-uplinks-settings + + - serial (string): Serial """ metadata = { - 'tags': ['appliance', 'monitor', 'performance'], - 'operation': 'getDeviceAppliancePerformance' + "tags": ["appliance", "configure", "uplinks", "settings"], + "operation": "getDeviceApplianceUplinksSettings", } - resource = f'/devices/{serial}/appliance/performance' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/uplinks/settings" return self._session.get(metadata, resource) - def getNetworkApplianceClientSecurityEvents(self, networkId: str, clientId: str, total_pages=1, direction='next', **kwargs): + def updateDeviceApplianceUplinksSettings(self, serial: str, interfaces: dict, **kwargs): + """ + **Update the uplink settings for a secure router or security appliance** + https://developer.cisco.com/meraki/api-v1/#!update-device-appliance-uplinks-settings + + - serial (string): Serial + - interfaces (object): Interface settings. + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "uplinks", "settings"], + "operation": "updateDeviceApplianceUplinksSettings", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/uplinks/settings" + + body_params = [ + "interfaces", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDeviceApplianceUplinksSettings: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def createDeviceApplianceVmxAuthenticationToken(self, serial: str): + """ + **Generate a new vMX authentication token** + https://developer.cisco.com/meraki/api-v1/#!create-device-appliance-vmx-authentication-token + + - serial (string): Serial + """ + + metadata = { + "tags": ["appliance", "configure", "vmx", "authenticationToken"], + "operation": "createDeviceApplianceVmxAuthenticationToken", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/vmx/authenticationToken" + + return self._session.post(metadata, resource) + + def getNetworkApplianceClientSecurityEvents( + self, networkId: str, clientId: str, total_pages=1, direction="next", **kwargs + ): """ - **List the security events for a client. Clients can be identified by a client key or either the MAC or IP depending on whether the network uses Track-by-IP.** + **List the security events for a client** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-client-security-events - - networkId (string): (required) - - clientId (string): (required) + - networkId (string): Network ID + - clientId (string): Client ID - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages - direction (string): direction to paginate, either "next" (default) or "prev" page - t0 (string): The beginning of the timespan for the data. Data is gathered after the specified t0 value. The maximum lookback period is 791 days from today. @@ -55,19 +272,39 @@ def getNetworkApplianceClientSecurityEvents(self, networkId: str, clientId: str, kwargs.update(locals()) - if 'sortOrder' in kwargs: - options = ['ascending', 'descending'] - assert kwargs['sortOrder'] in options, f'''"sortOrder" cannot be "{kwargs['sortOrder']}", & must be set to one of: {options}''' + if "sortOrder" in kwargs: + options = ["ascending", "descending"] + assert kwargs["sortOrder"] in options, ( + f'''"sortOrder" cannot be "{kwargs["sortOrder"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['appliance', 'monitor', 'clients', 'security', 'events'], - 'operation': 'getNetworkApplianceClientSecurityEvents' + "tags": ["appliance", "monitor", "clients", "security", "events"], + "operation": "getNetworkApplianceClientSecurityEvents", } - resource = f'/networks/{networkId}/appliance/clients/{clientId}/security/events' - - query_params = ['t0', 't1', 'timespan', 'perPage', 'startingAfter', 'endingBefore', 'sortOrder', ] + networkId = urllib.parse.quote(str(networkId), safe="") + clientId = urllib.parse.quote(str(clientId), safe="") + resource = f"/networks/{networkId}/appliance/clients/{clientId}/security/events" + + query_params = [ + "t0", + "t1", + "timespan", + "perPage", + "startingAfter", + "endingBefore", + "sortOrder", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getNetworkApplianceClientSecurityEvents: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get_pages(metadata, resource, params, total_pages, direction) def getNetworkApplianceConnectivityMonitoringDestinations(self, networkId: str): @@ -75,14 +312,15 @@ def getNetworkApplianceConnectivityMonitoringDestinations(self, networkId: str): **Return the connectivity testing destinations for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-connectivity-monitoring-destinations - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'connectivityMonitoringDestinations'], - 'operation': 'getNetworkApplianceConnectivityMonitoringDestinations' + "tags": ["appliance", "configure", "connectivityMonitoringDestinations"], + "operation": "getNetworkApplianceConnectivityMonitoringDestinations", } - resource = f'/networks/{networkId}/appliance/connectivityMonitoringDestinations' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/connectivityMonitoringDestinations" return self._session.get(metadata, resource) @@ -91,21 +329,32 @@ def updateNetworkApplianceConnectivityMonitoringDestinations(self, networkId: st **Update the connectivity testing destinations for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-connectivity-monitoring-destinations - - networkId (string): (required) + - networkId (string): Network ID - destinations (array): The list of connectivity monitoring destinations """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'connectivityMonitoringDestinations'], - 'operation': 'updateNetworkApplianceConnectivityMonitoringDestinations' + "tags": ["appliance", "configure", "connectivityMonitoringDestinations"], + "operation": "updateNetworkApplianceConnectivityMonitoringDestinations", } - resource = f'/networks/{networkId}/appliance/connectivityMonitoringDestinations' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/connectivityMonitoringDestinations" - body_params = ['destinations', ] + body_params = [ + "destinations", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceConnectivityMonitoringDestinations: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceContentFiltering(self, networkId: str): @@ -113,14 +362,15 @@ def getNetworkApplianceContentFiltering(self, networkId: str): **Return the content filtering settings for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-content-filtering - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'contentFiltering'], - 'operation': 'getNetworkApplianceContentFiltering' + "tags": ["appliance", "configure", "contentFiltering"], + "operation": "getNetworkApplianceContentFiltering", } - resource = f'/networks/{networkId}/appliance/contentFiltering' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/contentFiltering" return self._session.get(metadata, resource) @@ -129,7 +379,7 @@ def updateNetworkApplianceContentFiltering(self, networkId: str, **kwargs): **Update the content filtering settings for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-content-filtering - - networkId (string): (required) + - networkId (string): Network ID - allowedUrlPatterns (array): A list of URL patterns that are allowed - blockedUrlPatterns (array): A list of URL patterns that are blocked - blockedUrlCategories (array): A list of URL categories to block @@ -138,19 +388,35 @@ def updateNetworkApplianceContentFiltering(self, networkId: str, **kwargs): kwargs.update(locals()) - if 'urlCategoryListSize' in kwargs: - options = ['topSites', 'fullList'] - assert kwargs['urlCategoryListSize'] in options, f'''"urlCategoryListSize" cannot be "{kwargs['urlCategoryListSize']}", & must be set to one of: {options}''' + if "urlCategoryListSize" in kwargs: + options = ["fullList", "topSites"] + assert kwargs["urlCategoryListSize"] in options, ( + f'''"urlCategoryListSize" cannot be "{kwargs["urlCategoryListSize"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['appliance', 'configure', 'contentFiltering'], - 'operation': 'updateNetworkApplianceContentFiltering' + "tags": ["appliance", "configure", "contentFiltering"], + "operation": "updateNetworkApplianceContentFiltering", } - resource = f'/networks/{networkId}/appliance/contentFiltering' - - body_params = ['allowedUrlPatterns', 'blockedUrlPatterns', 'blockedUrlCategories', 'urlCategoryListSize', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/contentFiltering" + + body_params = [ + "allowedUrlPatterns", + "blockedUrlPatterns", + "blockedUrlCategories", + "urlCategoryListSize", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceContentFiltering: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceContentFilteringCategories(self, networkId: str): @@ -158,30 +424,92 @@ def getNetworkApplianceContentFilteringCategories(self, networkId: str): **List all available content filtering categories for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-content-filtering-categories - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'contentFiltering', 'categories'], - 'operation': 'getNetworkApplianceContentFilteringCategories' + "tags": ["appliance", "configure", "contentFiltering", "categories"], + "operation": "getNetworkApplianceContentFilteringCategories", } - resource = f'/networks/{networkId}/appliance/contentFiltering/categories' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/contentFiltering/categories" return self._session.get(metadata, resource) + def updateNetworkApplianceDevicesRedundancy(self, networkId: str, enabled: bool, **kwargs): + """ + **Update MX warm spare settings** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-devices-redundancy + + - networkId (string): Network ID + - enabled (boolean): Enable warm spare + - mode (string): HA mode (disabled|active-passive|active-active) + - designations (array): Ordered warm spare roles + - uplink (object): Uplink configuration + """ + + kwargs.update(locals()) + + if "mode" in kwargs: + options = ["active-active", "active-passive", "disabled"] + assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}''' + + metadata = { + "tags": ["appliance", "configure", "devices", "redundancy"], + "operation": "updateNetworkApplianceDevicesRedundancy", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/devices/redundancy" + + body_params = [ + "enabled", + "mode", + "designations", + "uplink", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceDevicesRedundancy: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def createNetworkApplianceDevicesRedundancySwap(self, networkId: str): + """ + **Swap MX primary and warm spare appliances** + https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-devices-redundancy-swap + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "devices", "redundancy", "swap"], + "operation": "createNetworkApplianceDevicesRedundancySwap", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/devices/redundancy/swap" + + return self._session.post(metadata, resource) + def getNetworkApplianceFirewallCellularFirewallRules(self, networkId: str): """ **Return the cellular firewall rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-cellular-firewall-rules - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'cellularFirewallRules'], - 'operation': 'getNetworkApplianceFirewallCellularFirewallRules' + "tags": ["appliance", "configure", "firewall", "cellularFirewallRules"], + "operation": "getNetworkApplianceFirewallCellularFirewallRules", } - resource = f'/networks/{networkId}/appliance/firewall/cellularFirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/cellularFirewallRules" return self._session.get(metadata, resource) @@ -190,21 +518,32 @@ def updateNetworkApplianceFirewallCellularFirewallRules(self, networkId: str, ** **Update the cellular firewall rules of an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-cellular-firewall-rules - - networkId (string): (required) + - networkId (string): Network ID - rules (array): An ordered array of the firewall rules (not including the default rule) """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'cellularFirewallRules'], - 'operation': 'updateNetworkApplianceFirewallCellularFirewallRules' + "tags": ["appliance", "configure", "firewall", "cellularFirewallRules"], + "operation": "updateNetworkApplianceFirewallCellularFirewallRules", } - resource = f'/networks/{networkId}/appliance/firewall/cellularFirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/cellularFirewallRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallCellularFirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallFirewalledServices(self, networkId: str): @@ -212,14 +551,15 @@ def getNetworkApplianceFirewallFirewalledServices(self, networkId: str): **List the appliance services and their accessibility rules** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-firewalled-services - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'firewalledServices'], - 'operation': 'getNetworkApplianceFirewallFirewalledServices' + "tags": ["appliance", "configure", "firewall", "firewalledServices"], + "operation": "getNetworkApplianceFirewallFirewalledServices", } - resource = f'/networks/{networkId}/appliance/firewall/firewalledServices' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/firewalledServices" return self._session.get(metadata, resource) @@ -228,15 +568,17 @@ def getNetworkApplianceFirewallFirewalledService(self, networkId: str, service: **Return the accessibility settings of the given service ('ICMP', 'web', or 'SNMP')** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-firewalled-service - - networkId (string): (required) - - service (string): (required) + - networkId (string): Network ID + - service (string): Service """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'firewalledServices'], - 'operation': 'getNetworkApplianceFirewallFirewalledService' + "tags": ["appliance", "configure", "firewall", "firewalledServices"], + "operation": "getNetworkApplianceFirewallFirewalledService", } - resource = f'/networks/{networkId}/appliance/firewall/firewalledServices/{service}' + networkId = urllib.parse.quote(str(networkId), safe="") + service = urllib.parse.quote(str(service), safe="") + resource = f"/networks/{networkId}/appliance/firewall/firewalledServices/{service}" return self._session.get(metadata, resource) @@ -245,27 +587,92 @@ def updateNetworkApplianceFirewallFirewalledService(self, networkId: str, servic **Updates the accessibility settings for the given service ('ICMP', 'web', or 'SNMP')** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-firewalled-service - - networkId (string): (required) - - service (string): (required) + - networkId (string): Network ID + - service (string): Service - access (string): A string indicating the rule for which IPs are allowed to use the specified service. Acceptable values are "blocked" (no remote IPs can access the service), "restricted" (only allowed IPs can access the service), and "unrestriced" (any remote IP can access the service). This field is required - - allowedIps (array): An array of allowed IPs that can access the service. This field is required if "access" is set to "restricted". Otherwise this field is ignored + - allowedIps (array): An array of allowed CIDRs that can access the service. This field is required if "access" is set to "restricted". Otherwise this field is ignored """ kwargs.update(locals()) - if 'access' in kwargs: - options = ['blocked', 'restricted', 'unrestricted'] - assert kwargs['access'] in options, f'''"access" cannot be "{kwargs['access']}", & must be set to one of: {options}''' + if "access" in kwargs: + options = ["blocked", "restricted", "unrestricted"] + assert kwargs["access"] in options, ( + f'''"access" cannot be "{kwargs["access"]}", & must be set to one of: {options}''' + ) + + metadata = { + "tags": ["appliance", "configure", "firewall", "firewalledServices"], + "operation": "updateNetworkApplianceFirewallFirewalledService", + } + networkId = urllib.parse.quote(str(networkId), safe="") + service = urllib.parse.quote(str(service), safe="") + resource = f"/networks/{networkId}/appliance/firewall/firewalledServices/{service}" + + body_params = [ + "access", + "allowedIps", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallFirewalledService: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def getNetworkApplianceFirewallInboundCellularFirewallRules(self, networkId: str): + """ + **Return the inbound cellular firewall rules for an MX network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-inbound-cellular-firewall-rules + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "firewall", "inboundCellularFirewallRules"], + "operation": "getNetworkApplianceFirewallInboundCellularFirewallRules", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/inboundCellularFirewallRules" + + return self._session.get(metadata, resource) + + def updateNetworkApplianceFirewallInboundCellularFirewallRules(self, networkId: str, **kwargs): + """ + **Update the inbound cellular firewall rules of an MX network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-inbound-cellular-firewall-rules + + - networkId (string): Network ID + - rules (array): An ordered array of the firewall rules (not including the default rule) + """ + + kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'firewalledServices'], - 'operation': 'updateNetworkApplianceFirewallFirewalledService' + "tags": ["appliance", "configure", "firewall", "inboundCellularFirewallRules"], + "operation": "updateNetworkApplianceFirewallInboundCellularFirewallRules", } - resource = f'/networks/{networkId}/appliance/firewall/firewalledServices/{service}' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/inboundCellularFirewallRules" - body_params = ['access', 'allowedIps', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallInboundCellularFirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallInboundFirewallRules(self, networkId: str): @@ -273,14 +680,15 @@ def getNetworkApplianceFirewallInboundFirewallRules(self, networkId: str): **Return the inbound firewall rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-inbound-firewall-rules - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'inboundFirewallRules'], - 'operation': 'getNetworkApplianceFirewallInboundFirewallRules' + "tags": ["appliance", "configure", "firewall", "inboundFirewallRules"], + "operation": "getNetworkApplianceFirewallInboundFirewallRules", } - resource = f'/networks/{networkId}/appliance/firewall/inboundFirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/inboundFirewallRules" return self._session.get(metadata, resource) @@ -289,7 +697,7 @@ def updateNetworkApplianceFirewallInboundFirewallRules(self, networkId: str, **k **Update the inbound firewall rules of an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-inbound-firewall-rules - - networkId (string): (required) + - networkId (string): Network ID - rules (array): An ordered array of the firewall rules (not including the default rule) - syslogDefaultRule (boolean): Log the special default rule (boolean value - enable only if you've configured a syslog server) (optional) """ @@ -297,14 +705,26 @@ def updateNetworkApplianceFirewallInboundFirewallRules(self, networkId: str, **k kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'inboundFirewallRules'], - 'operation': 'updateNetworkApplianceFirewallInboundFirewallRules' + "tags": ["appliance", "configure", "firewall", "inboundFirewallRules"], + "operation": "updateNetworkApplianceFirewallInboundFirewallRules", } - resource = f'/networks/{networkId}/appliance/firewall/inboundFirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/inboundFirewallRules" - body_params = ['rules', 'syslogDefaultRule', ] + body_params = [ + "rules", + "syslogDefaultRule", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallInboundFirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallL3FirewallRules(self, networkId: str): @@ -312,14 +732,15 @@ def getNetworkApplianceFirewallL3FirewallRules(self, networkId: str): **Return the L3 firewall rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-l-3-firewall-rules - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'l3FirewallRules'], - 'operation': 'getNetworkApplianceFirewallL3FirewallRules' + "tags": ["appliance", "configure", "firewall", "l3FirewallRules"], + "operation": "getNetworkApplianceFirewallL3FirewallRules", } - resource = f'/networks/{networkId}/appliance/firewall/l3FirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/l3FirewallRules" return self._session.get(metadata, resource) @@ -328,7 +749,7 @@ def updateNetworkApplianceFirewallL3FirewallRules(self, networkId: str, **kwargs **Update the L3 firewall rules of an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-l-3-firewall-rules - - networkId (string): (required) + - networkId (string): Network ID - rules (array): An ordered array of the firewall rules (not including the default rule) - syslogDefaultRule (boolean): Log the special default rule (boolean value - enable only if you've configured a syslog server) (optional) """ @@ -336,14 +757,26 @@ def updateNetworkApplianceFirewallL3FirewallRules(self, networkId: str, **kwargs kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'l3FirewallRules'], - 'operation': 'updateNetworkApplianceFirewallL3FirewallRules' + "tags": ["appliance", "configure", "firewall", "l3FirewallRules"], + "operation": "updateNetworkApplianceFirewallL3FirewallRules", } - resource = f'/networks/{networkId}/appliance/firewall/l3FirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/l3FirewallRules" - body_params = ['rules', 'syslogDefaultRule', ] + body_params = [ + "rules", + "syslogDefaultRule", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallL3FirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallL7FirewallRules(self, networkId: str): @@ -351,14 +784,15 @@ def getNetworkApplianceFirewallL7FirewallRules(self, networkId: str): **List the MX L7 firewall rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-l-7-firewall-rules - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'l7FirewallRules'], - 'operation': 'getNetworkApplianceFirewallL7FirewallRules' + "tags": ["appliance", "configure", "firewall", "l7FirewallRules"], + "operation": "getNetworkApplianceFirewallL7FirewallRules", } - resource = f'/networks/{networkId}/appliance/firewall/l7FirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/l7FirewallRules" return self._session.get(metadata, resource) @@ -367,21 +801,32 @@ def updateNetworkApplianceFirewallL7FirewallRules(self, networkId: str, **kwargs **Update the MX L7 firewall rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-l-7-firewall-rules - - networkId (string): (required) - - rules (array): An ordered array of the MX L7 firewall rules + - networkId (string): Network ID + - rules (array): An ordered array of the MX L7 firewall rules. Each rule is an object with 'policy', 'type', and 'value'. The 'value' shape depends on 'type': object for application/applicationCategory, string for host/port/ipRange, and an array of 2-letter ISO 3166-1 alpha-2 country codes for allowedCountries/blockedCountries. For backward compatibility, request types also accept whitelistedCountries/blacklistedCountries. """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'l7FirewallRules'], - 'operation': 'updateNetworkApplianceFirewallL7FirewallRules' + "tags": ["appliance", "configure", "firewall", "l7FirewallRules"], + "operation": "updateNetworkApplianceFirewallL7FirewallRules", } - resource = f'/networks/{networkId}/appliance/firewall/l7FirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/l7FirewallRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallL7FirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallL7FirewallRulesApplicationCategories(self, networkId: str): @@ -389,53 +834,99 @@ def getNetworkApplianceFirewallL7FirewallRulesApplicationCategories(self, networ **Return the L7 firewall application categories and their associated applications for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-l-7-firewall-rules-application-categories - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'l7FirewallRules', 'applicationCategories'], - 'operation': 'getNetworkApplianceFirewallL7FirewallRulesApplicationCategories' + "tags": ["appliance", "configure", "firewall", "l7FirewallRules", "applicationCategories"], + "operation": "getNetworkApplianceFirewallL7FirewallRulesApplicationCategories", } - resource = f'/networks/{networkId}/appliance/firewall/l7FirewallRules/applicationCategories' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/l7FirewallRules/applicationCategories" return self._session.get(metadata, resource) + def updateNetworkApplianceFirewallMulticastForwarding(self, networkId: str, rules: list, **kwargs): + """ + **Update static multicast forward rules for a network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-multicast-forwarding + + - networkId (string): Network ID + - rules (array): Static multicast forwarding rules. Pass an empty array to clear all rules. + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "firewall", "multicastForwarding"], + "operation": "updateNetworkApplianceFirewallMulticastForwarding", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/multicastForwarding" + + body_params = [ + "rules", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallMulticastForwarding: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + def getNetworkApplianceFirewallOneToManyNatRules(self, networkId: str): """ **Return the 1:Many NAT mapping rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-one-to-many-nat-rules - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'oneToManyNatRules'], - 'operation': 'getNetworkApplianceFirewallOneToManyNatRules' + "tags": ["appliance", "configure", "firewall", "oneToManyNatRules"], + "operation": "getNetworkApplianceFirewallOneToManyNatRules", } - resource = f'/networks/{networkId}/appliance/firewall/oneToManyNatRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/oneToManyNatRules" return self._session.get(metadata, resource) - def updateNetworkApplianceFirewallOneToManyNatRules(self, networkId: str, rules: list): + def updateNetworkApplianceFirewallOneToManyNatRules(self, networkId: str, rules: list, **kwargs): """ **Set the 1:Many NAT mapping rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-one-to-many-nat-rules - - networkId (string): (required) + - networkId (string): Network ID - rules (array): An array of 1:Many nat rules """ kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'oneToManyNatRules'], - 'operation': 'updateNetworkApplianceFirewallOneToManyNatRules' + "tags": ["appliance", "configure", "firewall", "oneToManyNatRules"], + "operation": "updateNetworkApplianceFirewallOneToManyNatRules", } - resource = f'/networks/{networkId}/appliance/firewall/oneToManyNatRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/oneToManyNatRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallOneToManyNatRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallOneToOneNatRules(self, networkId: str): @@ -443,37 +934,49 @@ def getNetworkApplianceFirewallOneToOneNatRules(self, networkId: str): **Return the 1:1 NAT mapping rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-one-to-one-nat-rules - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'oneToOneNatRules'], - 'operation': 'getNetworkApplianceFirewallOneToOneNatRules' + "tags": ["appliance", "configure", "firewall", "oneToOneNatRules"], + "operation": "getNetworkApplianceFirewallOneToOneNatRules", } - resource = f'/networks/{networkId}/appliance/firewall/oneToOneNatRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/oneToOneNatRules" return self._session.get(metadata, resource) - def updateNetworkApplianceFirewallOneToOneNatRules(self, networkId: str, rules: list): + def updateNetworkApplianceFirewallOneToOneNatRules(self, networkId: str, rules: list, **kwargs): """ **Set the 1:1 NAT mapping rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-one-to-one-nat-rules - - networkId (string): (required) + - networkId (string): Network ID - rules (array): An array of 1:1 nat rules """ kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'oneToOneNatRules'], - 'operation': 'updateNetworkApplianceFirewallOneToOneNatRules' + "tags": ["appliance", "configure", "firewall", "oneToOneNatRules"], + "operation": "updateNetworkApplianceFirewallOneToOneNatRules", } - resource = f'/networks/{networkId}/appliance/firewall/oneToOneNatRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/oneToOneNatRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallOneToOneNatRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallPortForwardingRules(self, networkId: str): @@ -481,901 +984,3195 @@ def getNetworkApplianceFirewallPortForwardingRules(self, networkId: str): **Return the port forwarding rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-port-forwarding-rules - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'portForwardingRules'], - 'operation': 'getNetworkApplianceFirewallPortForwardingRules' + "tags": ["appliance", "configure", "firewall", "portForwardingRules"], + "operation": "getNetworkApplianceFirewallPortForwardingRules", } - resource = f'/networks/{networkId}/appliance/firewall/portForwardingRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/portForwardingRules" return self._session.get(metadata, resource) - def updateNetworkApplianceFirewallPortForwardingRules(self, networkId: str, rules: list): + def updateNetworkApplianceFirewallPortForwardingRules(self, networkId: str, rules: list, **kwargs): """ **Update the port forwarding rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-port-forwarding-rules - - networkId (string): (required) + - networkId (string): Network ID - rules (array): An array of port forwarding params """ kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'portForwardingRules'], - 'operation': 'updateNetworkApplianceFirewallPortForwardingRules' + "tags": ["appliance", "configure", "firewall", "portForwardingRules"], + "operation": "updateNetworkApplianceFirewallPortForwardingRules", } - resource = f'/networks/{networkId}/appliance/firewall/portForwardingRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/portForwardingRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallPortForwardingRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) - def getNetworkAppliancePorts(self, networkId: str): + def getNetworkApplianceFirewallSettings(self, networkId: str): """ - **List per-port VLAN settings for all ports of a MX.** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-ports + **Return the firewall settings for this network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-firewall-settings - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'ports'], - 'operation': 'getNetworkAppliancePorts' + "tags": ["appliance", "configure", "firewall", "settings"], + "operation": "getNetworkApplianceFirewallSettings", } - resource = f'/networks/{networkId}/appliance/ports' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/settings" return self._session.get(metadata, resource) - def getNetworkAppliancePort(self, networkId: str, portId: str): + def updateNetworkApplianceFirewallSettings(self, networkId: str, **kwargs): """ - **Return per-port VLAN settings for a single MX port.** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-port + **Update the firewall settings for this network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-settings - - networkId (string): (required) - - portId (string): (required) + - networkId (string): Network ID + - spoofingProtection (object): Spoofing protection settings """ + kwargs.update(locals()) + metadata = { - 'tags': ['appliance', 'configure', 'ports'], - 'operation': 'getNetworkAppliancePort' + "tags": ["appliance", "configure", "firewall", "settings"], + "operation": "updateNetworkApplianceFirewallSettings", } - resource = f'/networks/{networkId}/appliance/ports/{portId}' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/settings" - return self._session.get(metadata, resource) + body_params = [ + "spoofingProtection", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs): + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceFirewallSettings: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def createNetworkApplianceInterfacesL3(self, networkId: str, ipv4: dict, **kwargs): """ - **Update the per-port VLAN settings for a single MX port.** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-port + **Create wired L3 interface** + https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-interfaces-l-3 - - networkId (string): (required) - - portId (string): (required) - - enabled (boolean): The status of the port - - dropUntaggedTraffic (boolean): Trunk port can Drop all Untagged traffic. When true, no VLAN is required. Access ports cannot have dropUntaggedTraffic set to true. - - type (string): The type of the port: 'access' or 'trunk'. - - vlan (integer): Native VLAN when the port is in Trunk mode. Access VLAN when the port is in Access mode. - - allowedVlans (string): Comma-delimited list of the VLAN ID's allowed on the port, or 'all' to permit all VLAN's on the port. - - accessPolicy (string): The name of the policy. Only applicable to Access ports. Valid values are: 'open', '8021x-radius', 'mac-radius', 'hybris-radius' for MX64 or Z3 or any MX supporting the per port authentication feature. Otherwise, 'open' is the only valid value and 'open' is the default value if the field is missing. + - networkId (string): Network ID + - ipv4 (object): IPv4 configuration + - port (object): Port configuration """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'ports'], - 'operation': 'updateNetworkAppliancePort' + "tags": ["appliance", "configure", "interfaces", "l3"], + "operation": "createNetworkApplianceInterfacesL3", } - resource = f'/networks/{networkId}/appliance/ports/{portId}' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/interfaces/l3" - body_params = ['enabled', 'dropUntaggedTraffic', 'type', 'vlan', 'allowedVlans', 'accessPolicy', ] + body_params = [ + "port", + "ipv4", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createNetworkApplianceInterfacesL3: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) - def getNetworkApplianceSecurityEvents(self, networkId: str, total_pages=1, direction='next', **kwargs): + def updateNetworkApplianceInterfacesL3(self, networkId: str, interfaceId: str, **kwargs): """ - **List the security events for a network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-security-events + **Update wired L3 interface** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-interfaces-l-3 - - networkId (string): (required) - - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages - - direction (string): direction to paginate, either "next" (default) or "prev" page - - t0 (string): The beginning of the timespan for the data. Data is gathered after the specified t0 value. The maximum lookback period is 365 days from today. - - t1 (string): The end of the timespan for the data. t1 can be a maximum of 365 days after t0. - - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 365 days. The default is 31 days. - - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100. - - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. - - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. - - sortOrder (string): Sorted order of security events based on event detection time. Order options are 'ascending' or 'descending'. Default is ascending order. + - networkId (string): Network ID + - interfaceId (string): Interface ID + - port (object): Port configuration + - ipv4 (object): IPv4 configuration """ kwargs.update(locals()) - if 'sortOrder' in kwargs: - options = ['ascending', 'descending'] - assert kwargs['sortOrder'] in options, f'''"sortOrder" cannot be "{kwargs['sortOrder']}", & must be set to one of: {options}''' - metadata = { - 'tags': ['appliance', 'monitor', 'security', 'events'], - 'operation': 'getNetworkApplianceSecurityEvents' + "tags": ["appliance", "configure", "interfaces", "l3"], + "operation": "updateNetworkApplianceInterfacesL3", } - resource = f'/networks/{networkId}/appliance/security/events' + networkId = urllib.parse.quote(str(networkId), safe="") + interfaceId = urllib.parse.quote(str(interfaceId), safe="") + resource = f"/networks/{networkId}/appliance/interfaces/l3/{interfaceId}" + + body_params = [ + "port", + "ipv4", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - query_params = ['t0', 't1', 'timespan', 'perPage', 'startingAfter', 'endingBefore', 'sortOrder', ] - params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceInterfacesL3: ignoring unrecognized kwargs: {invalid}") - return self._session.get_pages(metadata, resource, params, total_pages, direction) + return self._session.put(metadata, resource, payload) - def getNetworkApplianceSecurityIntrusion(self, networkId: str): + def deleteNetworkApplianceInterfacesL3(self, networkId: str, interfaceId: str): """ - **Returns all supported intrusion settings for an MX network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-security-intrusion + **Delete wired L3 interface** + https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-interfaces-l-3 - - networkId (string): (required) + - networkId (string): Network ID + - interfaceId (string): Interface ID """ metadata = { - 'tags': ['appliance', 'configure', 'security', 'intrusion'], - 'operation': 'getNetworkApplianceSecurityIntrusion' + "tags": ["appliance", "configure", "interfaces", "l3"], + "operation": "deleteNetworkApplianceInterfacesL3", } - resource = f'/networks/{networkId}/appliance/security/intrusion' + networkId = urllib.parse.quote(str(networkId), safe="") + interfaceId = urllib.parse.quote(str(interfaceId), safe="") + resource = f"/networks/{networkId}/appliance/interfaces/l3/{interfaceId}" - return self._session.get(metadata, resource) + return self._session.delete(metadata, resource) - def updateNetworkApplianceSecurityIntrusion(self, networkId: str, **kwargs): + def getNetworkAppliancePorts(self, networkId: str): """ - **Set the supported intrusion settings for an MX network** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-security-intrusion + **List per-port VLAN settings for all ports of a secure router or security appliance.** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-ports - - networkId (string): (required) - - mode (string): Set mode to 'disabled'/'detection'/'prevention' (optional - omitting will leave current config unchanged) - - idsRulesets (string): Set the detection ruleset 'connectivity'/'balanced'/'security' (optional - omitting will leave current config unchanged). Default value is 'balanced' if none currently saved - - protectedNetworks (object): Set the included/excluded networks from the intrusion engine (optional - omitting will leave current config unchanged). This is available only in 'passthrough' mode + - networkId (string): Network ID """ - kwargs.update(locals()) - - if 'mode' in kwargs: - options = ['prevention', 'detection', 'disabled'] - assert kwargs['mode'] in options, f'''"mode" cannot be "{kwargs['mode']}", & must be set to one of: {options}''' - if 'idsRulesets' in kwargs: - options = ['connectivity', 'balanced', 'security'] - assert kwargs['idsRulesets'] in options, f'''"idsRulesets" cannot be "{kwargs['idsRulesets']}", & must be set to one of: {options}''' - metadata = { - 'tags': ['appliance', 'configure', 'security', 'intrusion'], - 'operation': 'updateNetworkApplianceSecurityIntrusion' + "tags": ["appliance", "configure", "ports"], + "operation": "getNetworkAppliancePorts", } - resource = f'/networks/{networkId}/appliance/security/intrusion' - - body_params = ['mode', 'idsRulesets', 'protectedNetworks', ] - payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/ports" - return self._session.put(metadata, resource, payload) + return self._session.get(metadata, resource) - def getNetworkApplianceSecurityMalware(self, networkId: str): + def getNetworkAppliancePort(self, networkId: str, portId: str): """ - **Returns all supported malware settings for an MX network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-security-malware + **Return per-port VLAN settings for a single secure router or security appliance port.** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-port - - networkId (string): (required) + - networkId (string): Network ID + - portId (string): Port ID """ metadata = { - 'tags': ['appliance', 'configure', 'security', 'malware'], - 'operation': 'getNetworkApplianceSecurityMalware' + "tags": ["appliance", "configure", "ports"], + "operation": "getNetworkAppliancePort", } - resource = f'/networks/{networkId}/appliance/security/malware' + networkId = urllib.parse.quote(str(networkId), safe="") + portId = urllib.parse.quote(str(portId), safe="") + resource = f"/networks/{networkId}/appliance/ports/{portId}" return self._session.get(metadata, resource) - def updateNetworkApplianceSecurityMalware(self, networkId: str, mode: str, **kwargs): + def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs): """ - **Set the supported malware settings for an MX network** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-security-malware + **Update the per-port VLAN settings for a single secure router or security appliance port.** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-port - - networkId (string): (required) - - mode (string): Set mode to 'enabled' to enable malware prevention, otherwise 'disabled' - - allowedUrls (array): The urls that should be permitted by the malware detection engine. If omitted, the current config will remain unchanged. This is available only if your network supports AMP allow listing - - allowedFiles (array): The sha256 digests of files that should be permitted by the malware detection engine. If omitted, the current config will remain unchanged. This is available only if your network supports AMP allow listing + - networkId (string): Network ID + - portId (string): Port ID + - enabled (boolean): The status of the port + - dropUntaggedTraffic (boolean): Trunk port can Drop all Untagged traffic. When true, no VLAN is required. Access ports cannot have dropUntaggedTraffic set to true. + - type (string): The type of the port: 'access' or 'trunk'. + - vlan (integer): Native VLAN when the port is in Trunk mode. Access VLAN when the port is in Access mode. + - allowedVlans (string): Comma-delimited list of VLAN IDs (e.g. '2,15') for all devices. Secure Routers also support VLAN ranges (e.g. '2-10,15'). Use 'all' to permit all VLANs on the port. + - accessPolicy (string): The name of the policy. Only applicable to Access ports. Valid values are: 'open', '8021x-radius', 'mac-radius', 'hybris-radius' for MX64 or Z3 or any MX supporting the per port authentication feature. Otherwise, 'open' is the only valid value and 'open' is the default value if the field is missing. + - sgt (object): Security Group Tag settings for the port. """ kwargs.update(locals()) - if 'mode' in kwargs: - options = ['enabled', 'disabled'] - assert kwargs['mode'] in options, f'''"mode" cannot be "{kwargs['mode']}", & must be set to one of: {options}''' - metadata = { - 'tags': ['appliance', 'configure', 'security', 'malware'], - 'operation': 'updateNetworkApplianceSecurityMalware' + "tags": ["appliance", "configure", "ports"], + "operation": "updateNetworkAppliancePort", } - resource = f'/networks/{networkId}/appliance/security/malware' - - body_params = ['mode', 'allowedUrls', 'allowedFiles', ] + networkId = urllib.parse.quote(str(networkId), safe="") + portId = urllib.parse.quote(str(portId), safe="") + resource = f"/networks/{networkId}/appliance/ports/{portId}" + + body_params = [ + "enabled", + "dropUntaggedTraffic", + "type", + "vlan", + "allowedVlans", + "accessPolicy", + "sgt", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkAppliancePort: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) - def getNetworkApplianceSingleLan(self, networkId: str): + def getNetworkAppliancePrefixesDelegatedStatics(self, networkId: str): """ - **Return single LAN configuration** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-single-lan + **List static delegated prefixes for a network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-prefixes-delegated-statics - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'singleLan'], - 'operation': 'getNetworkApplianceSingleLan' + "tags": ["appliance", "configure", "prefixes", "delegated", "statics"], + "operation": "getNetworkAppliancePrefixesDelegatedStatics", } - resource = f'/networks/{networkId}/appliance/singleLan' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics" return self._session.get(metadata, resource) - def updateNetworkApplianceSingleLan(self, networkId: str, **kwargs): + def createNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, prefix: str, origin: dict, **kwargs): """ - **Update single LAN configuration** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-single-lan + **Add a static delegated prefix from a network** + https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-prefixes-delegated-static - - networkId (string): (required) - - subnet (string): The subnet of the single LAN configuration - - applianceIp (string): The appliance IP address of the single LAN + - networkId (string): Network ID + - prefix (string): A static IPv6 prefix + - origin (object): The origin of the prefix + - description (string): A name or description for the prefix """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'singleLan'], - 'operation': 'updateNetworkApplianceSingleLan' + "tags": ["appliance", "configure", "prefixes", "delegated", "statics"], + "operation": "createNetworkAppliancePrefixesDelegatedStatic", } - resource = f'/networks/{networkId}/appliance/singleLan' - - body_params = ['subnet', 'applianceIp', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics" + + body_params = [ + "prefix", + "origin", + "description", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createNetworkAppliancePrefixesDelegatedStatic: ignoring unrecognized kwargs: {invalid}" + ) - def getNetworkApplianceStaticRoutes(self, networkId: str): - """ - **List the static routes for an MX or teleworker network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-static-routes + return self._session.post(metadata, resource, payload) + + def getNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDelegatedPrefixId: str): + """ + **Return a static delegated prefix from a network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-prefixes-delegated-static - - networkId (string): (required) + - networkId (string): Network ID + - staticDelegatedPrefixId (string): Static delegated prefix ID """ metadata = { - 'tags': ['appliance', 'configure', 'staticRoutes'], - 'operation': 'getNetworkApplianceStaticRoutes' + "tags": ["appliance", "configure", "prefixes", "delegated", "statics"], + "operation": "getNetworkAppliancePrefixesDelegatedStatic", } - resource = f'/networks/{networkId}/appliance/staticRoutes' + networkId = urllib.parse.quote(str(networkId), safe="") + staticDelegatedPrefixId = urllib.parse.quote(str(staticDelegatedPrefixId), safe="") + resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics/{staticDelegatedPrefixId}" return self._session.get(metadata, resource) - def createNetworkApplianceStaticRoute(self, networkId: str, name: str, subnet: str, gatewayIp: str): + def updateNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDelegatedPrefixId: str, **kwargs): """ - **Add a static route for an MX or teleworker network** - https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-static-route + **Update a static delegated prefix from a network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-prefixes-delegated-static - - networkId (string): (required) - - name (string): The name of the new static route - - subnet (string): The subnet of the static route - - gatewayIp (string): The gateway IP (next hop) of the static route + - networkId (string): Network ID + - staticDelegatedPrefixId (string): Static delegated prefix ID + - prefix (string): A static IPv6 prefix + - origin (object): The origin of the prefix + - description (string): A name or description for the prefix """ - kwargs = locals() + kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'staticRoutes'], - 'operation': 'createNetworkApplianceStaticRoute' + "tags": ["appliance", "configure", "prefixes", "delegated", "statics"], + "operation": "updateNetworkAppliancePrefixesDelegatedStatic", } - resource = f'/networks/{networkId}/appliance/staticRoutes' - - body_params = ['name', 'subnet', 'gatewayIp', ] + networkId = urllib.parse.quote(str(networkId), safe="") + staticDelegatedPrefixId = urllib.parse.quote(str(staticDelegatedPrefixId), safe="") + resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics/{staticDelegatedPrefixId}" + + body_params = [ + "prefix", + "origin", + "description", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkAppliancePrefixesDelegatedStatic: ignoring unrecognized kwargs: {invalid}" + ) - def getNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str): + return self._session.put(metadata, resource, payload) + + def deleteNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDelegatedPrefixId: str): """ - **Return a static route for an MX or teleworker network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-static-route + **Delete a static delegated prefix from a network** + https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-prefixes-delegated-static - - networkId (string): (required) - - staticRouteId (string): (required) + - networkId (string): Network ID + - staticDelegatedPrefixId (string): Static delegated prefix ID """ metadata = { - 'tags': ['appliance', 'configure', 'staticRoutes'], - 'operation': 'getNetworkApplianceStaticRoute' + "tags": ["appliance", "configure", "prefixes", "delegated", "statics"], + "operation": "deleteNetworkAppliancePrefixesDelegatedStatic", } - resource = f'/networks/{networkId}/appliance/staticRoutes/{staticRouteId}' + networkId = urllib.parse.quote(str(networkId), safe="") + staticDelegatedPrefixId = urllib.parse.quote(str(staticDelegatedPrefixId), safe="") + resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics/{staticDelegatedPrefixId}" + + return self._session.delete(metadata, resource) + + def getNetworkApplianceRfProfiles(self, networkId: str): + """ + **List the RF profiles for this network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-rf-profiles + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "rfProfiles"], + "operation": "getNetworkApplianceRfProfiles", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/rfProfiles" return self._session.get(metadata, resource) - def updateNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str, **kwargs): + def createNetworkApplianceRfProfile(self, networkId: str, name: str, **kwargs): """ - **Update a static route for an MX or teleworker network** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-static-route + **Creates new RF profile for this network** + https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-rf-profile - - networkId (string): (required) - - staticRouteId (string): (required) - - name (string): The name of the static route - - subnet (string): The subnet of the static route - - gatewayIp (string): The gateway IP (next hop) of the static route - - enabled (boolean): The enabled state of the static route - - fixedIpAssignments (object): The DHCP fixed IP assignments on the static route. This should be an object that contains mappings from MAC addresses to objects that themselves each contain "ip" and "name" string fields. See the sample request/response for more details. - - reservedIpRanges (array): The DHCP reserved IP ranges on the static route + - networkId (string): Network ID + - name (string): The name of the new profile. Must be unique. This param is required on creation. + - twoFourGhzSettings (object): Settings related to 2.4Ghz band + - fiveGhzSettings (object): Settings related to 5Ghz band + - perSsidSettings (object): Per-SSID radio settings by number. """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'staticRoutes'], - 'operation': 'updateNetworkApplianceStaticRoute' + "tags": ["appliance", "configure", "rfProfiles"], + "operation": "createNetworkApplianceRfProfile", } - resource = f'/networks/{networkId}/appliance/staticRoutes/{staticRouteId}' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/rfProfiles" + + body_params = [ + "name", + "twoFourGhzSettings", + "fiveGhzSettings", + "perSsidSettings", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createNetworkApplianceRfProfile: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) - body_params = ['name', 'subnet', 'gatewayIp', 'enabled', 'fixedIpAssignments', 'reservedIpRanges', ] + def updateNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str, **kwargs): + """ + **Updates specified RF profile for this network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-rf-profile + + - networkId (string): Network ID + - rfProfileId (string): Rf profile ID + - name (string): The name of the new profile. Must be unique. + - twoFourGhzSettings (object): Settings related to 2.4Ghz band + - fiveGhzSettings (object): Settings related to 5Ghz band + - perSsidSettings (object): Per-SSID radio settings by number. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "rfProfiles"], + "operation": "updateNetworkApplianceRfProfile", + } + networkId = urllib.parse.quote(str(networkId), safe="") + rfProfileId = urllib.parse.quote(str(rfProfileId), safe="") + resource = f"/networks/{networkId}/appliance/rfProfiles/{rfProfileId}" + + body_params = [ + "name", + "twoFourGhzSettings", + "fiveGhzSettings", + "perSsidSettings", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceRfProfile: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) - def deleteNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str): + def deleteNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str): """ - **Delete a static route from an MX or teleworker network** - https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-static-route + **Delete a RF Profile** + https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-rf-profile - - networkId (string): (required) - - staticRouteId (string): (required) + - networkId (string): Network ID + - rfProfileId (string): Rf profile ID """ metadata = { - 'tags': ['appliance', 'configure', 'staticRoutes'], - 'operation': 'deleteNetworkApplianceStaticRoute' + "tags": ["appliance", "configure", "rfProfiles"], + "operation": "deleteNetworkApplianceRfProfile", } - resource = f'/networks/{networkId}/appliance/staticRoutes/{staticRouteId}' + networkId = urllib.parse.quote(str(networkId), safe="") + rfProfileId = urllib.parse.quote(str(rfProfileId), safe="") + resource = f"/networks/{networkId}/appliance/rfProfiles/{rfProfileId}" return self._session.delete(metadata, resource) - def getNetworkApplianceTrafficShaping(self, networkId: str): + def getNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str): """ - **Display the traffic shaping settings for an MX network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping + **Return a RF profile** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-rf-profile - - networkId (string): (required) + - networkId (string): Network ID + - rfProfileId (string): Rf profile ID """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping'], - 'operation': 'getNetworkApplianceTrafficShaping' + "tags": ["appliance", "configure", "rfProfiles"], + "operation": "getNetworkApplianceRfProfile", } - resource = f'/networks/{networkId}/appliance/trafficShaping' + networkId = urllib.parse.quote(str(networkId), safe="") + rfProfileId = urllib.parse.quote(str(rfProfileId), safe="") + resource = f"/networks/{networkId}/appliance/rfProfiles/{rfProfileId}" return self._session.get(metadata, resource) - def updateNetworkApplianceTrafficShaping(self, networkId: str, **kwargs): + def updateNetworkApplianceSdwanInternetPolicies(self, networkId: str, **kwargs): """ - **Update the traffic shaping settings for an MX network** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping + **Update SDWAN internet traffic preferences for an MX network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-sdwan-internet-policies - - networkId (string): (required) - - globalBandwidthLimits (object): Global per-client bandwidth limit + - networkId (string): Network ID + - wanTrafficUplinkPreferences (array): policies with respective traffic filters for an MX network """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping'], - 'operation': 'updateNetworkApplianceTrafficShaping' + "tags": ["appliance", "configure", "sdwan", "internetPolicies"], + "operation": "updateNetworkApplianceSdwanInternetPolicies", } - resource = f'/networks/{networkId}/appliance/trafficShaping' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/sdwan/internetPolicies" - body_params = ['globalBandwidthLimits', ] + body_params = [ + "wanTrafficUplinkPreferences", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceSdwanInternetPolicies: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) - def getNetworkApplianceTrafficShapingCustomPerformanceClasses(self, networkId: str): + def getNetworkApplianceSecurityEvents(self, networkId: str, total_pages=1, direction="next", **kwargs): """ - **List all custom performance classes for an MX network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping-custom-performance-classes + **List the security events for a network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-security-events + + - networkId (string): Network ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - t0 (string): The beginning of the timespan for the data. Data is gathered after the specified t0 value. The maximum lookback period is 365 days from today. + - t1 (string): The end of the timespan for the data. t1 can be a maximum of 365 days after t0. + - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 365 days. The default is 31 days. + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - sortOrder (string): Sorted order of security events based on event detection time. Order options are 'ascending' or 'descending'. Default is ascending order. + """ + + kwargs.update(locals()) + + if "sortOrder" in kwargs: + options = ["ascending", "descending"] + assert kwargs["sortOrder"] in options, ( + f'''"sortOrder" cannot be "{kwargs["sortOrder"]}", & must be set to one of: {options}''' + ) + + metadata = { + "tags": ["appliance", "monitor", "security", "events"], + "operation": "getNetworkApplianceSecurityEvents", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/security/events" + + query_params = [ + "t0", + "t1", + "timespan", + "perPage", + "startingAfter", + "endingBefore", + "sortOrder", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getNetworkApplianceSecurityEvents: ignoring unrecognized kwargs: {invalid}") + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getNetworkApplianceSecurityIntrusion(self, networkId: str): + """ + **Returns all supported intrusion settings for an MX network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-security-intrusion - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'customPerformanceClasses'], - 'operation': 'getNetworkApplianceTrafficShapingCustomPerformanceClasses' + "tags": ["appliance", "configure", "security", "intrusion"], + "operation": "getNetworkApplianceSecurityIntrusion", } - resource = f'/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/security/intrusion" return self._session.get(metadata, resource) - def createNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, name: str, **kwargs): + def updateNetworkApplianceSecurityIntrusion(self, networkId: str, **kwargs): """ - **Add a custom performance class for an MX network** - https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-traffic-shaping-custom-performance-class + **Set the supported intrusion settings for an MX network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-security-intrusion - - networkId (string): (required) - - name (string): Name of the custom performance class - - maxLatency (integer): Maximum latency in milliseconds - - maxJitter (integer): Maximum jitter in milliseconds - - maxLossPercentage (integer): Maximum percentage of packet loss + - networkId (string): Network ID + - mode (string): Set mode to 'disabled'/'detection'/'prevention' (optional - omitting will leave current config unchanged) + - idsRulesets (string): Set the detection ruleset 'connectivity'/'balanced'/'security' (optional - omitting will leave current config unchanged). Default value is 'balanced' if none currently saved + - protectedNetworks (object): Set the included/excluded networks from the intrusion engine (optional - omitting will leave current config unchanged). This is available only in 'passthrough' mode """ kwargs.update(locals()) + if "mode" in kwargs: + options = ["detection", "disabled", "prevention"] + assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}''' + if "idsRulesets" in kwargs: + options = ["balanced", "connectivity", "security"] + assert kwargs["idsRulesets"] in options, ( + f'''"idsRulesets" cannot be "{kwargs["idsRulesets"]}", & must be set to one of: {options}''' + ) + metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'customPerformanceClasses'], - 'operation': 'createNetworkApplianceTrafficShapingCustomPerformanceClass' + "tags": ["appliance", "configure", "security", "intrusion"], + "operation": "updateNetworkApplianceSecurityIntrusion", } - resource = f'/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses' - - body_params = ['name', 'maxLatency', 'maxJitter', 'maxLossPercentage', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/security/intrusion" + + body_params = [ + "mode", + "idsRulesets", + "protectedNetworks", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceSecurityIntrusion: ignoring unrecognized kwargs: {invalid}" + ) - def getNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, customPerformanceClassId: str): + return self._session.put(metadata, resource, payload) + + def getNetworkApplianceSecurityMalware(self, networkId: str): """ - **Return a custom performance class for an MX network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping-custom-performance-class + **Returns all supported malware settings for an MX network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-security-malware - - networkId (string): (required) - - customPerformanceClassId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'customPerformanceClasses'], - 'operation': 'getNetworkApplianceTrafficShapingCustomPerformanceClass' + "tags": ["appliance", "configure", "security", "malware"], + "operation": "getNetworkApplianceSecurityMalware", } - resource = f'/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/security/malware" return self._session.get(metadata, resource) - def updateNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, customPerformanceClassId: str, **kwargs): + def updateNetworkApplianceSecurityMalware(self, networkId: str, mode: str, **kwargs): """ - **Update a custom performance class for an MX network** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-custom-performance-class + **Set the supported malware settings for an MX network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-security-malware - - networkId (string): (required) - - customPerformanceClassId (string): (required) - - name (string): Name of the custom performance class - - maxLatency (integer): Maximum latency in milliseconds - - maxJitter (integer): Maximum jitter in milliseconds - - maxLossPercentage (integer): Maximum percentage of packet loss + - networkId (string): Network ID + - mode (string): Set mode to 'enabled' to enable malware prevention, otherwise 'disabled' + - allowedUrls (array): The urls that should be permitted by the malware detection engine. If omitted, the current config will remain unchanged. This is available only if your network supports AMP allow listing + - allowedFiles (array): The sha256 digests of files that should be permitted by the malware detection engine. If omitted, the current config will remain unchanged. This is available only if your network supports AMP allow listing """ kwargs.update(locals()) + if "mode" in kwargs: + options = ["disabled", "enabled"] + assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}''' + metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'customPerformanceClasses'], - 'operation': 'updateNetworkApplianceTrafficShapingCustomPerformanceClass' + "tags": ["appliance", "configure", "security", "malware"], + "operation": "updateNetworkApplianceSecurityMalware", } - resource = f'/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}' - - body_params = ['name', 'maxLatency', 'maxJitter', 'maxLossPercentage', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/security/malware" + + body_params = [ + "mode", + "allowedUrls", + "allowedFiles", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceSecurityMalware: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) - def deleteNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, customPerformanceClassId: str): + def getNetworkApplianceSettings(self, networkId: str): """ - **Delete a custom performance class from an MX network** - https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-traffic-shaping-custom-performance-class + **Return the appliance settings for a network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-settings - - networkId (string): (required) - - customPerformanceClassId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'customPerformanceClasses'], - 'operation': 'deleteNetworkApplianceTrafficShapingCustomPerformanceClass' + "tags": ["appliance", "configure", "settings"], + "operation": "getNetworkApplianceSettings", } - resource = f'/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/settings" - return self._session.delete(metadata, resource) + return self._session.get(metadata, resource) - def updateNetworkApplianceTrafficShapingRules(self, networkId: str, **kwargs): + def updateNetworkApplianceSettings(self, networkId: str, **kwargs): """ - **Update the traffic shaping settings rules for an MX network** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-rules - - - networkId (string): (required) - - defaultRulesEnabled (boolean): Whether default traffic shaping rules are enabled (true) or disabled (false). - There are 4 default rules, which can - be seen on your network's traffic shaping page. Note that default rules - count against the rule limit of 8. - - - rules (array): An array of traffic shaping rules. Rules are applied in the order that - they are specified in. An empty list (or null) means no rules. Note that - you are allowed a maximum of 8 rules. + **Update the appliance settings for a network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-settings + - networkId (string): Network ID + - clientTrackingMethod (string): Client tracking method of a network + - deploymentMode (string): Deployment mode of a network + - dynamicDns (object): Dynamic DNS settings for a network """ kwargs.update(locals()) + if "clientTrackingMethod" in kwargs: + options = ["IP address", "MAC address", "Unique client identifier"] + assert kwargs["clientTrackingMethod"] in options, ( + f'''"clientTrackingMethod" cannot be "{kwargs["clientTrackingMethod"]}", & must be set to one of: {options}''' + ) + if "deploymentMode" in kwargs: + options = ["passthrough", "routed"] + assert kwargs["deploymentMode"] in options, ( + f'''"deploymentMode" cannot be "{kwargs["deploymentMode"]}", & must be set to one of: {options}''' + ) + metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'rules'], - 'operation': 'updateNetworkApplianceTrafficShapingRules' + "tags": ["appliance", "configure", "settings"], + "operation": "updateNetworkApplianceSettings", } - resource = f'/networks/{networkId}/appliance/trafficShaping/rules' - - body_params = ['defaultRulesEnabled', 'rules', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/settings" + + body_params = [ + "clientTrackingMethod", + "deploymentMode", + "dynamicDns", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceSettings: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) - def getNetworkApplianceTrafficShapingRules(self, networkId: str): + def getNetworkApplianceSingleLan(self, networkId: str): """ - **Display the traffic shaping settings rules for an MX network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping-rules + **Return single LAN configuration** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-single-lan - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'rules'], - 'operation': 'getNetworkApplianceTrafficShapingRules' + "tags": ["appliance", "configure", "singleLan"], + "operation": "getNetworkApplianceSingleLan", } - resource = f'/networks/{networkId}/appliance/trafficShaping/rules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/singleLan" return self._session.get(metadata, resource) - def getNetworkApplianceTrafficShapingUplinkBandwidth(self, networkId: str): + def updateNetworkApplianceSingleLan(self, networkId: str, **kwargs): """ - **Returns the uplink bandwidth settings for your MX network.** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping-uplink-bandwidth + **Update single LAN configuration** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-single-lan - - networkId (string): (required) + - networkId (string): Network ID + - subnet (string): The subnet of the single LAN configuration + - applianceIp (string): The appliance IP address of the single LAN + - ipv6 (object): IPv6 configuration on the VLAN + - mandatoryDhcp (object): Mandatory DHCP will enforce that clients connecting to this LAN must use the IP address assigned by the DHCP server. Clients who use a static IP address won't be able to associate. Only available on firmware versions 17.0 and above """ + kwargs.update(locals()) + metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'uplinkBandwidth'], - 'operation': 'getNetworkApplianceTrafficShapingUplinkBandwidth' + "tags": ["appliance", "configure", "singleLan"], + "operation": "updateNetworkApplianceSingleLan", } - resource = f'/networks/{networkId}/appliance/trafficShaping/uplinkBandwidth' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/singleLan" + + body_params = [ + "subnet", + "applianceIp", + "ipv6", + "mandatoryDhcp", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.get(metadata, resource) + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceSingleLan: ignoring unrecognized kwargs: {invalid}") - def updateNetworkApplianceTrafficShapingUplinkBandwidth(self, networkId: str, **kwargs): - """ - **Updates the uplink bandwidth settings for your MX network.** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-uplink-bandwidth + return self._session.put(metadata, resource, payload) - - networkId (string): (required) - - bandwidthLimits (object): A mapping of uplinks to their bandwidth settings (be sure to check which uplinks are supported for your network) + def getNetworkApplianceSsids(self, networkId: str): """ + **List the MX SSIDs in a network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-ssids - kwargs.update(locals()) + - networkId (string): Network ID + """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'uplinkBandwidth'], - 'operation': 'updateNetworkApplianceTrafficShapingUplinkBandwidth' + "tags": ["appliance", "configure", "ssids"], + "operation": "getNetworkApplianceSsids", } - resource = f'/networks/{networkId}/appliance/trafficShaping/uplinkBandwidth' - - body_params = ['bandwidthLimits', ] - payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/ssids" - return self._session.put(metadata, resource, payload) + return self._session.get(metadata, resource) - def getNetworkApplianceTrafficShapingUplinkSelection(self, networkId: str): + def getNetworkApplianceSsid(self, networkId: str, number: str): """ - **Show uplink selection settings for an MX network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping-uplink-selection + **Return a single MX SSID** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-ssid - - networkId (string): (required) + - networkId (string): Network ID + - number (string): Number """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'uplinkSelection'], - 'operation': 'getNetworkApplianceTrafficShapingUplinkSelection' + "tags": ["appliance", "configure", "ssids"], + "operation": "getNetworkApplianceSsid", } - resource = f'/networks/{networkId}/appliance/trafficShaping/uplinkSelection' + networkId = urllib.parse.quote(str(networkId), safe="") + number = urllib.parse.quote(str(number), safe="") + resource = f"/networks/{networkId}/appliance/ssids/{number}" return self._session.get(metadata, resource) - def updateNetworkApplianceTrafficShapingUplinkSelection(self, networkId: str, **kwargs): + def updateNetworkApplianceSsid(self, networkId: str, number: str, **kwargs): """ - **Update uplink selection settings for an MX network** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-uplink-selection + **Update the attributes of an MX SSID** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-ssid - - networkId (string): (required) - - activeActiveAutoVpnEnabled (boolean): Toggle for enabling or disabling active-active AutoVPN - - defaultUplink (string): The default uplink. Must be one of: 'wan1' or 'wan2' - - loadBalancingEnabled (boolean): Toggle for enabling or disabling load balancing - - wanTrafficUplinkPreferences (array): Array of uplink preference rules for WAN traffic - - vpnTrafficUplinkPreferences (array): Array of uplink preference rules for VPN traffic + - networkId (string): Network ID + - number (string): Number + - name (string): The name of the SSID. + - enabled (boolean): Whether or not the SSID is enabled. + - defaultVlanId (integer): The VLAN ID of the VLAN associated to this SSID. This parameter is only valid if the network is in routed mode. + - authMode (string): The association control method for the SSID ('open', 'psk', '8021x-meraki' or '8021x-radius'). + - psk (string): The passkey for the SSID. This param is only valid if the authMode is 'psk'. + - radiusServers (array): The RADIUS 802.1x servers to be used for authentication. This param is only valid if the authMode is '8021x-radius'. + - encryptionMode (string): The psk encryption mode for the SSID ('wep' or 'wpa'). This param is only valid if the authMode is 'psk'. + - wpaEncryptionMode (string): The types of WPA encryption. ('WPA1 and WPA2', 'WPA2 only', 'WPA3 Transition Mode' or 'WPA3 only'). This param is only valid if (1) the authMode is 'psk' & the encryptionMode is 'wpa' OR (2) the authMode is '8021x-meraki' OR (3) the authMode is '8021x-radius' + - visible (boolean): Boolean indicating whether the MX should advertise or hide this SSID. + - dhcpEnforcedDeauthentication (object): DHCP Enforced Deauthentication enables the disassociation of wireless clients in addition to Mandatory DHCP. This param is only valid on firmware versions >= MX 17.0 where the associated LAN has Mandatory DHCP Enabled + - dot11w (object): The current setting for Protected Management Frames (802.11w). """ kwargs.update(locals()) - if 'defaultUplink' in kwargs: - options = ['wan1', 'wan2'] - assert kwargs['defaultUplink'] in options, f'''"defaultUplink" cannot be "{kwargs['defaultUplink']}", & must be set to one of: {options}''' + if "authMode" in kwargs: + options = ["8021x-meraki", "8021x-radius", "open", "psk"] + assert kwargs["authMode"] in options, ( + f'''"authMode" cannot be "{kwargs["authMode"]}", & must be set to one of: {options}''' + ) + if "encryptionMode" in kwargs: + options = ["wep", "wpa"] + assert kwargs["encryptionMode"] in options, ( + f'''"encryptionMode" cannot be "{kwargs["encryptionMode"]}", & must be set to one of: {options}''' + ) + if "wpaEncryptionMode" in kwargs: + options = ["WPA1 and WPA2", "WPA2 only", "WPA3 Transition Mode", "WPA3 only"] + assert kwargs["wpaEncryptionMode"] in options, ( + f'''"wpaEncryptionMode" cannot be "{kwargs["wpaEncryptionMode"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'uplinkSelection'], - 'operation': 'updateNetworkApplianceTrafficShapingUplinkSelection' + "tags": ["appliance", "configure", "ssids"], + "operation": "updateNetworkApplianceSsid", } - resource = f'/networks/{networkId}/appliance/trafficShaping/uplinkSelection' - - body_params = ['activeActiveAutoVpnEnabled', 'defaultUplink', 'loadBalancingEnabled', 'wanTrafficUplinkPreferences', 'vpnTrafficUplinkPreferences', ] + networkId = urllib.parse.quote(str(networkId), safe="") + number = urllib.parse.quote(str(number), safe="") + resource = f"/networks/{networkId}/appliance/ssids/{number}" + + body_params = [ + "name", + "enabled", + "defaultVlanId", + "authMode", + "psk", + "radiusServers", + "encryptionMode", + "wpaEncryptionMode", + "visible", + "dhcpEnforcedDeauthentication", + "dot11w", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceSsid: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) - def getNetworkApplianceVlans(self, networkId: str): + def getNetworkApplianceStaticRoutes(self, networkId: str): """ - **List the VLANs for an MX network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vlans + **List the static routes for an MX or teleworker network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-static-routes - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'vlans'], - 'operation': 'getNetworkApplianceVlans' + "tags": ["appliance", "configure", "staticRoutes"], + "operation": "getNetworkApplianceStaticRoutes", } - resource = f'/networks/{networkId}/appliance/vlans' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/staticRoutes" return self._session.get(metadata, resource) - def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, subnet: str, applianceIp: str, **kwargs): + def createNetworkApplianceStaticRoute(self, networkId: str, name: str, subnet: str, gatewayIp: str, **kwargs): """ - **Add a VLAN** - https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-vlan + **Add a static route for an MX or teleworker network** + https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-static-route - - networkId (string): (required) - - id (string): The VLAN ID of the new VLAN (must be between 1 and 4094) - - name (string): The name of the new VLAN - - subnet (string): The subnet of the VLAN - - applianceIp (string): The local IP of the appliance on the VLAN - - groupPolicyId (string): The id of the desired group policy to apply to the VLAN + - networkId (string): Network ID + - name (string): Name of the route + - subnet (string): Subnet of the route + - gatewayIp (string): Gateway IP address (next hop) + - gatewayVlanId (integer): Gateway VLAN ID """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'vlans'], - 'operation': 'createNetworkApplianceVlan' + "tags": ["appliance", "configure", "staticRoutes"], + "operation": "createNetworkApplianceStaticRoute", } - resource = f'/networks/{networkId}/appliance/vlans' - - body_params = ['id', 'name', 'subnet', 'applianceIp', 'groupPolicyId', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/staticRoutes" + + body_params = [ + "name", + "subnet", + "gatewayIp", + "gatewayVlanId", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createNetworkApplianceStaticRoute: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) - def getNetworkApplianceVlansSettings(self, networkId: str): + def getNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str): """ - **Returns the enabled status of VLANs for the network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vlans-settings + **Return a static route for an MX or teleworker network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-static-route - - networkId (string): (required) + - networkId (string): Network ID + - staticRouteId (string): Static route ID """ metadata = { - 'tags': ['appliance', 'configure', 'vlans', 'settings'], - 'operation': 'getNetworkApplianceVlansSettings' + "tags": ["appliance", "configure", "staticRoutes"], + "operation": "getNetworkApplianceStaticRoute", } - resource = f'/networks/{networkId}/appliance/vlans/settings' + networkId = urllib.parse.quote(str(networkId), safe="") + staticRouteId = urllib.parse.quote(str(staticRouteId), safe="") + resource = f"/networks/{networkId}/appliance/staticRoutes/{staticRouteId}" return self._session.get(metadata, resource) - def updateNetworkApplianceVlansSettings(self, networkId: str, **kwargs): + def updateNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str, **kwargs): """ - **Enable/Disable VLANs for the given network** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-vlans-settings + **Update a static route for an MX or teleworker network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-static-route - - networkId (string): (required) - - vlansEnabled (boolean): Boolean indicating whether to enable (true) or disable (false) VLANs for the network + - networkId (string): Network ID + - staticRouteId (string): Static route ID + - name (string): Name of the route + - subnet (string): Subnet of the route + - gatewayIp (string): Gateway IP address (next hop) + - gatewayVlanId (integer): Gateway VLAN ID + - enabled (boolean): Whether the route should be enabled or not + - fixedIpAssignments (object): Fixed DHCP IP assignments on the route + - reservedIpRanges (array): DHCP reserved IP ranges """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'vlans', 'settings'], - 'operation': 'updateNetworkApplianceVlansSettings' + "tags": ["appliance", "configure", "staticRoutes"], + "operation": "updateNetworkApplianceStaticRoute", } - resource = f'/networks/{networkId}/appliance/vlans/settings' - - body_params = ['vlansEnabled', ] + networkId = urllib.parse.quote(str(networkId), safe="") + staticRouteId = urllib.parse.quote(str(staticRouteId), safe="") + resource = f"/networks/{networkId}/appliance/staticRoutes/{staticRouteId}" + + body_params = [ + "name", + "subnet", + "gatewayIp", + "gatewayVlanId", + "enabled", + "fixedIpAssignments", + "reservedIpRanges", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceStaticRoute: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) - def getNetworkApplianceVlan(self, networkId: str, vlanId: str): + def deleteNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str): """ - **Return a VLAN** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vlan + **Delete a static route from an MX or teleworker network** + https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-static-route - - networkId (string): (required) - - vlanId (string): (required) + - networkId (string): Network ID + - staticRouteId (string): Static route ID """ metadata = { - 'tags': ['appliance', 'configure', 'vlans'], - 'operation': 'getNetworkApplianceVlan' + "tags": ["appliance", "configure", "staticRoutes"], + "operation": "deleteNetworkApplianceStaticRoute", } - resource = f'/networks/{networkId}/appliance/vlans/{vlanId}' + networkId = urllib.parse.quote(str(networkId), safe="") + staticRouteId = urllib.parse.quote(str(staticRouteId), safe="") + resource = f"/networks/{networkId}/appliance/staticRoutes/{staticRouteId}" - return self._session.get(metadata, resource) + return self._session.delete(metadata, resource) - def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs): + def getNetworkApplianceTrafficShaping(self, networkId: str): """ - **Update a VLAN** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-vlan + **Display the traffic shaping settings for an MX network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping - - networkId (string): (required) - - vlanId (string): (required) - - name (string): The name of the VLAN - - subnet (string): The subnet of the VLAN - - applianceIp (string): The local IP of the appliance on the VLAN - - groupPolicyId (string): The id of the desired group policy to apply to the VLAN - - vpnNatSubnet (string): The translated VPN subnet if VPN and VPN subnet translation are enabled on the VLAN - - dhcpHandling (string): The appliance's handling of DHCP requests on this VLAN. One of: 'Run a DHCP server', 'Relay DHCP to another server' or 'Do not respond to DHCP requests' - - dhcpRelayServerIps (array): The IPs of the DHCP servers that DHCP requests should be relayed to - - dhcpLeaseTime (string): The term of DHCP leases if the appliance is running a DHCP server on this VLAN. One of: '30 minutes', '1 hour', '4 hours', '12 hours', '1 day' or '1 week' - - dhcpBootOptionsEnabled (boolean): Use DHCP boot options specified in other properties - - dhcpBootNextServer (string): DHCP boot option to direct boot clients to the server to load the boot file from - - dhcpBootFilename (string): DHCP boot option for boot filename - - fixedIpAssignments (object): The DHCP fixed IP assignments on the VLAN. This should be an object that contains mappings from MAC addresses to objects that themselves each contain "ip" and "name" string fields. See the sample request/response for more details. - - reservedIpRanges (array): The DHCP reserved IP ranges on the VLAN - - dnsNameservers (string): The DNS nameservers used for DHCP responses, either "upstream_dns", "google_dns", "opendns", or a newline seperated string of IP addresses or domain names - - dhcpOptions (array): The list of DHCP options that will be included in DHCP responses. Each object in the list should have "code", "type", and "value" properties. + - networkId (string): Network ID """ - kwargs.update(locals()) + metadata = { + "tags": ["appliance", "configure", "trafficShaping"], + "operation": "getNetworkApplianceTrafficShaping", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping" + + return self._session.get(metadata, resource) + + def updateNetworkApplianceTrafficShaping(self, networkId: str, globalBandwidthLimits: dict, **kwargs): + """ + **Update the traffic shaping settings for an MX network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping + + - networkId (string): Network ID + - globalBandwidthLimits (object): Global per-client bandwidth limit + """ - if 'dhcpHandling' in kwargs: - options = ['Run a DHCP server', 'Relay DHCP to another server', 'Do not respond to DHCP requests'] - assert kwargs['dhcpHandling'] in options, f'''"dhcpHandling" cannot be "{kwargs['dhcpHandling']}", & must be set to one of: {options}''' - if 'dhcpLeaseTime' in kwargs: - options = ['30 minutes', '1 hour', '4 hours', '12 hours', '1 day', '1 week'] - assert kwargs['dhcpLeaseTime'] in options, f'''"dhcpLeaseTime" cannot be "{kwargs['dhcpLeaseTime']}", & must be set to one of: {options}''' + kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'vlans'], - 'operation': 'updateNetworkApplianceVlan' + "tags": ["appliance", "configure", "trafficShaping"], + "operation": "updateNetworkApplianceTrafficShaping", } - resource = f'/networks/{networkId}/appliance/vlans/{vlanId}' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping" - body_params = ['name', 'subnet', 'applianceIp', 'groupPolicyId', 'vpnNatSubnet', 'dhcpHandling', 'dhcpRelayServerIps', 'dhcpLeaseTime', 'dhcpBootOptionsEnabled', 'dhcpBootNextServer', 'dhcpBootFilename', 'fixedIpAssignments', 'reservedIpRanges', 'dnsNameservers', 'dhcpOptions', ] + body_params = [ + "globalBandwidthLimits", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceTrafficShaping: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) - def deleteNetworkApplianceVlan(self, networkId: str, vlanId: str): + def getNetworkApplianceTrafficShapingCustomPerformanceClasses(self, networkId: str): """ - **Delete a VLAN from a network** - https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-vlan + **List all custom performance classes for an MX network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping-custom-performance-classes - - networkId (string): (required) - - vlanId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'vlans'], - 'operation': 'deleteNetworkApplianceVlan' + "tags": ["appliance", "configure", "trafficShaping", "customPerformanceClasses"], + "operation": "getNetworkApplianceTrafficShapingCustomPerformanceClasses", } - resource = f'/networks/{networkId}/appliance/vlans/{vlanId}' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses" - return self._session.delete(metadata, resource) + return self._session.get(metadata, resource) - def getNetworkApplianceVpnSiteToSiteVpn(self, networkId: str): + def createNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, name: str, **kwargs): """ - **Return the site-to-site VPN settings of a network. Only valid for MX networks.** + **Add a custom performance class for an MX network** + https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-traffic-shaping-custom-performance-class + + - networkId (string): Network ID + - name (string): Name of the custom performance class + - maxLatency (integer): Maximum latency in milliseconds + - maxJitter (integer): Maximum jitter in milliseconds + - maxLossPercentage (integer): Maximum percentage of packet loss + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "customPerformanceClasses"], + "operation": "createNetworkApplianceTrafficShapingCustomPerformanceClass", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses" + + body_params = [ + "name", + "maxLatency", + "maxJitter", + "maxLossPercentage", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createNetworkApplianceTrafficShapingCustomPerformanceClass: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, customPerformanceClassId: str): + """ + **Return a custom performance class for an MX network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping-custom-performance-class + + - networkId (string): Network ID + - customPerformanceClassId (string): Custom performance class ID + """ + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "customPerformanceClasses"], + "operation": "getNetworkApplianceTrafficShapingCustomPerformanceClass", + } + networkId = urllib.parse.quote(str(networkId), safe="") + customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}" + + return self._session.get(metadata, resource) + + def updateNetworkApplianceTrafficShapingCustomPerformanceClass( + self, networkId: str, customPerformanceClassId: str, **kwargs + ): + """ + **Update a custom performance class for an MX network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-custom-performance-class + + - networkId (string): Network ID + - customPerformanceClassId (string): Custom performance class ID + - name (string): Name of the custom performance class + - maxLatency (integer): Maximum latency in milliseconds + - maxJitter (integer): Maximum jitter in milliseconds + - maxLossPercentage (integer): Maximum percentage of packet loss + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "customPerformanceClasses"], + "operation": "updateNetworkApplianceTrafficShapingCustomPerformanceClass", + } + networkId = urllib.parse.quote(str(networkId), safe="") + customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}" + + body_params = [ + "name", + "maxLatency", + "maxJitter", + "maxLossPercentage", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceTrafficShapingCustomPerformanceClass: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def deleteNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, customPerformanceClassId: str): + """ + **Delete a custom performance class from an MX network** + https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-traffic-shaping-custom-performance-class + + - networkId (string): Network ID + - customPerformanceClassId (string): Custom performance class ID + """ + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "customPerformanceClasses"], + "operation": "deleteNetworkApplianceTrafficShapingCustomPerformanceClass", + } + networkId = urllib.parse.quote(str(networkId), safe="") + customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}" + + return self._session.delete(metadata, resource) + + def updateNetworkApplianceTrafficShapingRules(self, networkId: str, **kwargs): + """ + **Update the traffic shaping settings rules for an MX network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-rules + + - networkId (string): Network ID + - defaultRulesEnabled (boolean): Whether default traffic shaping rules are enabled (true) or disabled (false). There are 4 default rules, which can be seen on your network's traffic shaping page. Note that default rules count against the rule limit of 8. + - rules (array): An array of traffic shaping rules. Rules are applied in the order that + they are specified in. An empty list (or null) means no rules. Note that + you are allowed a maximum of 8 rules. + + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "rules"], + "operation": "updateNetworkApplianceTrafficShapingRules", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/rules" + + body_params = [ + "defaultRulesEnabled", + "rules", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceTrafficShapingRules: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def getNetworkApplianceTrafficShapingRules(self, networkId: str): + """ + **Display the traffic shaping settings rules for an MX network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping-rules + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "rules"], + "operation": "getNetworkApplianceTrafficShapingRules", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/rules" + + return self._session.get(metadata, resource) + + def getNetworkApplianceTrafficShapingUplinkBandwidth(self, networkId: str): + """ + **Returns the uplink bandwidth limits for your MX network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping-uplink-bandwidth + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "uplinkBandwidth"], + "operation": "getNetworkApplianceTrafficShapingUplinkBandwidth", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/uplinkBandwidth" + + return self._session.get(metadata, resource) + + def updateNetworkApplianceTrafficShapingUplinkBandwidth(self, networkId: str, **kwargs): + """ + **Updates the uplink bandwidth settings for your MX network.** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-uplink-bandwidth + + - networkId (string): Network ID + - bandwidthLimits (object): A mapping of uplinks to their bandwidth settings (be sure to check which uplinks are supported for your network) + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "uplinkBandwidth"], + "operation": "updateNetworkApplianceTrafficShapingUplinkBandwidth", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/uplinkBandwidth" + + body_params = [ + "bandwidthLimits", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceTrafficShapingUplinkBandwidth: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def getNetworkApplianceTrafficShapingUplinkSelection(self, networkId: str): + """ + **Show uplink selection settings for an MX network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-traffic-shaping-uplink-selection + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "uplinkSelection"], + "operation": "getNetworkApplianceTrafficShapingUplinkSelection", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/uplinkSelection" + + return self._session.get(metadata, resource) + + def updateNetworkApplianceTrafficShapingUplinkSelection(self, networkId: str, **kwargs): + """ + **Update uplink selection settings for an MX network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-uplink-selection + + - networkId (string): Network ID + - activeActiveAutoVpnEnabled (boolean): Toggle for enabling or disabling active-active AutoVPN + - defaultUplink (string): The default uplink. Must be a WAN interface 'wanX' + - loadBalancingEnabled (boolean): Toggle for enabling or disabling load balancing + - failoverAndFailback (object): WAN failover and failback behavior + - wanTrafficUplinkPreferences (array): Array of uplink preference rules for WAN traffic + - vpnTrafficUplinkPreferences (array): Array of uplink preference rules for VPN traffic + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "uplinkSelection"], + "operation": "updateNetworkApplianceTrafficShapingUplinkSelection", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/uplinkSelection" + + body_params = [ + "activeActiveAutoVpnEnabled", + "defaultUplink", + "loadBalancingEnabled", + "failoverAndFailback", + "wanTrafficUplinkPreferences", + "vpnTrafficUplinkPreferences", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceTrafficShapingUplinkSelection: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def updateNetworkApplianceTrafficShapingVpnExclusions(self, networkId: str, **kwargs): + """ + **Update VPN exclusion rules for an MX network.** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-vpn-exclusions + + - networkId (string): Network ID + - custom (array): Custom VPN exclusion rules. Pass an empty array to clear existing rules. + - majorApplications (array): Major Application based VPN exclusion rules. Pass an empty array to clear existing rules. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "vpnExclusions"], + "operation": "updateNetworkApplianceTrafficShapingVpnExclusions", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/vpnExclusions" + + body_params = [ + "custom", + "majorApplications", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceTrafficShapingVpnExclusions: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def connectNetworkApplianceUmbrellaAccount(self, networkId: str, api: dict, **kwargs): + """ + **Connect a Cisco Umbrella account to this network** + https://developer.cisco.com/meraki/api-v1/#!connect-network-appliance-umbrella-account + + - networkId (string): Network ID + - api (object): Umbrella API credentials + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "umbrella", "account"], + "operation": "connectNetworkApplianceUmbrellaAccount", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/umbrella/account/connect" + + body_params = [ + "api", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"connectNetworkApplianceUmbrellaAccount: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def disconnectNetworkApplianceUmbrellaAccount(self, networkId: str): + """ + **Disconnect Umbrella account from this network** + https://developer.cisco.com/meraki/api-v1/#!disconnect-network-appliance-umbrella-account + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "umbrella", "account"], + "operation": "disconnectNetworkApplianceUmbrellaAccount", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/umbrella/account/disconnect" + + return self._session.post(metadata, resource) + + def exclusionsNetworkApplianceUmbrellaDomains(self, networkId: str, domains: list, **kwargs): + """ + **Specify one or more domain names to be excluded from being routed to Cisco Umbrella.** + https://developer.cisco.com/meraki/api-v1/#!exclusions-network-appliance-umbrella-domains + + - networkId (string): Network ID + - domains (array): Domain names to exclude from Umbrella DNS routing (e.g., 'example.com', 'corp.example.org'). Standard FQDNs only — wildcards are not supported. Values are lowercased before saving. Each call replaces the full exclusion list. + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "umbrella", "domains"], + "operation": "exclusionsNetworkApplianceUmbrellaDomains", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/umbrella/domains/exclusions" + + body_params = [ + "domains", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"exclusionsNetworkApplianceUmbrellaDomains: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def addNetworkApplianceUmbrellaPolicies(self, networkId: str, policy: dict, **kwargs): + """ + **Add one Cisco Umbrella DNS security policy to an MX network by policy ID** + https://developer.cisco.com/meraki/api-v1/#!add-network-appliance-umbrella-policies + + - networkId (string): Network ID + - policy (object): Umbrella policy to add + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "umbrella", "policies"], + "operation": "addNetworkApplianceUmbrellaPolicies", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/umbrella/policies/add" + + body_params = [ + "policy", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"addNetworkApplianceUmbrellaPolicies: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def removeNetworkApplianceUmbrellaPolicies(self, networkId: str, policy: dict, **kwargs): + """ + **Remove one Cisco Umbrella DNS security policy from an MX network by policy ID** + https://developer.cisco.com/meraki/api-v1/#!remove-network-appliance-umbrella-policies + + - networkId (string): Network ID + - policy (object): Umbrella policy to remove + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "umbrella", "policies"], + "operation": "removeNetworkApplianceUmbrellaPolicies", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/umbrella/policies/remove" + + body_params = [ + "policy", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"removeNetworkApplianceUmbrellaPolicies: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def protectionNetworkApplianceUmbrella(self, networkId: str, enabled: bool, **kwargs): + """ + **Enable or disable umbrella protection for an appliance network** + https://developer.cisco.com/meraki/api-v1/#!protection-network-appliance-umbrella + + - networkId (string): Network ID + - enabled (boolean): Enable or disable umbrella protection + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "umbrella"], + "operation": "protectionNetworkApplianceUmbrella", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/umbrella/protection" + + body_params = [ + "enabled", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"protectionNetworkApplianceUmbrella: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def updateNetworkApplianceUplinksNat(self, networkId: str, uplinks: list, **kwargs): + """ + **Update uplink NAT settings of the specified network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-uplinks-nat + + - networkId (string): Network ID + - uplinks (array): Per-uplink NAT exception configuration on the network. + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "uplinks", "nat"], + "operation": "updateNetworkApplianceUplinksNat", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/uplinks/nat" + + body_params = [ + "uplinks", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceUplinksNat: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def getNetworkApplianceUplinksUsageHistory(self, networkId: str, **kwargs): + """ + **Get the sent and received bytes for each uplink of a network.** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-uplinks-usage-history + + - networkId (string): Network ID + - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 30 days from today. + - t1 (string): The end of the timespan for the data. t1 can be a maximum of 31 days after t0. + - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 31 days. The default is 10 minutes. + - resolution (integer): The time resolution in seconds for returned data. The valid resolutions are: 60, 300, 600, 1800, 3600, 86400. The default is 60. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "monitor", "uplinks", "usageHistory"], + "operation": "getNetworkApplianceUplinksUsageHistory", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/uplinks/usageHistory" + + query_params = [ + "t0", + "t1", + "timespan", + "resolution", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getNetworkApplianceUplinksUsageHistory: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def getNetworkApplianceVlans(self, networkId: str): + """ + **List the VLANs for a Security Appliance network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vlans + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "vlans"], + "operation": "getNetworkApplianceVlans", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vlans" + + return self._session.get(metadata, resource) + + def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, **kwargs): + """ + **Add a VLAN** + https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-vlan + + - networkId (string): Network ID + - id (string): The VLAN ID of the new VLAN (must be between 1 and 4094) + - name (string): The name of the new VLAN + - subnet (string): The subnet of the VLAN + - applianceIp (string): The local IP of the appliance on the VLAN + - groupPolicyId (string): The id of the desired group policy to apply to the VLAN + - templateVlanType (string): Type of subnetting of the VLAN. Applicable only for template network. + - cidr (string): CIDR of the pool of subnets. Applicable only for template network. Each network bound to the template will automatically pick a subnet from this pool to build its own VLAN. + - mask (integer): Mask used for the subnet of all bound to the template networks. Applicable only for template network. + - ipv6 (object): IPv6 configuration on the VLAN + - dhcpHandling (string): The appliance's handling of DHCP requests on this VLAN. One of: 'Run a DHCP server', 'Relay DHCP to another server' or 'Do not respond to DHCP requests' + - dhcpRelayServerIps (array): The IPs (IPv4) of the DHCP servers that DHCP requests should be relayed to. CIDR/subnet notation and hostnames are not supported. + - dhcpLeaseTime (string): The term of DHCP leases if the appliance is running a DHCP server on this VLAN. One of: '30 minutes', '1 hour', '4 hours', '12 hours', '1 day' or '1 week' + - mandatoryDhcp (object): Mandatory DHCP will enforce that clients connecting to this VLAN must use the IP address assigned by the DHCP server. Clients who use a static IP address won't be able to associate. Only available on firmware versions 17.0 and above + - dhcpBootOptionsEnabled (boolean): Use DHCP boot options specified in other properties + - dhcpBootNextServer (string): DHCP boot option to direct boot clients to the server to load the boot file from + - dhcpBootFilename (string): DHCP boot option for boot filename + - dhcpOptions (array): The list of DHCP options that will be included in DHCP responses. Each object in the list should have "code", "type", and "value" properties. + - sgt (object): Security Group Tag settings for the VLAN. + - vrf (object): VRF configuration on the VLAN. + - uplinks (array): Per-uplink NAT exception override configuration on the VLAN. Applicable only for networks that support NAT exceptions. + """ + + kwargs.update(locals()) + + if "templateVlanType" in kwargs: + options = ["same", "unique"] + assert kwargs["templateVlanType"] in options, ( + f'''"templateVlanType" cannot be "{kwargs["templateVlanType"]}", & must be set to one of: {options}''' + ) + if "dhcpHandling" in kwargs: + options = ["Do not respond to DHCP requests", "Relay DHCP to another server", "Run a DHCP server"] + assert kwargs["dhcpHandling"] in options, ( + f'''"dhcpHandling" cannot be "{kwargs["dhcpHandling"]}", & must be set to one of: {options}''' + ) + if "dhcpLeaseTime" in kwargs: + options = ["1 day", "1 hour", "1 week", "12 hours", "30 minutes", "4 hours"] + assert kwargs["dhcpLeaseTime"] in options, ( + f'''"dhcpLeaseTime" cannot be "{kwargs["dhcpLeaseTime"]}", & must be set to one of: {options}''' + ) + + metadata = { + "tags": ["appliance", "configure", "vlans"], + "operation": "createNetworkApplianceVlan", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vlans" + + body_params = [ + "id", + "name", + "subnet", + "applianceIp", + "groupPolicyId", + "templateVlanType", + "cidr", + "mask", + "ipv6", + "dhcpHandling", + "dhcpRelayServerIps", + "dhcpLeaseTime", + "mandatoryDhcp", + "dhcpBootOptionsEnabled", + "dhcpBootNextServer", + "dhcpBootFilename", + "dhcpOptions", + "sgt", + "vrf", + "uplinks", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createNetworkApplianceVlan: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getNetworkApplianceVlansSettings(self, networkId: str): + """ + **Returns the enabled status of VLANs for the network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vlans-settings + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "vlans", "settings"], + "operation": "getNetworkApplianceVlansSettings", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vlans/settings" + + return self._session.get(metadata, resource) + + def updateNetworkApplianceVlansSettings(self, networkId: str, **kwargs): + """ + **Enable/Disable VLANs for the given network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-vlans-settings + + - networkId (string): Network ID + - vlansEnabled (boolean): Boolean indicating whether to enable (true) or disable (false) VLANs for the network + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "vlans", "settings"], + "operation": "updateNetworkApplianceVlansSettings", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vlans/settings" + + body_params = [ + "vlansEnabled", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceVlansSettings: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def getNetworkApplianceVlan(self, networkId: str, vlanId: str): + """ + **Return a VLAN** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vlan + + - networkId (string): Network ID + - vlanId (string): Vlan ID + """ + + metadata = { + "tags": ["appliance", "configure", "vlans"], + "operation": "getNetworkApplianceVlan", + } + networkId = urllib.parse.quote(str(networkId), safe="") + vlanId = urllib.parse.quote(str(vlanId), safe="") + resource = f"/networks/{networkId}/appliance/vlans/{vlanId}" + + return self._session.get(metadata, resource) + + def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs): + """ + **Update a VLAN** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-vlan + + - networkId (string): Network ID + - vlanId (string): Vlan ID + - name (string): The name of the VLAN + - subnet (string): The subnet of the VLAN + - applianceIp (string): The local IP of the appliance on the VLAN + - groupPolicyId (string): The id of the desired group policy to apply to the VLAN + - vpnNatSubnet (string): The translated VPN subnet if VPN and VPN subnet translation are enabled on the VLAN + - dhcpHandling (string): The appliance's handling of DHCP requests on this VLAN. One of: 'Run a DHCP server', 'Relay DHCP to another server' or 'Do not respond to DHCP requests' + - dhcpRelayServerIps (array): The IPs (IPv4) of the DHCP servers that DHCP requests should be relayed to. CIDR/subnet notation and hostnames are not supported. + - dhcpLeaseTime (string): The term of DHCP leases if the appliance is running a DHCP server on this VLAN. One of: '30 minutes', '1 hour', '4 hours', '12 hours', '1 day' or '1 week' + - dhcpBootOptionsEnabled (boolean): Use DHCP boot options specified in other properties + - dhcpBootNextServer (string): DHCP boot option to direct boot clients to the server to load the boot file from + - dhcpBootFilename (string): DHCP boot option for boot filename + - fixedIpAssignments (object): The DHCP fixed IP assignments on the VLAN. This should be an object that contains mappings from MAC addresses to objects that themselves each contain "ip" and "name" string fields. See the sample request/response for more details. + - reservedIpRanges (array): The DHCP reserved IP ranges on the VLAN + - dnsNameservers (string): The DNS nameservers used for DHCP responses, either "upstream_dns", "google_dns", "opendns", or a newline seperated string of IP addresses or domain names + - dhcpOptions (array): The list of DHCP options that will be included in DHCP responses. Each object in the list should have "code", "type", and "value" properties. + - templateVlanType (string): Type of subnetting of the VLAN. Applicable only for template network. + - cidr (string): CIDR of the pool of subnets. Applicable only for template network. Each network bound to the template will automatically pick a subnet from this pool to build its own VLAN. + - mask (integer): Mask used for the subnet of all bound to the template networks. Applicable only for template network. + - ipv6 (object): IPv6 configuration on the VLAN + - mandatoryDhcp (object): Mandatory DHCP will enforce that clients connecting to this VLAN must use the IP address assigned by the DHCP server. Clients who use a static IP address won't be able to associate. Only available on firmware versions 17.0 and above + - sgt (object): Security Group Tag settings for the VLAN. + - vrf (object): VRF configuration on the VLAN. + - uplinks (array): Per-uplink NAT exception override configuration on the VLAN. Applicable only for networks that support NAT exceptions. + """ + + kwargs.update(locals()) + + if "dhcpHandling" in kwargs: + options = ["Do not respond to DHCP requests", "Relay DHCP to another server", "Run a DHCP server"] + assert kwargs["dhcpHandling"] in options, ( + f'''"dhcpHandling" cannot be "{kwargs["dhcpHandling"]}", & must be set to one of: {options}''' + ) + if "dhcpLeaseTime" in kwargs: + options = ["1 day", "1 hour", "1 week", "12 hours", "30 minutes", "4 hours"] + assert kwargs["dhcpLeaseTime"] in options, ( + f'''"dhcpLeaseTime" cannot be "{kwargs["dhcpLeaseTime"]}", & must be set to one of: {options}''' + ) + if "templateVlanType" in kwargs: + options = ["same", "unique"] + assert kwargs["templateVlanType"] in options, ( + f'''"templateVlanType" cannot be "{kwargs["templateVlanType"]}", & must be set to one of: {options}''' + ) + + metadata = { + "tags": ["appliance", "configure", "vlans"], + "operation": "updateNetworkApplianceVlan", + } + networkId = urllib.parse.quote(str(networkId), safe="") + vlanId = urllib.parse.quote(str(vlanId), safe="") + resource = f"/networks/{networkId}/appliance/vlans/{vlanId}" + + body_params = [ + "name", + "subnet", + "applianceIp", + "groupPolicyId", + "vpnNatSubnet", + "dhcpHandling", + "dhcpRelayServerIps", + "dhcpLeaseTime", + "dhcpBootOptionsEnabled", + "dhcpBootNextServer", + "dhcpBootFilename", + "fixedIpAssignments", + "reservedIpRanges", + "dnsNameservers", + "dhcpOptions", + "templateVlanType", + "cidr", + "mask", + "ipv6", + "mandatoryDhcp", + "sgt", + "vrf", + "uplinks", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceVlan: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def deleteNetworkApplianceVlan(self, networkId: str, vlanId: str): + """ + **Delete a VLAN from a network** + https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-vlan + + - networkId (string): Network ID + - vlanId (string): Vlan ID + """ + + metadata = { + "tags": ["appliance", "configure", "vlans"], + "operation": "deleteNetworkApplianceVlan", + } + networkId = urllib.parse.quote(str(networkId), safe="") + vlanId = urllib.parse.quote(str(vlanId), safe="") + resource = f"/networks/{networkId}/appliance/vlans/{vlanId}" + + return self._session.delete(metadata, resource) + + def getNetworkApplianceVpnBgp(self, networkId: str): + """ + **Return a Hub BGP Configuration** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vpn-bgp + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "vpn", "bgp"], + "operation": "getNetworkApplianceVpnBgp", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vpn/bgp" + + return self._session.get(metadata, resource) + + def updateNetworkApplianceVpnBgp(self, networkId: str, enabled: bool, **kwargs): + """ + **Update a Hub BGP Configuration** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-vpn-bgp + + - networkId (string): Network ID + - enabled (boolean): Boolean value to enable or disable the BGP configuration. When BGP is enabled, the asNumber (ASN) will be autopopulated with the preconfigured ASN at other Hubs or a default value if there is no ASN configured. + - asNumber (integer): An Autonomous System Number (ASN) is required if you are to run BGP and peer with another BGP Speaker outside of the Auto VPN domain. This ASN will be applied to the entire Auto VPN domain and is only configurable for Auto VPN BGP networks. The entire 4-byte ASN range is supported. So, the ASN must be an integer between 1 and 4294967295. When absent, this field is not updated. If no value exists then it defaults to 64512. + - ibgpHoldTimer (integer): The iBGP holdtimer in seconds. The iBGP holdtimer must be an integer between 12 and 240. When absent, this field is not updated. If no value exists then it defaults to 240. + - routerId (string): The router ID of the appliance + - neighbors (array): List of BGP neighbors. This list replaces the existing set of neighbors. When absent, this field is not updated. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "vpn", "bgp"], + "operation": "updateNetworkApplianceVpnBgp", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vpn/bgp" + + body_params = [ + "enabled", + "asNumber", + "ibgpHoldTimer", + "routerId", + "neighbors", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceVpnBgp: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def getNetworkApplianceVpnSiteToSiteVpn(self, networkId: str): + """ + **Return the site-to-site VPN settings of a network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vpn-site-to-site-vpn - - networkId (string): (required) + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "vpn", "siteToSiteVpn"], + "operation": "getNetworkApplianceVpnSiteToSiteVpn", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vpn/siteToSiteVpn" + + return self._session.get(metadata, resource) + + def updateNetworkApplianceVpnSiteToSiteVpn(self, networkId: str, mode: str, **kwargs): + """ + **Update the site-to-site VPN settings of a network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-vpn-site-to-site-vpn + + - networkId (string): Network ID + - mode (string): The site-to-site VPN mode. Can be one of 'none', 'spoke' or 'hub' + - hubs (array): The list of VPN hubs, in order of preference. In spoke mode, at least 1 hub is required. + - subnets (array): The list of subnets and their VPN presence. + - sgt (object): Security Group Tag settings for the VPN peer. + - subnet (object): Configuration of subnet features + - hostTranslations (array): The list of VPN host translations. Host translations are supported starting from MX firmware version 26.1.2 + """ + + kwargs.update(locals()) + + if "mode" in kwargs: + options = ["hub", "none", "spoke"] + assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}''' + + metadata = { + "tags": ["appliance", "configure", "vpn", "siteToSiteVpn"], + "operation": "updateNetworkApplianceVpnSiteToSiteVpn", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vpn/siteToSiteVpn" + + body_params = [ + "mode", + "hubs", + "subnets", + "sgt", + "subnet", + "hostTranslations", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkApplianceVpnSiteToSiteVpn: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def getNetworkApplianceWarmSpare(self, networkId: str): + """ + **Return MX warm spare settings** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-warm-spare + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "warmSpare"], + "operation": "getNetworkApplianceWarmSpare", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/warmSpare" + + return self._session.get(metadata, resource) + + def updateNetworkApplianceWarmSpare(self, networkId: str, enabled: bool, **kwargs): + """ + **Update MX warm spare settings** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-warm-spare + + - networkId (string): Network ID + - enabled (boolean): Enable warm spare + - spareSerial (string): Serial number of the warm spare appliance + - uplinkMode (string): Uplink mode, either virtual or public + - virtualIp1 (string): The WAN 1 shared IP + - virtualIp2 (string): The WAN 2 shared IP + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "warmSpare"], + "operation": "updateNetworkApplianceWarmSpare", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/warmSpare" + + body_params = [ + "enabled", + "spareSerial", + "uplinkMode", + "virtualIp1", + "virtualIp2", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkApplianceWarmSpare: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def swapNetworkApplianceWarmSpare(self, networkId: str): + """ + **Swap MX primary and warm spare appliances** + https://developer.cisco.com/meraki/api-v1/#!swap-network-appliance-warm-spare + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "warmSpare"], + "operation": "swapNetworkApplianceWarmSpare", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/warmSpare/swap" + + return self._session.post(metadata, resource) + + def getOrganizationApplianceDevicesInterfacesL3(self, organizationId: str, total_pages=1, direction="next", **kwargs): + """ + **List L3 interfaces across networks for the organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-interfaces-l-3 + + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - networkIds (array): Optional Network IDs to filter results + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "devices", "interfaces", "l3"], + "operation": "getOrganizationApplianceDevicesInterfacesL3", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/devices/interfaces/l3" + + query_params = [ + "networkIds", + "perPage", + "startingAfter", + "endingBefore", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "networkIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceDevicesInterfacesL3: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationApplianceDevicesInterfacesPortsByDevice(self, organizationId: str, **kwargs): + """ + **Returns port configurations for appliances in a given organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-interfaces-ports-by-device + + - organizationId (string): Organization ID + - serials (array): Parameter to filter the results by device serials + - interfaces (array): Parameter to filter the results by specific interfaces + - numbers (array): Parameter to filter the results by specific ports + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "devices", "interfaces", "ports", "byDevice"], + "operation": "getOrganizationApplianceDevicesInterfacesPortsByDevice", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/devices/interfaces/ports/byDevice" + + query_params = [ + "serials", + "interfaces", + "numbers", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "serials", + "interfaces", + "numbers", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceDevicesInterfacesPortsByDevice: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def getOrganizationApplianceDevicesPortsTransceiversReadingsHistoryByDevice( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): + """ + **Return time-series digital optical monitoring (DOM) readings for ports on each DOM-enabled Catalyst appliance in an organization.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-ports-transceivers-readings-history-by-device + + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 10. Default is 5. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 30 days from today. + - t1 (string): The end of the timespan for the data. t1 can be a maximum of 30 days after t0. + - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 30 days. The default is 1 day. If interval is provided, the timespan will be autocalculated. + - interval (integer): The time interval in seconds for returned data. The valid intervals are: 300, 1200, 14400, 86400. The default is 1200. Interval is calculated if time params are provided. + - networkIds (array): Networks for which information should be gathered. + - serials (array): Optional parameter to filter usage by appliance serial. + - portIds (array): Optional parameter to filter usage by port ID. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "monitor", "devices", "ports", "transceivers", "readings", "history", "byDevice"], + "operation": "getOrganizationApplianceDevicesPortsTransceiversReadingsHistoryByDevice", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/devices/ports/transceivers/readings/history/byDevice" + + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + "t0", + "t1", + "timespan", + "interval", + "networkIds", + "serials", + "portIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "networkIds", + "serials", + "portIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceDevicesPortsTransceiversReadingsHistoryByDevice: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationApplianceDevicesRedundancyByNetwork( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): + """ + **Return MX warm spare settings** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-redundancy-by-network + + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - perPage (integer): The number of entries per page returned. Acceptable range is 5 - 1000. Default is 50. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "devices", "redundancy", "byNetwork"], + "operation": "getOrganizationApplianceDevicesRedundancyByNetwork", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/devices/redundancy/byNetwork" + + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceDevicesRedundancyByNetwork: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationApplianceDnsLocalProfiles(self, organizationId: str, **kwargs): + """ + **Fetch the local DNS profiles used in the organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-dns-local-profiles + + - organizationId (string): Organization ID + - profileIds (array): Optional parameter to filter the results by profile IDs + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "profiles"], + "operation": "getOrganizationApplianceDnsLocalProfiles", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles" + + query_params = [ + "profileIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "profileIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceDnsLocalProfiles: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def createOrganizationApplianceDnsLocalProfile(self, organizationId: str, name: str, **kwargs): + """ + **Create a new local DNS profile** + https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-local-profile + + - organizationId (string): Organization ID + - name (string): Name of profile + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "profiles"], + "operation": "createOrganizationApplianceDnsLocalProfile", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles" + + body_params = [ + "name", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createOrganizationApplianceDnsLocalProfile: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getOrganizationApplianceDnsLocalProfilesAssignments(self, organizationId: str, **kwargs): + """ + **Fetch the local DNS profile assignments in the organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-dns-local-profiles-assignments + + - organizationId (string): Organization ID + - profileIds (array): Optional parameter to filter the results by profile IDs + - networkIds (array): Optional parameter to filter the results by network IDs + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "profiles", "assignments"], + "operation": "getOrganizationApplianceDnsLocalProfilesAssignments", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/assignments" + + query_params = [ + "profileIds", + "networkIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "profileIds", + "networkIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceDnsLocalProfilesAssignments: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate(self, organizationId: str, items: list, **kwargs): + """ + **Assign the local DNS profile to networks in the organization** + https://developer.cisco.com/meraki/api-v1/#!bulk-organization-appliance-dns-local-profiles-assignments-create + + - organizationId (string): Organization ID + - items (array): List containing the network ID and Profile ID + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "profiles", "assignments"], + "operation": "bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/assignments/bulkCreate" + + body_params = [ + "items", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def createOrganizationApplianceDnsLocalProfilesAssignmentsBulkDelete(self, organizationId: str, items: list, **kwargs): + """ + **Unassign the local DNS profile to networks in the organization** + https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-local-profiles-assignments-bulk-delete + + - organizationId (string): Organization ID + - items (array): List containing the assignment ID + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "profiles", "assignments", "bulkDelete"], + "operation": "createOrganizationApplianceDnsLocalProfilesAssignmentsBulkDelete", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/assignments/bulkDelete" + + body_params = [ + "items", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createOrganizationApplianceDnsLocalProfilesAssignmentsBulkDelete: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def updateOrganizationApplianceDnsLocalProfile(self, organizationId: str, profileId: str, name: str, **kwargs): + """ + **Update a local DNS profile** + https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-dns-local-profile + + - organizationId (string): Organization ID + - profileId (string): Profile ID + - name (string): Name of profile + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "profiles"], + "operation": "updateOrganizationApplianceDnsLocalProfile", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + profileId = urllib.parse.quote(str(profileId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/{profileId}" + + body_params = [ + "name", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationApplianceDnsLocalProfile: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def deleteOrganizationApplianceDnsLocalProfile(self, organizationId: str, profileId: str): + """ + **Deletes a local DNS profile** + https://developer.cisco.com/meraki/api-v1/#!delete-organization-appliance-dns-local-profile + + - organizationId (string): Organization ID + - profileId (string): Profile ID + """ + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "profiles"], + "operation": "deleteOrganizationApplianceDnsLocalProfile", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + profileId = urllib.parse.quote(str(profileId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/{profileId}" + + return self._session.delete(metadata, resource) + + def getOrganizationApplianceDnsLocalRecords(self, organizationId: str, **kwargs): + """ + **Fetch the DNS records used in local DNS profiles** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-dns-local-records + + - organizationId (string): Organization ID + - profileIds (array): Optional parameter to filter the results by profile IDs + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "records"], + "operation": "getOrganizationApplianceDnsLocalRecords", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/records" + + query_params = [ + "profileIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "profileIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceDnsLocalRecords: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def createOrganizationApplianceDnsLocalRecord( + self, organizationId: str, hostname: str, address: str, profile: dict, **kwargs + ): + """ + **Create a new local DNS record** + https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-local-record + + - organizationId (string): Organization ID + - hostname (string): Hostname for the DNS record + - address (string): IP for the DNS record + - profile (object): The profile the DNS record is associated with + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "records"], + "operation": "createOrganizationApplianceDnsLocalRecord", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/records" + + body_params = [ + "hostname", + "address", + "profile", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createOrganizationApplianceDnsLocalRecord: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def updateOrganizationApplianceDnsLocalRecord(self, organizationId: str, recordId: str, **kwargs): + """ + **Updates a local DNS record** + https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-dns-local-record + + - organizationId (string): Organization ID + - recordId (string): Record ID + - hostname (string): Hostname for the DNS record + - address (string): IP for the DNS record + - profile (object): The profile the DNS record is associated with + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "records"], + "operation": "updateOrganizationApplianceDnsLocalRecord", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + recordId = urllib.parse.quote(str(recordId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/records/{recordId}" + + body_params = [ + "hostname", + "address", + "profile", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationApplianceDnsLocalRecord: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def deleteOrganizationApplianceDnsLocalRecord(self, organizationId: str, recordId: str): + """ + **Deletes a local DNS record** + https://developer.cisco.com/meraki/api-v1/#!delete-organization-appliance-dns-local-record + + - organizationId (string): Organization ID + - recordId (string): Record ID + """ + + metadata = { + "tags": ["appliance", "configure", "dns", "local", "records"], + "operation": "deleteOrganizationApplianceDnsLocalRecord", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + recordId = urllib.parse.quote(str(recordId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/records/{recordId}" + + return self._session.delete(metadata, resource) + + def getOrganizationApplianceDnsSplitProfiles(self, organizationId: str, **kwargs): + """ + **Fetch the split DNS profiles used in the organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-dns-split-profiles + + - organizationId (string): Organization ID + - profileIds (array): Optional parameter to filter the results by profile IDs + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "dns", "split", "profiles"], + "operation": "getOrganizationApplianceDnsSplitProfiles", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles" + + query_params = [ + "profileIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "profileIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceDnsSplitProfiles: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def createOrganizationApplianceDnsSplitProfile( + self, organizationId: str, name: str, hostnames: list, nameservers: dict, **kwargs + ): + """ + **Create a new split DNS profile** + https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-split-profile + + - organizationId (string): Organization ID + - name (string): Name of profile + - hostnames (array): The hostname patterns to match for redirection. For more information on Split DNS hostname pattern formatting, please consult the Split DNS KB. + - nameservers (object): Contains the nameserver information for redirection. + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "dns", "split", "profiles"], + "operation": "createOrganizationApplianceDnsSplitProfile", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles" + + body_params = [ + "name", + "hostnames", + "nameservers", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createOrganizationApplianceDnsSplitProfile: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getOrganizationApplianceDnsSplitProfilesAssignments(self, organizationId: str, **kwargs): + """ + **Fetch the split DNS profile assignments in the organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-dns-split-profiles-assignments + + - organizationId (string): Organization ID + - profileIds (array): Optional parameter to filter the results by profile IDs + - networkIds (array): Optional parameter to filter the results by network IDs + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "dns", "split", "profiles", "assignments"], + "operation": "getOrganizationApplianceDnsSplitProfilesAssignments", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/assignments" + + query_params = [ + "profileIds", + "networkIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "profileIds", + "networkIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceDnsSplitProfilesAssignments: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def createOrganizationApplianceDnsSplitProfilesAssignmentsBulkCreate(self, organizationId: str, items: list, **kwargs): + """ + **Assign the split DNS profile to networks in the organization** + https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-split-profiles-assignments-bulk-create + + - organizationId (string): Organization ID + - items (array): List containing the network ID and Profile ID + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "dns", "split", "profiles", "assignments", "bulkCreate"], + "operation": "createOrganizationApplianceDnsSplitProfilesAssignmentsBulkCreate", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/assignments/bulkCreate" + + body_params = [ + "items", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createOrganizationApplianceDnsSplitProfilesAssignmentsBulkCreate: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def createOrganizationApplianceDnsSplitProfilesAssignmentsBulkDelete(self, organizationId: str, items: list, **kwargs): + """ + **Unassign the split DNS profile to networks in the organization** + https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-split-profiles-assignments-bulk-delete + + - organizationId (string): Organization ID + - items (array): List containing the assignment ID + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "dns", "split", "profiles", "assignments", "bulkDelete"], + "operation": "createOrganizationApplianceDnsSplitProfilesAssignmentsBulkDelete", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/assignments/bulkDelete" + + body_params = [ + "items", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createOrganizationApplianceDnsSplitProfilesAssignmentsBulkDelete: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def updateOrganizationApplianceDnsSplitProfile(self, organizationId: str, profileId: str, **kwargs): """ + **Update a split DNS profile** + https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-dns-split-profile + + - organizationId (string): Organization ID + - profileId (string): Profile ID + - name (string): Name of profile + - hostnames (array): The hostname patterns to match for redirection. For more information on Split DNS hostname pattern formatting, please consult the Split DNS KB. + - nameservers (object): Contains the nameserver information for redirection. + """ + + kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'siteToSiteVpn'], - 'operation': 'getNetworkApplianceVpnSiteToSiteVpn' + "tags": ["appliance", "configure", "dns", "split", "profiles"], + "operation": "updateOrganizationApplianceDnsSplitProfile", } - resource = f'/networks/{networkId}/appliance/vpn/siteToSiteVpn' + organizationId = urllib.parse.quote(str(organizationId), safe="") + profileId = urllib.parse.quote(str(profileId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/{profileId}" + + body_params = [ + "name", + "hostnames", + "nameservers", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.get(metadata, resource) + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationApplianceDnsSplitProfile: ignoring unrecognized kwargs: {invalid}" + ) - def updateNetworkApplianceVpnSiteToSiteVpn(self, networkId: str, mode: str, **kwargs): - """ - **Update the site-to-site VPN settings of a network. Only valid for MX networks in NAT mode.** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-vpn-site-to-site-vpn + return self._session.put(metadata, resource, payload) - - networkId (string): (required) - - mode (string): The site-to-site VPN mode. Can be one of 'none', 'spoke' or 'hub' - - hubs (array): The list of VPN hubs, in order of preference. In spoke mode, at least 1 hub is required. - - subnets (array): The list of subnets and their VPN presence. + def deleteOrganizationApplianceDnsSplitProfile(self, organizationId: str, profileId: str): """ + **Deletes a split DNS profile** + https://developer.cisco.com/meraki/api-v1/#!delete-organization-appliance-dns-split-profile - kwargs.update(locals()) - - if 'mode' in kwargs: - options = ['none', 'spoke', 'hub'] - assert kwargs['mode'] in options, f'''"mode" cannot be "{kwargs['mode']}", & must be set to one of: {options}''' + - organizationId (string): Organization ID + - profileId (string): Profile ID + """ metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'siteToSiteVpn'], - 'operation': 'updateNetworkApplianceVpnSiteToSiteVpn' + "tags": ["appliance", "configure", "dns", "split", "profiles"], + "operation": "deleteOrganizationApplianceDnsSplitProfile", } - resource = f'/networks/{networkId}/appliance/vpn/siteToSiteVpn' + organizationId = urllib.parse.quote(str(organizationId), safe="") + profileId = urllib.parse.quote(str(profileId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/{profileId}" - body_params = ['mode', 'hubs', 'subnets', ] - payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - - return self._session.put(metadata, resource, payload) + return self._session.delete(metadata, resource) - def getNetworkApplianceWarmSpare(self, networkId: str): + def getOrganizationApplianceFirewallMulticastForwardingByNetwork( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): """ - **Return MX warm spare settings** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-warm-spare + **List Static Multicasting forwarding settings for MX networks** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-firewall-multicast-forwarding-by-network - - networkId (string): (required) + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 1000. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - networkIds (array): Optional parameter to filter the results by network IDs """ + kwargs.update(locals()) + metadata = { - 'tags': ['appliance', 'configure', 'warmSpare'], - 'operation': 'getNetworkApplianceWarmSpare' + "tags": ["appliance", "configure", "firewall", "multicastForwarding", "byNetwork"], + "operation": "getOrganizationApplianceFirewallMulticastForwardingByNetwork", } - resource = f'/networks/{networkId}/appliance/warmSpare' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/firewall/multicastForwarding/byNetwork" + + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + "networkIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get(metadata, resource) + array_params = [ + "networkIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) - def updateNetworkApplianceWarmSpare(self, networkId: str, enabled: bool, **kwargs): + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceFirewallMulticastForwardingByNetwork: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationApplianceInterfacesPacketsOverviewsByDevice( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): """ - **Update MX warm spare settings** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-warm-spare + **Returns packet counter overviews for all interfaces on Secure Routers in the organization, including totals and average rates by packet type over the requested timespan.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-interfaces-packets-overviews-by-device - - networkId (string): (required) - - enabled (boolean): Enable warm spare - - spareSerial (string): Serial number of the warm spare appliance - - uplinkMode (string): Uplink mode, either virtual or public - - virtualIp1 (string): The WAN 1 shared IP - - virtualIp2 (string): The WAN 2 shared IP + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 14 days from today. + - t1 (string): The end of the timespan for the data. t1 can be a maximum of 14 days after t0. + - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 14 days. The default is 1 day. + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 50. Default is 10. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - networkIds (array): Optional parameter to filter Secure Routers in the provided networks + - serials (array): Optional parameter to filter Secure Routers by their serial numbers """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'warmSpare'], - 'operation': 'updateNetworkApplianceWarmSpare' + "tags": ["appliance", "monitor", "interfaces", "packets", "overviews", "byDevice"], + "operation": "getOrganizationApplianceInterfacesPacketsOverviewsByDevice", } - resource = f'/networks/{networkId}/appliance/warmSpare' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/interfaces/packets/overviews/byDevice" + + query_params = [ + "t0", + "t1", + "timespan", + "perPage", + "startingAfter", + "endingBefore", + "networkIds", + "serials", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - body_params = ['enabled', 'spareSerial', 'uplinkMode', 'virtualIp1', 'virtualIp2', ] - payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + array_params = [ + "networkIds", + "serials", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) - return self._session.put(metadata, resource, payload) + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceInterfacesPacketsOverviewsByDevice: ignoring unrecognized kwargs: {invalid}" + ) - def swapNetworkApplianceWarmSpare(self, networkId: str): + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationApplianceRoutingVrfsSettings(self, organizationId: str): """ - **Swap MX primary and warm spare appliances** - https://developer.cisco.com/meraki/api-v1/#!swap-network-appliance-warm-spare + **Return the VRF setting for an organization.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-routing-vrfs-settings - - networkId (string): (required) + - organizationId (string): Organization ID """ metadata = { - 'tags': ['appliance', 'configure', 'warmSpare'], - 'operation': 'swapNetworkApplianceWarmSpare' + "tags": ["appliance", "configure", "routing", "vrfs", "settings"], + "operation": "getOrganizationApplianceRoutingVrfsSettings", } - resource = f'/networks/{networkId}/appliance/warmSpare/swap' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/routing/vrfs/settings" - return self._session.post(metadata, resource) + return self._session.get(metadata, resource) + + def updateOrganizationApplianceRoutingVrfsSettings(self, organizationId: str, enabled: bool, **kwargs): + """ + **Update the VRF setting for an organization.** + https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-routing-vrfs-settings + + - organizationId (string): Organization ID + - enabled (boolean): Boolean indicating whether VRFs are enabled for the organization. + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "routing", "vrfs", "settings"], + "operation": "updateOrganizationApplianceRoutingVrfsSettings", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/routing/vrfs/settings" - def getOrganizationApplianceSecurityEvents(self, organizationId: str, total_pages=1, direction='next', **kwargs): + body_params = [ + "enabled", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationApplianceRoutingVrfsSettings: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def getOrganizationApplianceSecurityEvents(self, organizationId: str, total_pages=1, direction="next", **kwargs): """ **List the security events for an organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-security-events - - organizationId (string): (required) + - organizationId (string): Organization ID - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages - direction (string): direction to paginate, either "next" (default) or "prev" page - t0 (string): The beginning of the timespan for the data. Data is gathered after the specified t0 value. The maximum lookback period is 365 days from today. @@ -1389,19 +4186,38 @@ def getOrganizationApplianceSecurityEvents(self, organizationId: str, total_page kwargs.update(locals()) - if 'sortOrder' in kwargs: - options = ['ascending', 'descending'] - assert kwargs['sortOrder'] in options, f'''"sortOrder" cannot be "{kwargs['sortOrder']}", & must be set to one of: {options}''' + if "sortOrder" in kwargs: + options = ["ascending", "descending"] + assert kwargs["sortOrder"] in options, ( + f'''"sortOrder" cannot be "{kwargs["sortOrder"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['appliance', 'monitor', 'security', 'events'], - 'operation': 'getOrganizationApplianceSecurityEvents' + "tags": ["appliance", "monitor", "security", "events"], + "operation": "getOrganizationApplianceSecurityEvents", } - resource = f'/organizations/{organizationId}/appliance/security/events' - - query_params = ['t0', 't1', 'timespan', 'perPage', 'startingAfter', 'endingBefore', 'sortOrder', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/security/events" + + query_params = [ + "t0", + "t1", + "timespan", + "perPage", + "startingAfter", + "endingBefore", + "sortOrder", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceSecurityEvents: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get_pages(metadata, resource, params, total_pages, direction) def getOrganizationApplianceSecurityIntrusion(self, organizationId: str): @@ -1409,71 +4225,343 @@ def getOrganizationApplianceSecurityIntrusion(self, organizationId: str): **Returns all supported intrusion settings for an organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-security-intrusion - - organizationId (string): (required) + - organizationId (string): Organization ID """ metadata = { - 'tags': ['appliance', 'configure', 'security', 'intrusion'], - 'operation': 'getOrganizationApplianceSecurityIntrusion' + "tags": ["appliance", "configure", "security", "intrusion"], + "operation": "getOrganizationApplianceSecurityIntrusion", } - resource = f'/organizations/{organizationId}/appliance/security/intrusion' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/security/intrusion" return self._session.get(metadata, resource) - def updateOrganizationApplianceSecurityIntrusion(self, organizationId: str, allowedRules: list): + def updateOrganizationApplianceSecurityIntrusion(self, organizationId: str, allowedRules: list, **kwargs): """ **Sets supported intrusion settings for an organization** https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-security-intrusion - - organizationId (string): (required) - - allowedRules (array): Sets a list of specific SNORT® signatures to allow + - organizationId (string): Organization ID + - allowedRules (array): Sets a list of specific SNORT signatures to allow """ kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'security', 'intrusion'], - 'operation': 'updateOrganizationApplianceSecurityIntrusion' + "tags": ["appliance", "configure", "security", "intrusion"], + "operation": "updateOrganizationApplianceSecurityIntrusion", } - resource = f'/organizations/{organizationId}/appliance/security/intrusion' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/security/intrusion" - body_params = ['allowedRules', ] + body_params = [ + "allowedRules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationApplianceSecurityIntrusion: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) - def getOrganizationApplianceUplinkStatuses(self, organizationId: str, total_pages=1, direction='next', **kwargs): + def getOrganizationApplianceTrafficShapingVpnExclusionsByNetwork( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): + """ + **Display VPN exclusion rules for MX networks.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-traffic-shaping-vpn-exclusions-by-network + + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 50. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - networkIds (array): Optional parameter to filter the results by network IDs + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "trafficShaping", "vpnExclusions", "byNetwork"], + "operation": "getOrganizationApplianceTrafficShapingVpnExclusionsByNetwork", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/trafficShaping/vpnExclusions/byNetwork" + + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + "networkIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "networkIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceTrafficShapingVpnExclusionsByNetwork: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationApplianceUplinkStatuses(self, organizationId: str, total_pages=1, direction="next", **kwargs): """ **List the uplink status of every Meraki MX and Z series appliances in the organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-uplink-statuses - - organizationId (string): (required) + - organizationId (string): Organization ID - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages - direction (string): direction to paginate, either "next" (default) or "prev" page - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 1000. - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - networkIds (array): A list of network IDs. The returned devices will be filtered to only include these networks. + - serials (array): A list of serial numbers. The returned devices will be filtered to only include these serials. + - iccids (array): A list of ICCIDs. The returned devices will be filtered to only include these ICCIDs. """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'monitor', 'uplink', 'statuses'], - 'operation': 'getOrganizationApplianceUplinkStatuses' + "tags": ["appliance", "monitor", "uplinks", "statuses"], + "operation": "getOrganizationApplianceUplinkStatuses", } - resource = f'/organizations/{organizationId}/appliance/uplink/statuses' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/uplink/statuses" + + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + "networkIds", + "serials", + "iccids", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "networkIds", + "serials", + "iccids", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceUplinkStatuses: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationApplianceUplinksNatByNetwork(self, organizationId: str, total_pages=1, direction="next", **kwargs): + """ + **Fetch uplink NAT settings of each network in the organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-uplinks-nat-by-network + + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - networkIds (array): Optional parameter to filter the results by the included set of network IDs + - interfaces (array): Optional parameter to filter the results by the included set of interfaces + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 100000. Default is 1000. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + """ + + kwargs.update(locals()) - query_params = ['perPage', 'startingAfter', 'endingBefore', ] + metadata = { + "tags": ["appliance", "configure", "uplinks", "nat", "byNetwork"], + "operation": "getOrganizationApplianceUplinksNatByNetwork", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/uplinks/nat/byNetwork" + + query_params = [ + "networkIds", + "interfaces", + "perPage", + "startingAfter", + "endingBefore", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + array_params = [ + "networkIds", + "interfaces", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceUplinksNatByNetwork: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get_pages(metadata, resource, params, total_pages, direction) - def getOrganizationApplianceVpnStats(self, organizationId: str, total_pages=1, direction='next', **kwargs): + def getOrganizationApplianceUplinksStatusesOverview(self, organizationId: str, **kwargs): + """ + **Returns an overview of uplink statuses** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-uplinks-statuses-overview + + - organizationId (string): Organization ID + - networkIds (array): A list of network IDs. The returned devices will be filtered to only include these networks. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "monitor", "uplinks", "statuses", "overview"], + "operation": "getOrganizationApplianceUplinksStatusesOverview", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/uplinks/statuses/overview" + + query_params = [ + "networkIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "networkIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceUplinksStatusesOverview: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def getOrganizationApplianceUplinksUsageByNetwork(self, organizationId: str, **kwargs): + """ + **Get the sent and received bytes for each uplink of all MX and Z networks within an organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-uplinks-usage-by-network + + - organizationId (string): Organization ID + - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 30 days from today. + - t1 (string): The end of the timespan for the data. t1 can be a maximum of 14 days after t0. + - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 14 days. The default is 1 day. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "monitor", "uplinks", "usage", "byNetwork"], + "operation": "getOrganizationApplianceUplinksUsageByNetwork", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/uplinks/usage/byNetwork" + + query_params = [ + "t0", + "t1", + "timespan", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationApplianceUplinksUsageByNetwork: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def getOrganizationApplianceVpnSiteToSiteIpsecPeersSlas(self, organizationId: str): + """ + **Get the list of available IPsec SLA policies for an organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-vpn-site-to-site-ipsec-peers-slas + + - organizationId (string): Organization ID + """ + + metadata = { + "tags": ["appliance", "configure", "vpn", "siteToSite", "ipsec", "peers", "slas"], + "operation": "getOrganizationApplianceVpnSiteToSiteIpsecPeersSlas", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/siteToSite/ipsec/peers/slas" + + return self._session.get(metadata, resource) + + def updateOrganizationApplianceVpnSiteToSiteIpsecPeersSlas(self, organizationId: str, **kwargs): + """ + **Update the IPsec SLA policies for an organization** + https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-vpn-site-to-site-ipsec-peers-slas + + - organizationId (string): Organization ID + - items (array): List of IPsec SLA policies + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "vpn", "siteToSite", "ipsec", "peers", "slas"], + "operation": "updateOrganizationApplianceVpnSiteToSiteIpsecPeersSlas", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/siteToSite/ipsec/peers/slas" + + body_params = [ + "items", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationApplianceVpnSiteToSiteIpsecPeersSlas: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def getOrganizationApplianceVpnStats(self, organizationId: str, total_pages=1, direction="next", **kwargs): """ **Show VPN history stat for networks in an organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-vpn-stats - - organizationId (string): (required) + - organizationId (string): Organization ID - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages - direction (string): direction to paginate, either "next" (default) or "prev" page - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 300. Default is 300. @@ -1488,28 +4576,45 @@ def getOrganizationApplianceVpnStats(self, organizationId: str, total_pages=1, d kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'monitor', 'vpn', 'stats'], - 'operation': 'getOrganizationApplianceVpnStats' + "tags": ["appliance", "monitor", "vpn", "stats"], + "operation": "getOrganizationApplianceVpnStats", } - resource = f'/organizations/{organizationId}/appliance/vpn/stats' - - query_params = ['perPage', 'startingAfter', 'endingBefore', 'networkIds', 't0', 't1', 'timespan', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/stats" + + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + "networkIds", + "t0", + "t1", + "timespan", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['networkIds', ] + array_params = [ + "networkIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getOrganizationApplianceVpnStats: ignoring unrecognized kwargs: {invalid}") + return self._session.get_pages(metadata, resource, params, total_pages, direction) - def getOrganizationApplianceVpnStatuses(self, organizationId: str, total_pages=1, direction='next', **kwargs): + def getOrganizationApplianceVpnStatuses(self, organizationId: str, total_pages=1, direction="next", **kwargs): """ **Show VPN status for networks in an organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-vpn-statuses - - organizationId (string): (required) + - organizationId (string): Organization ID - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages - direction (string): direction to paginate, either "next" (default) or "prev" page - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 300. Default is 300. @@ -1521,20 +4626,34 @@ def getOrganizationApplianceVpnStatuses(self, organizationId: str, total_pages=1 kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'monitor', 'vpn', 'statuses'], - 'operation': 'getOrganizationApplianceVpnStatuses' + "tags": ["appliance", "monitor", "vpn", "statuses"], + "operation": "getOrganizationApplianceVpnStatuses", } - resource = f'/organizations/{organizationId}/appliance/vpn/statuses' - - query_params = ['perPage', 'startingAfter', 'endingBefore', 'networkIds', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/statuses" + + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + "networkIds", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['networkIds', ] + array_params = [ + "networkIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getOrganizationApplianceVpnStatuses: ignoring unrecognized kwargs: {invalid}") + return self._session.get_pages(metadata, resource, params, total_pages, direction) def getOrganizationApplianceVpnThirdPartyVPNPeers(self, organizationId: str): @@ -1542,37 +4661,49 @@ def getOrganizationApplianceVpnThirdPartyVPNPeers(self, organizationId: str): **Return the third party VPN peers for an organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-vpn-third-party-v-p-n-peers - - organizationId (string): (required) + - organizationId (string): Organization ID """ metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'thirdPartyVPNPeers'], - 'operation': 'getOrganizationApplianceVpnThirdPartyVPNPeers' + "tags": ["appliance", "configure", "vpn", "thirdPartyVPNPeers"], + "operation": "getOrganizationApplianceVpnThirdPartyVPNPeers", } - resource = f'/organizations/{organizationId}/appliance/vpn/thirdPartyVPNPeers' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/thirdPartyVPNPeers" return self._session.get(metadata, resource) - def updateOrganizationApplianceVpnThirdPartyVPNPeers(self, organizationId: str, peers: list): + def updateOrganizationApplianceVpnThirdPartyVPNPeers(self, organizationId: str, peers: list, **kwargs): """ **Update the third party VPN peers for an organization** https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-vpn-third-party-v-p-n-peers - - organizationId (string): (required) + - organizationId (string): Organization ID - peers (array): The list of VPN peers """ kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'thirdPartyVPNPeers'], - 'operation': 'updateOrganizationApplianceVpnThirdPartyVPNPeers' + "tags": ["appliance", "configure", "vpn", "thirdPartyVPNPeers"], + "operation": "updateOrganizationApplianceVpnThirdPartyVPNPeers", } - resource = f'/organizations/{organizationId}/appliance/vpn/thirdPartyVPNPeers' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/thirdPartyVPNPeers" - body_params = ['peers', ] + body_params = [ + "peers", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationApplianceVpnThirdPartyVPNPeers: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getOrganizationApplianceVpnVpnFirewallRules(self, organizationId: str): @@ -1580,14 +4711,15 @@ def getOrganizationApplianceVpnVpnFirewallRules(self, organizationId: str): **Return the firewall rules for an organization's site-to-site VPN** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-vpn-vpn-firewall-rules - - organizationId (string): (required) + - organizationId (string): Organization ID """ metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'vpnFirewallRules'], - 'operation': 'getOrganizationApplianceVpnVpnFirewallRules' + "tags": ["appliance", "configure", "vpn", "vpnFirewallRules"], + "operation": "getOrganizationApplianceVpnVpnFirewallRules", } - resource = f'/organizations/{organizationId}/appliance/vpn/vpnFirewallRules' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/vpnFirewallRules" return self._session.get(metadata, resource) @@ -1596,7 +4728,7 @@ def updateOrganizationApplianceVpnVpnFirewallRules(self, organizationId: str, ** **Update the firewall rules of an organization's site-to-site VPN** https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-vpn-vpn-firewall-rules - - organizationId (string): (required) + - organizationId (string): Organization ID - rules (array): An ordered array of the firewall rules (not including the default rule) - syslogDefaultRule (boolean): Log the special default rule (boolean value - enable only if you've configured a syslog server) (optional) """ @@ -1604,12 +4736,211 @@ def updateOrganizationApplianceVpnVpnFirewallRules(self, organizationId: str, ** kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'vpnFirewallRules'], - 'operation': 'updateOrganizationApplianceVpnVpnFirewallRules' + "tags": ["appliance", "configure", "vpn", "vpnFirewallRules"], + "operation": "updateOrganizationApplianceVpnVpnFirewallRules", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/vpnFirewallRules" + + body_params = [ + "rules", + "syslogDefaultRule", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationApplianceVpnVpnFirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def assignOrganizationPoliciesGlobalGroupPoliciesApplianceVlans( + self, organizationId: str, policy: dict, vlans: list, **kwargs + ): + """ + **Assign VLANs to a policy** + https://developer.cisco.com/meraki/api-v1/#!assign-organization-policies-global-group-policies-appliance-vlans + + - organizationId (string): Organization ID + - policy (object): Policy to assign VLANs to + - vlans (array): VLANs to assign + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "policies", "global", "group", "vlans"], + "operation": "assignOrganizationPoliciesGlobalGroupPoliciesApplianceVlans", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/policies/global/group/policies/appliance/vlans/assign" + + body_params = [ + "policy", + "vlans", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"assignOrganizationPoliciesGlobalGroupPoliciesApplianceVlans: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignments( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): + """ + **List appliance VLAN policy assignments** + https://developer.cisco.com/meraki/api-v1/#!get-organization-policies-global-group-policies-appliance-vlans-assignments + + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - assignmentIds (array): Filter assignments by assignment IDs + - policyIds (array): Filter assignments by policy IDs + - interfaceIds (array): Filter assignments by interface IDs + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 1000. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "policies", "global", "group", "vlans", "assignments"], + "operation": "getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignments", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/policies/global/group/policies/appliance/vlans/assignments" + + query_params = [ + "assignmentIds", + "policyIds", + "interfaceIds", + "perPage", + "startingAfter", + "endingBefore", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "assignmentIds", + "policyIds", + "interfaceIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignments: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignmentsByVlan( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): + """ + **List policies by appliance VLANs** + https://developer.cisco.com/meraki/api-v1/#!get-organization-policies-global-group-policies-appliance-vlans-assignments-by-vlan + + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - search (string): Search term for filtering policies + - vlanIds (array): Filter by VLAN IDs + - interfaceIds (array): Filter by interface IDs + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 100. Default is 100. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "policies", "global", "group", "vlans", "assignments", "byVlan"], + "operation": "getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignmentsByVlan", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/policies/global/group/policies/appliance/vlans/assignments/byVlan" + + query_params = [ + "search", + "vlanIds", + "interfaceIds", + "perPage", + "startingAfter", + "endingBefore", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "vlanIds", + "interfaceIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignmentsByVlan: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def removeOrganizationPoliciesGlobalGroupPoliciesApplianceVlans( + self, organizationId: str, policy: dict, vlans: list, **kwargs + ): + """ + **Remove VLANs from a policy** + https://developer.cisco.com/meraki/api-v1/#!remove-organization-policies-global-group-policies-appliance-vlans + + - organizationId (string): Organization ID + - policy (object): Policy to remove VLANs from + - vlans (array): VLANs to remove + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "policies", "global", "group", "vlans"], + "operation": "removeOrganizationPoliciesGlobalGroupPoliciesApplianceVlans", } - resource = f'/organizations/{organizationId}/appliance/vpn/vpnFirewallRules' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/policies/global/group/policies/appliance/vlans/remove" - body_params = ['rules', 'syslogDefaultRule', ] + body_params = [ + "policy", + "vlans", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) \ No newline at end of file + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"removeOrganizationPoliciesGlobalGroupPoliciesApplianceVlans: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) diff --git a/meraki/aio/api/camera.py b/meraki/aio/api/camera.py index 3db516ae..98e5c8ac 100644 --- a/meraki/aio/api/camera.py +++ b/meraki/aio/api/camera.py @@ -1,3 +1,6 @@ +import urllib + + class AsyncCamera: def __init__(self, session): super().__init__() @@ -5,17 +8,18 @@ def __init__(self, session): def getDeviceCameraAnalyticsLive(self, serial: str): """ - **Returns live state from camera of analytics zones** + **Returns live state from camera analytics zones** https://developer.cisco.com/meraki/api-v1/#!get-device-camera-analytics-live - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['camera', 'monitor', 'analytics', 'live'], - 'operation': 'getDeviceCameraAnalyticsLive' + "tags": ["camera", "monitor", "analytics", "live"], + "operation": "getDeviceCameraAnalyticsLive", } - resource = f'/devices/{serial}/camera/analytics/live' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/analytics/live" return self._session.get(metadata, resource) @@ -24,7 +28,7 @@ def getDeviceCameraAnalyticsOverview(self, serial: str, **kwargs): **Returns an overview of aggregate analytics data for a timespan** https://developer.cisco.com/meraki/api-v1/#!get-device-camera-analytics-overview - - serial (string): (required) + - serial (string): Serial - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 365 days from today. - t1 (string): The end of the timespan for the data. t1 can be a maximum of 7 days after t0. - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 7 days. The default is 1 hour. @@ -33,19 +37,33 @@ def getDeviceCameraAnalyticsOverview(self, serial: str, **kwargs): kwargs.update(locals()) - if 'objectType' in kwargs: - options = ['person', 'vehicle'] - assert kwargs['objectType'] in options, f'''"objectType" cannot be "{kwargs['objectType']}", & must be set to one of: {options}''' + if "objectType" in kwargs: + options = ["person", "vehicle"] + assert kwargs["objectType"] in options, ( + f'''"objectType" cannot be "{kwargs["objectType"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['camera', 'monitor', 'analytics', 'overview'], - 'operation': 'getDeviceCameraAnalyticsOverview' + "tags": ["camera", "monitor", "analytics", "overview"], + "operation": "getDeviceCameraAnalyticsOverview", } - resource = f'/devices/{serial}/camera/analytics/overview' - - query_params = ['t0', 't1', 'timespan', 'objectType', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/analytics/overview" + + query_params = [ + "t0", + "t1", + "timespan", + "objectType", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getDeviceCameraAnalyticsOverview: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) def getDeviceCameraAnalyticsRecent(self, serial: str, **kwargs): @@ -53,25 +71,36 @@ def getDeviceCameraAnalyticsRecent(self, serial: str, **kwargs): **Returns most recent record for analytics zones** https://developer.cisco.com/meraki/api-v1/#!get-device-camera-analytics-recent - - serial (string): (required) + - serial (string): Serial - objectType (string): [optional] The object type for which analytics will be retrieved. The default object type is person. The available types are [person, vehicle]. """ kwargs.update(locals()) - if 'objectType' in kwargs: - options = ['person', 'vehicle'] - assert kwargs['objectType'] in options, f'''"objectType" cannot be "{kwargs['objectType']}", & must be set to one of: {options}''' + if "objectType" in kwargs: + options = ["person", "vehicle"] + assert kwargs["objectType"] in options, ( + f'''"objectType" cannot be "{kwargs["objectType"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['camera', 'monitor', 'analytics', 'recent'], - 'operation': 'getDeviceCameraAnalyticsRecent' + "tags": ["camera", "monitor", "analytics", "recent"], + "operation": "getDeviceCameraAnalyticsRecent", } - resource = f'/devices/{serial}/camera/analytics/recent' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/analytics/recent" - query_params = ['objectType', ] + query_params = [ + "objectType", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getDeviceCameraAnalyticsRecent: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) def getDeviceCameraAnalyticsZones(self, serial: str): @@ -79,14 +108,15 @@ def getDeviceCameraAnalyticsZones(self, serial: str): **Returns all configured analytic zones for this camera** https://developer.cisco.com/meraki/api-v1/#!get-device-camera-analytics-zones - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['camera', 'monitor', 'analytics', 'zones'], - 'operation': 'getDeviceCameraAnalyticsZones' + "tags": ["camera", "monitor", "analytics", "zones"], + "operation": "getDeviceCameraAnalyticsZones", } - resource = f'/devices/{serial}/camera/analytics/zones' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/analytics/zones" return self._session.get(metadata, resource) @@ -95,8 +125,8 @@ def getDeviceCameraAnalyticsZoneHistory(self, serial: str, zoneId: str, **kwargs **Return historical records for analytic zones** https://developer.cisco.com/meraki/api-v1/#!get-device-camera-analytics-zone-history - - serial (string): (required) - - zoneId (string): (required) + - serial (string): Serial + - zoneId (string): Zone ID - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 365 days from today. - t1 (string): The end of the timespan for the data. t1 can be a maximum of 14 hours after t0. - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 14 hours. The default is 1 hour. @@ -106,27 +136,130 @@ def getDeviceCameraAnalyticsZoneHistory(self, serial: str, zoneId: str, **kwargs kwargs.update(locals()) - if 'objectType' in kwargs: - options = ['person', 'vehicle'] - assert kwargs['objectType'] in options, f'''"objectType" cannot be "{kwargs['objectType']}", & must be set to one of: {options}''' + if "objectType" in kwargs: + options = ["person", "vehicle"] + assert kwargs["objectType"] in options, ( + f'''"objectType" cannot be "{kwargs["objectType"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['camera', 'monitor', 'analytics', 'zones', 'history'], - 'operation': 'getDeviceCameraAnalyticsZoneHistory' + "tags": ["camera", "monitor", "analytics", "zones", "history"], + "operation": "getDeviceCameraAnalyticsZoneHistory", } - resource = f'/devices/{serial}/camera/analytics/zones/{zoneId}/history' + serial = urllib.parse.quote(str(serial), safe="") + zoneId = urllib.parse.quote(str(zoneId), safe="") + resource = f"/devices/{serial}/camera/analytics/zones/{zoneId}/history" + + query_params = [ + "t0", + "t1", + "timespan", + "resolution", + "objectType", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getDeviceCameraAnalyticsZoneHistory: ignoring unrecognized kwargs: {invalid}") - query_params = ['t0', 't1', 'timespan', 'resolution', 'objectType', ] + return self._session.get(metadata, resource, params) + + def clipDeviceCamera(self, serial: str, startTimestamp: str, endTimestamp: str, **kwargs): + """ + **Generate a video clip of up to 5 minutes long.** + https://developer.cisco.com/meraki/api-v1/#!clip-device-camera + + - serial (string): Serial + - startTimestamp (string): The start time for the clip. The timestamp is expected to be in ISO 8601 format. + - endTimestamp (string): The end time for the clip. The timestamp is expected to be in ISO 8601 format. + - imagerId (integer): The imager ID to query. Required for multi-imager cameras (must be between 1 and the imager count). For single-imager cameras, must be omitted or set to 0. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "monitor"], + "operation": "clipDeviceCamera", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/clip" + + query_params = [ + "startTimestamp", + "endTimestamp", + "imagerId", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"clipDeviceCamera: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) + def getDeviceCameraCustomAnalytics(self, serial: str): + """ + **Return custom analytics settings for a camera** + https://developer.cisco.com/meraki/api-v1/#!get-device-camera-custom-analytics + + - serial (string): Serial + """ + + metadata = { + "tags": ["camera", "configure", "customAnalytics"], + "operation": "getDeviceCameraCustomAnalytics", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/customAnalytics" + + return self._session.get(metadata, resource) + + def updateDeviceCameraCustomAnalytics(self, serial: str, **kwargs): + """ + **Update custom analytics settings for a camera** + https://developer.cisco.com/meraki/api-v1/#!update-device-camera-custom-analytics + + - serial (string): Serial + - enabled (boolean): Enable custom analytics + - artifactId (string): The ID of the custom analytics artifact + - parameters (array): Parameters for the custom analytics workload + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "customAnalytics"], + "operation": "updateDeviceCameraCustomAnalytics", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/customAnalytics" + + body_params = [ + "enabled", + "artifactId", + "parameters", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDeviceCameraCustomAnalytics: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + def generateDeviceCameraSnapshot(self, serial: str, **kwargs): """ **Generate a snapshot of what the camera sees at the specified time and return a link to that image.** https://developer.cisco.com/meraki/api-v1/#!generate-device-camera-snapshot - - serial (string): (required) + - serial (string): Serial - timestamp (string): [optional] The snapshot will be taken from this time on the camera. The timestamp is expected to be in ISO 8601 format. If no timestamp is specified, we will assume current time. - fullframe (boolean): [optional] If set to "true" the snapshot will be taken at full sensor resolution. This will error if used with timestamp. """ @@ -134,14 +267,24 @@ def generateDeviceCameraSnapshot(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['camera', 'monitor'], - 'operation': 'generateDeviceCameraSnapshot' + "tags": ["camera", "monitor"], + "operation": "generateDeviceCameraSnapshot", } - resource = f'/devices/{serial}/camera/generateSnapshot' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/generateSnapshot" - body_params = ['timestamp', 'fullframe', ] + body_params = [ + "timestamp", + "fullframe", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"generateDeviceCameraSnapshot: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getDeviceCameraQualityAndRetention(self, serial: str): @@ -149,14 +292,15 @@ def getDeviceCameraQualityAndRetention(self, serial: str): **Returns quality and retention settings for the given camera** https://developer.cisco.com/meraki/api-v1/#!get-device-camera-quality-and-retention - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['camera', 'configure', 'qualityAndRetention'], - 'operation': 'getDeviceCameraQualityAndRetention' + "tags": ["camera", "configure", "qualityAndRetention"], + "operation": "getDeviceCameraQualityAndRetention", } - resource = f'/devices/{serial}/camera/qualityAndRetention' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/qualityAndRetention" return self._session.get(metadata, resource) @@ -165,37 +309,60 @@ def updateDeviceCameraQualityAndRetention(self, serial: str, **kwargs): **Update quality and retention settings for the given camera** https://developer.cisco.com/meraki/api-v1/#!update-device-camera-quality-and-retention - - serial (string): (required) + - serial (string): Serial - profileId (string): The ID of a quality and retention profile to assign to the camera. The profile's settings will override all of the per-camera quality and retention settings. If the value of this parameter is null, any existing profile will be unassigned from the camera. - - motionBasedRetentionEnabled (boolean): Boolean indicating if motion-based retention is enabled(true) or disabled(false) on the camera + - motionBasedRetentionEnabled (boolean): Boolean indicating if motion-based retention is enabled(true) or disabled(false) on the camera. - audioRecordingEnabled (boolean): Boolean indicating if audio recording is enabled(true) or disabled(false) on the camera - - restrictedBandwidthModeEnabled (boolean): Boolean indicating if restricted bandwidth is enabled(true) or disabled(false) on the camera - - quality (string): Quality of the camera. Can be one of 'Standard', 'High' or 'Enhanced'. Not all qualities are supported by every camera model. - - resolution (string): Resolution of the camera. Can be one of '1280x720', '1920x1080', '1080x1080' or '2058x2058'. Not all resolutions are supported by every camera model. + - restrictedBandwidthModeEnabled (boolean): Boolean indicating if restricted bandwidth is enabled(true) or disabled(false) on the camera. This setting does not apply to MV2 cameras. + - quality (string): Quality of the camera. Can be one of 'Standard', 'High', 'Enhanced' or 'Ultra'. Not all qualities are supported by every camera model. + - resolution (string): Resolution of the camera. Can be one of '1280x720', '1920x1080', '1080x1080', '2112x2112', '2880x2880', '2688x1512' or '3840x2160'.Not all resolutions are supported by every camera model. - motionDetectorVersion (integer): The version of the motion detector that will be used by the camera. Only applies to Gen 2 cameras. Defaults to v2. """ kwargs.update(locals()) - if 'quality' in kwargs: - options = ['Standard', 'High', 'Enhanced'] - assert kwargs['quality'] in options, f'''"quality" cannot be "{kwargs['quality']}", & must be set to one of: {options}''' - if 'resolution' in kwargs: - options = ['1280x720', '1920x1080', '1080x1080', '2058x2058'] - assert kwargs['resolution'] in options, f'''"resolution" cannot be "{kwargs['resolution']}", & must be set to one of: {options}''' - if 'motionDetectorVersion' in kwargs: + if "quality" in kwargs: + options = ["Enhanced", "High", "Standard", "Ultra"] + assert kwargs["quality"] in options, ( + f'''"quality" cannot be "{kwargs["quality"]}", & must be set to one of: {options}''' + ) + if "resolution" in kwargs: + options = ["1080x1080", "1280x720", "1920x1080", "2112x2112", "2688x1512", "2880x2880", "3840x2160"] + assert kwargs["resolution"] in options, ( + f'''"resolution" cannot be "{kwargs["resolution"]}", & must be set to one of: {options}''' + ) + if "motionDetectorVersion" in kwargs: options = [1, 2] - assert kwargs['motionDetectorVersion'] in options, f'''"motionDetectorVersion" cannot be "{kwargs['motionDetectorVersion']}", & must be set to one of: {options}''' + assert kwargs["motionDetectorVersion"] in options, ( + f'''"motionDetectorVersion" cannot be "{kwargs["motionDetectorVersion"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['camera', 'configure', 'qualityAndRetention'], - 'operation': 'updateDeviceCameraQualityAndRetention' + "tags": ["camera", "configure", "qualityAndRetention"], + "operation": "updateDeviceCameraQualityAndRetention", } - resource = f'/devices/{serial}/camera/qualityAndRetention' - - body_params = ['profileId', 'motionBasedRetentionEnabled', 'audioRecordingEnabled', 'restrictedBandwidthModeEnabled', 'quality', 'resolution', 'motionDetectorVersion', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/qualityAndRetention" + + body_params = [ + "profileId", + "motionBasedRetentionEnabled", + "audioRecordingEnabled", + "restrictedBandwidthModeEnabled", + "quality", + "resolution", + "motionDetectorVersion", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateDeviceCameraQualityAndRetention: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getDeviceCameraSense(self, serial: str): @@ -203,14 +370,15 @@ def getDeviceCameraSense(self, serial: str): **Returns sense settings for a given camera** https://developer.cisco.com/meraki/api-v1/#!get-device-camera-sense - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['camera', 'configure', 'sense'], - 'operation': 'getDeviceCameraSense' + "tags": ["camera", "configure", "sense"], + "operation": "getDeviceCameraSense", } - resource = f'/devices/{serial}/camera/sense' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/sense" return self._session.get(metadata, resource) @@ -219,23 +387,36 @@ def updateDeviceCameraSense(self, serial: str, **kwargs): **Update sense settings for the given camera** https://developer.cisco.com/meraki/api-v1/#!update-device-camera-sense - - serial (string): (required) + - serial (string): Serial - senseEnabled (boolean): Boolean indicating if sense(license) is enabled(true) or disabled(false) on the camera - mqttBrokerId (string): The ID of the MQTT broker to be enabled on the camera. A value of null will disable MQTT on the camera + - audioDetection (object): The details of the audio detection config. - detectionModelId (string): The ID of the object detection model """ kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'sense'], - 'operation': 'updateDeviceCameraSense' + "tags": ["camera", "configure", "sense"], + "operation": "updateDeviceCameraSense", } - resource = f'/devices/{serial}/camera/sense' - - body_params = ['senseEnabled', 'mqttBrokerId', 'detectionModelId', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/sense" + + body_params = [ + "senseEnabled", + "mqttBrokerId", + "audioDetection", + "detectionModelId", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDeviceCameraSense: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getDeviceCameraSenseObjectDetectionModels(self, serial: str): @@ -243,14 +424,15 @@ def getDeviceCameraSenseObjectDetectionModels(self, serial: str): **Returns the MV Sense object detection model list for the given camera** https://developer.cisco.com/meraki/api-v1/#!get-device-camera-sense-object-detection-models - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['camera', 'configure', 'sense', 'objectDetectionModels'], - 'operation': 'getDeviceCameraSenseObjectDetectionModels' + "tags": ["camera", "configure", "sense", "objectDetectionModels"], + "operation": "getDeviceCameraSenseObjectDetectionModels", } - resource = f'/devices/{serial}/camera/sense/objectDetectionModels' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/sense/objectDetectionModels" return self._session.get(metadata, resource) @@ -259,14 +441,15 @@ def getDeviceCameraVideoSettings(self, serial: str): **Returns video settings for the given camera** https://developer.cisco.com/meraki/api-v1/#!get-device-camera-video-settings - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['camera', 'configure', 'video', 'settings'], - 'operation': 'getDeviceCameraVideoSettings' + "tags": ["camera", "configure", "video", "settings"], + "operation": "getDeviceCameraVideoSettings", } - resource = f'/devices/{serial}/camera/video/settings' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/video/settings" return self._session.get(metadata, resource) @@ -275,58 +458,125 @@ def updateDeviceCameraVideoSettings(self, serial: str, **kwargs): **Update video settings for the given camera** https://developer.cisco.com/meraki/api-v1/#!update-device-camera-video-settings - - serial (string): (required) + - serial (string): Serial - externalRtspEnabled (boolean): Boolean indicating if external rtsp stream is exposed """ kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'video', 'settings'], - 'operation': 'updateDeviceCameraVideoSettings' + "tags": ["camera", "configure", "video", "settings"], + "operation": "updateDeviceCameraVideoSettings", } - resource = f'/devices/{serial}/camera/video/settings' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/video/settings" - body_params = ['externalRtspEnabled', ] + body_params = [ + "externalRtspEnabled", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDeviceCameraVideoSettings: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getDeviceCameraVideoLink(self, serial: str, **kwargs): """ - **Returns video link to the specified camera. If a timestamp is supplied, it links to that timestamp.** + **Returns video link to the specified camera** https://developer.cisco.com/meraki/api-v1/#!get-device-camera-video-link - - serial (string): (required) + - serial (string): Serial - timestamp (string): [optional] The video link will start at this time. The timestamp should be a string in ISO8601 format. If no timestamp is specified, we will assume current time. """ kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'videoLink'], - 'operation': 'getDeviceCameraVideoLink' + "tags": ["camera", "configure", "videoLink"], + "operation": "getDeviceCameraVideoLink", } - resource = f'/devices/{serial}/camera/videoLink' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/videoLink" - query_params = ['timestamp', ] + query_params = [ + "timestamp", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getDeviceCameraVideoLink: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) + def getDeviceCameraWirelessProfiles(self, serial: str): + """ + **Returns wireless profile assigned to the given camera** + https://developer.cisco.com/meraki/api-v1/#!get-device-camera-wireless-profiles + + - serial (string): Serial + """ + + metadata = { + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "getDeviceCameraWirelessProfiles", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/wirelessProfiles" + + return self._session.get(metadata, resource) + + def updateDeviceCameraWirelessProfiles(self, serial: str, ids: dict, **kwargs): + """ + **Assign wireless profiles to the given camera** + https://developer.cisco.com/meraki/api-v1/#!update-device-camera-wireless-profiles + + - serial (string): Serial + - ids (object): The ids of the wireless profile to assign to the given camera + """ + + kwargs = locals() + + metadata = { + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "updateDeviceCameraWirelessProfiles", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/wirelessProfiles" + + body_params = [ + "ids", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDeviceCameraWirelessProfiles: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + def getNetworkCameraQualityRetentionProfiles(self, networkId: str): """ **List the quality retention profiles for this network** https://developer.cisco.com/meraki/api-v1/#!get-network-camera-quality-retention-profiles - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['camera', 'configure', 'qualityRetentionProfiles'], - 'operation': 'getNetworkCameraQualityRetentionProfiles' + "tags": ["camera", "configure", "qualityRetentionProfiles"], + "operation": "getNetworkCameraQualityRetentionProfiles", } - resource = f'/networks/{networkId}/camera/qualityRetentionProfiles' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/camera/qualityRetentionProfiles" return self._session.get(metadata, resource) @@ -335,29 +585,50 @@ def createNetworkCameraQualityRetentionProfile(self, networkId: str, name: str, **Creates new quality retention profile for this network.** https://developer.cisco.com/meraki/api-v1/#!create-network-camera-quality-retention-profile - - networkId (string): (required) + - networkId (string): Network ID - name (string): The name of the new profile. Must be unique. This parameter is required. - - motionBasedRetentionEnabled (boolean): Deletes footage older than 3 days in which no motion was detected. Can be either true or false. Defaults to false. - - restrictedBandwidthModeEnabled (boolean): Disable features that require additional bandwidth such as Motion Recap. Can be either true or false. Defaults to false. + - motionBasedRetentionEnabled (boolean): Deletes footage older than 3 days in which no motion was detected. Can be either true or false. Defaults to false. This setting does not apply to MV2 cameras. + - restrictedBandwidthModeEnabled (boolean): Disable features that require additional bandwidth such as Motion Recap. Can be either true or false. Defaults to false. This setting does not apply to MV2 cameras. - audioRecordingEnabled (boolean): Whether or not to record audio. Can be either true or false. Defaults to false. - cloudArchiveEnabled (boolean): Create redundant video backup using Cloud Archive. Can be either true or false. Defaults to false. - motionDetectorVersion (integer): The version of the motion detector that will be used by the camera. Only applies to Gen 2 cameras. Defaults to v2. + - smartRetention (object): Smart Retention records footage in two qualities and intelligently retains higher quality when motion, people or vehicles are detected. - scheduleId (string): Schedule for which this camera will record video, or 'null' to always record. - - maxRetentionDays (integer): The maximum number of days for which the data will be stored, or 'null' to keep data until storage space runs out. If the former, it can be one of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 30, 60, 90] days + - maxRetentionDays (integer): The maximum number of days for which the data will be stored, or 'null' to keep data until storage space runs out. If the former, it can be in the range of one to ninety days. - videoSettings (object): Video quality and resolution settings for all the camera models. """ kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'qualityRetentionProfiles'], - 'operation': 'createNetworkCameraQualityRetentionProfile' + "tags": ["camera", "configure", "qualityRetentionProfiles"], + "operation": "createNetworkCameraQualityRetentionProfile", } - resource = f'/networks/{networkId}/camera/qualityRetentionProfiles' - - body_params = ['name', 'motionBasedRetentionEnabled', 'restrictedBandwidthModeEnabled', 'audioRecordingEnabled', 'cloudArchiveEnabled', 'motionDetectorVersion', 'scheduleId', 'maxRetentionDays', 'videoSettings', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/camera/qualityRetentionProfiles" + + body_params = [ + "name", + "motionBasedRetentionEnabled", + "restrictedBandwidthModeEnabled", + "audioRecordingEnabled", + "cloudArchiveEnabled", + "motionDetectorVersion", + "smartRetention", + "scheduleId", + "maxRetentionDays", + "videoSettings", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createNetworkCameraQualityRetentionProfile: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def getNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRetentionProfileId: str): @@ -365,15 +636,17 @@ def getNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRetenti **Retrieve a single quality retention profile** https://developer.cisco.com/meraki/api-v1/#!get-network-camera-quality-retention-profile - - networkId (string): (required) - - qualityRetentionProfileId (string): (required) + - networkId (string): Network ID + - qualityRetentionProfileId (string): Quality retention profile ID """ metadata = { - 'tags': ['camera', 'configure', 'qualityRetentionProfiles'], - 'operation': 'getNetworkCameraQualityRetentionProfile' + "tags": ["camera", "configure", "qualityRetentionProfiles"], + "operation": "getNetworkCameraQualityRetentionProfile", } - resource = f'/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}' + networkId = urllib.parse.quote(str(networkId), safe="") + qualityRetentionProfileId = urllib.parse.quote(str(qualityRetentionProfileId), safe="") + resource = f"/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}" return self._session.get(metadata, resource) @@ -382,30 +655,52 @@ def updateNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRete **Update an existing quality retention profile for this network.** https://developer.cisco.com/meraki/api-v1/#!update-network-camera-quality-retention-profile - - networkId (string): (required) - - qualityRetentionProfileId (string): (required) + - networkId (string): Network ID + - qualityRetentionProfileId (string): Quality retention profile ID - name (string): The name of the new profile. Must be unique. - - motionBasedRetentionEnabled (boolean): Deletes footage older than 3 days in which no motion was detected. Can be either true or false. Defaults to false. - - restrictedBandwidthModeEnabled (boolean): Disable features that require additional bandwidth such as Motion Recap. Can be either true or false. Defaults to false. + - motionBasedRetentionEnabled (boolean): Deletes footage older than 3 days in which no motion was detected. Can be either true or false. Defaults to false. This setting does not apply to MV2 cameras. + - restrictedBandwidthModeEnabled (boolean): Disable features that require additional bandwidth such as Motion Recap. Can be either true or false. Defaults to false. This setting does not apply to MV2 cameras. - audioRecordingEnabled (boolean): Whether or not to record audio. Can be either true or false. Defaults to false. - cloudArchiveEnabled (boolean): Create redundant video backup using Cloud Archive. Can be either true or false. Defaults to false. - motionDetectorVersion (integer): The version of the motion detector that will be used by the camera. Only applies to Gen 2 cameras. Defaults to v2. + - smartRetention (object): Smart Retention records footage in two qualities and intelligently retains higher quality when motion, people or vehicles are detected. - scheduleId (string): Schedule for which this camera will record video, or 'null' to always record. - - maxRetentionDays (integer): The maximum number of days for which the data will be stored, or 'null' to keep data until storage space runs out. If the former, it can be one of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 30, 60, 90] days + - maxRetentionDays (integer): The maximum number of days for which the data will be stored, or 'null' to keep data until storage space runs out. If the former, it can be in the range of one to ninety days. - videoSettings (object): Video quality and resolution settings for all the camera models. """ kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'qualityRetentionProfiles'], - 'operation': 'updateNetworkCameraQualityRetentionProfile' + "tags": ["camera", "configure", "qualityRetentionProfiles"], + "operation": "updateNetworkCameraQualityRetentionProfile", } - resource = f'/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}' - - body_params = ['name', 'motionBasedRetentionEnabled', 'restrictedBandwidthModeEnabled', 'audioRecordingEnabled', 'cloudArchiveEnabled', 'motionDetectorVersion', 'scheduleId', 'maxRetentionDays', 'videoSettings', ] + networkId = urllib.parse.quote(str(networkId), safe="") + qualityRetentionProfileId = urllib.parse.quote(str(qualityRetentionProfileId), safe="") + resource = f"/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}" + + body_params = [ + "name", + "motionBasedRetentionEnabled", + "restrictedBandwidthModeEnabled", + "audioRecordingEnabled", + "cloudArchiveEnabled", + "motionDetectorVersion", + "smartRetention", + "scheduleId", + "maxRetentionDays", + "videoSettings", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkCameraQualityRetentionProfile: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def deleteNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRetentionProfileId: str): @@ -413,15 +708,17 @@ def deleteNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRete **Delete an existing quality retention profile for this network.** https://developer.cisco.com/meraki/api-v1/#!delete-network-camera-quality-retention-profile - - networkId (string): (required) - - qualityRetentionProfileId (string): (required) + - networkId (string): Network ID + - qualityRetentionProfileId (string): Quality retention profile ID """ metadata = { - 'tags': ['camera', 'configure', 'qualityRetentionProfiles'], - 'operation': 'deleteNetworkCameraQualityRetentionProfile' + "tags": ["camera", "configure", "qualityRetentionProfiles"], + "operation": "deleteNetworkCameraQualityRetentionProfile", } - resource = f'/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}' + networkId = urllib.parse.quote(str(networkId), safe="") + qualityRetentionProfileId = urllib.parse.quote(str(qualityRetentionProfileId), safe="") + resource = f"/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}" return self._session.delete(metadata, resource) @@ -430,13 +727,612 @@ def getNetworkCameraSchedules(self, networkId: str): **Returns a list of all camera recording schedules.** https://developer.cisco.com/meraki/api-v1/#!get-network-camera-schedules - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['camera', 'configure', 'schedules'], - 'operation': 'getNetworkCameraSchedules' + "tags": ["camera", "configure", "schedules"], + "operation": "getNetworkCameraSchedules", } - resource = f'/networks/{networkId}/camera/schedules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/camera/schedules" + + return self._session.get(metadata, resource) - return self._session.get(metadata, resource) \ No newline at end of file + def createNetworkCameraWirelessProfile(self, networkId: str, name: str, ssid: dict, **kwargs): + """ + **Creates a new camera wireless profile for this network.** + https://developer.cisco.com/meraki/api-v1/#!create-network-camera-wireless-profile + + - networkId (string): Network ID + - name (string): The name of the camera wireless profile. This parameter is required. + - ssid (object): The details of the SSID config. + - identity (object): The identity of the wireless profile. Required for creating wireless profiles in 8021x-radius auth mode. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "createNetworkCameraWirelessProfile", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/camera/wirelessProfiles" + + body_params = [ + "name", + "ssid", + "identity", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createNetworkCameraWirelessProfile: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getNetworkCameraWirelessProfiles(self, networkId: str): + """ + **List the camera wireless profiles for this network.** + https://developer.cisco.com/meraki/api-v1/#!get-network-camera-wireless-profiles + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "getNetworkCameraWirelessProfiles", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/camera/wirelessProfiles" + + return self._session.get(metadata, resource) + + def getNetworkCameraWirelessProfile(self, networkId: str, wirelessProfileId: str): + """ + **Retrieve a single camera wireless profile.** + https://developer.cisco.com/meraki/api-v1/#!get-network-camera-wireless-profile + + - networkId (string): Network ID + - wirelessProfileId (string): Wireless profile ID + """ + + metadata = { + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "getNetworkCameraWirelessProfile", + } + networkId = urllib.parse.quote(str(networkId), safe="") + wirelessProfileId = urllib.parse.quote(str(wirelessProfileId), safe="") + resource = f"/networks/{networkId}/camera/wirelessProfiles/{wirelessProfileId}" + + return self._session.get(metadata, resource) + + def updateNetworkCameraWirelessProfile(self, networkId: str, wirelessProfileId: str, **kwargs): + """ + **Update an existing camera wireless profile in this network.** + https://developer.cisco.com/meraki/api-v1/#!update-network-camera-wireless-profile + + - networkId (string): Network ID + - wirelessProfileId (string): Wireless profile ID + - name (string): The name of the camera wireless profile. + - ssid (object): The details of the SSID config. + - identity (object): The identity of the wireless profile. Required for creating wireless profiles in 8021x-radius auth mode. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "updateNetworkCameraWirelessProfile", + } + networkId = urllib.parse.quote(str(networkId), safe="") + wirelessProfileId = urllib.parse.quote(str(wirelessProfileId), safe="") + resource = f"/networks/{networkId}/camera/wirelessProfiles/{wirelessProfileId}" + + body_params = [ + "name", + "ssid", + "identity", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkCameraWirelessProfile: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def deleteNetworkCameraWirelessProfile(self, networkId: str, wirelessProfileId: str): + """ + **Delete an existing camera wireless profile for this network.** + https://developer.cisco.com/meraki/api-v1/#!delete-network-camera-wireless-profile + + - networkId (string): Network ID + - wirelessProfileId (string): Wireless profile ID + """ + + metadata = { + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "deleteNetworkCameraWirelessProfile", + } + networkId = urllib.parse.quote(str(networkId), safe="") + wirelessProfileId = urllib.parse.quote(str(wirelessProfileId), safe="") + resource = f"/networks/{networkId}/camera/wirelessProfiles/{wirelessProfileId}" + + return self._session.delete(metadata, resource) + + def getOrganizationCameraBoundariesAreasByDevice(self, organizationId: str, **kwargs): + """ + **Returns all configured area boundaries of cameras** + https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-boundaries-areas-by-device + + - organizationId (string): Organization ID + - serials (array): A list of serial numbers. The returned cameras will be filtered to only include these serials. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "boundaries", "areas", "byDevice"], + "operation": "getOrganizationCameraBoundariesAreasByDevice", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/boundaries/areas/byDevice" + + query_params = [ + "serials", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "serials", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationCameraBoundariesAreasByDevice: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def getOrganizationCameraBoundariesLinesByDevice(self, organizationId: str, **kwargs): + """ + **Returns all configured crossingline boundaries of cameras** + https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-boundaries-lines-by-device + + - organizationId (string): Organization ID + - serials (array): A list of serial numbers. The returned cameras will be filtered to only include these serials. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "boundaries", "lines", "byDevice"], + "operation": "getOrganizationCameraBoundariesLinesByDevice", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/boundaries/lines/byDevice" + + query_params = [ + "serials", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "serials", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationCameraBoundariesLinesByDevice: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def getOrganizationCameraCustomAnalyticsArtifacts(self, organizationId: str): + """ + **List Custom Analytics Artifacts** + https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-custom-analytics-artifacts + + - organizationId (string): Organization ID + """ + + metadata = { + "tags": ["camera", "configure", "customAnalytics", "artifacts"], + "operation": "getOrganizationCameraCustomAnalyticsArtifacts", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/customAnalytics/artifacts" + + return self._session.get(metadata, resource) + + def createOrganizationCameraCustomAnalyticsArtifact(self, organizationId: str, **kwargs): + """ + **Create custom analytics artifact** + https://developer.cisco.com/meraki/api-v1/#!create-organization-camera-custom-analytics-artifact + + - organizationId (string): Organization ID + - name (string): Unique name of the artifact + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "customAnalytics", "artifacts"], + "operation": "createOrganizationCameraCustomAnalyticsArtifact", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/customAnalytics/artifacts" + + body_params = [ + "name", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createOrganizationCameraCustomAnalyticsArtifact: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getOrganizationCameraCustomAnalyticsArtifact(self, organizationId: str, artifactId: str): + """ + **Get Custom Analytics Artifact** + https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-custom-analytics-artifact + + - organizationId (string): Organization ID + - artifactId (string): Artifact ID + """ + + metadata = { + "tags": ["camera", "configure", "customAnalytics", "artifacts"], + "operation": "getOrganizationCameraCustomAnalyticsArtifact", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + artifactId = urllib.parse.quote(str(artifactId), safe="") + resource = f"/organizations/{organizationId}/camera/customAnalytics/artifacts/{artifactId}" + + return self._session.get(metadata, resource) + + def deleteOrganizationCameraCustomAnalyticsArtifact(self, organizationId: str, artifactId: str): + """ + **Delete Custom Analytics Artifact** + https://developer.cisco.com/meraki/api-v1/#!delete-organization-camera-custom-analytics-artifact + + - organizationId (string): Organization ID + - artifactId (string): Artifact ID + """ + + metadata = { + "tags": ["camera", "configure", "customAnalytics", "artifacts"], + "operation": "deleteOrganizationCameraCustomAnalyticsArtifact", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + artifactId = urllib.parse.quote(str(artifactId), safe="") + resource = f"/organizations/{organizationId}/camera/customAnalytics/artifacts/{artifactId}" + + return self._session.delete(metadata, resource) + + def getOrganizationCameraDetectionsHistoryByBoundaryByInterval( + self, organizationId: str, boundaryIds: list, ranges: list, total_pages=1, direction="next", **kwargs + ): + """ + **Returns analytics data for timespans** + https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-detections-history-by-boundary-by-interval + + - organizationId (string): Organization ID + - boundaryIds (array): A list of boundary ids. The returned cameras will be filtered to only include these ids. + - ranges (array): A list of time ranges with intervals + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - duration (integer): The minimum time, in seconds, that the person or car remains in the area to be counted. Defaults to boundary configuration or 60. + - perPage (integer): The number of entries per page returned. Acceptable range is 1 - 1000. Defaults to 1000. + - boundaryTypes (array): The detection types. Defaults to 'person'. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "detections", "history", "byBoundary", "byInterval"], + "operation": "getOrganizationCameraDetectionsHistoryByBoundaryByInterval", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/detections/history/byBoundary/byInterval" + + query_params = [ + "boundaryIds", + "ranges", + "duration", + "perPage", + "boundaryTypes", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "boundaryIds", + "ranges", + "boundaryTypes", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationCameraDetectionsHistoryByBoundaryByInterval: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationCameraOnboardingStatuses(self, organizationId: str, **kwargs): + """ + **Fetch onboarding status of cameras** + https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-onboarding-statuses + + - organizationId (string): Organization ID + - serials (array): A list of serial numbers. The returned cameras will be filtered to only include these serials. + - networkIds (array): A list of network IDs. The returned cameras will be filtered to only include these networks. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "onboarding", "statuses"], + "operation": "getOrganizationCameraOnboardingStatuses", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/onboarding/statuses" + + query_params = [ + "serials", + "networkIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "serials", + "networkIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationCameraOnboardingStatuses: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def updateOrganizationCameraOnboardingStatuses(self, organizationId: str, **kwargs): + """ + **Notify that credential handoff to camera has completed** + https://developer.cisco.com/meraki/api-v1/#!update-organization-camera-onboarding-statuses + + - organizationId (string): Organization ID + - serial (string): Serial of camera + - wirelessCredentialsSent (boolean): Note whether credentials were sent successfully + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "onboarding", "statuses"], + "operation": "updateOrganizationCameraOnboardingStatuses", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/onboarding/statuses" + + body_params = [ + "serial", + "wirelessCredentialsSent", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationCameraOnboardingStatuses: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def getOrganizationCameraPermissions(self, organizationId: str): + """ + **List the permissions scopes for this organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-permissions + + - organizationId (string): Organization ID + """ + + metadata = { + "tags": ["camera", "configure", "permissions"], + "operation": "getOrganizationCameraPermissions", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/permissions" + + return self._session.get(metadata, resource) + + def getOrganizationCameraPermission(self, organizationId: str, permissionScopeId: str): + """ + **Retrieve a single permission scope** + https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-permission + + - organizationId (string): Organization ID + - permissionScopeId (string): Permission scope ID + """ + + metadata = { + "tags": ["camera", "configure", "permissions"], + "operation": "getOrganizationCameraPermission", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + permissionScopeId = urllib.parse.quote(str(permissionScopeId), safe="") + resource = f"/organizations/{organizationId}/camera/permissions/{permissionScopeId}" + + return self._session.get(metadata, resource) + + def getOrganizationCameraRoles(self, organizationId: str): + """ + **List all the roles in this organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-roles + + - organizationId (string): Organization ID + """ + + metadata = { + "tags": ["camera", "configure", "roles"], + "operation": "getOrganizationCameraRoles", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/roles" + + return self._session.get(metadata, resource) + + def createOrganizationCameraRole(self, organizationId: str, name: str, **kwargs): + """ + **Creates new role for this organization.** + https://developer.cisco.com/meraki/api-v1/#!create-organization-camera-role + + - organizationId (string): Organization ID + - name (string): The name of the new role. Must be unique. This parameter is required. + - appliedOnDevices (array): Device tag on which this specified permission is applied. + - appliedOnNetworks (array): Network tag on which this specified permission is applied. + - appliedOrgWide (array): Permissions to be applied org wide. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "roles"], + "operation": "createOrganizationCameraRole", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/roles" + + body_params = [ + "name", + "appliedOnDevices", + "appliedOnNetworks", + "appliedOrgWide", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createOrganizationCameraRole: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getOrganizationCameraRole(self, organizationId: str, roleId: str): + """ + **Retrieve a single role.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-role + + - organizationId (string): Organization ID + - roleId (string): Role ID + """ + + metadata = { + "tags": ["camera", "configure", "roles"], + "operation": "getOrganizationCameraRole", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + roleId = urllib.parse.quote(str(roleId), safe="") + resource = f"/organizations/{organizationId}/camera/roles/{roleId}" + + return self._session.get(metadata, resource) + + def deleteOrganizationCameraRole(self, organizationId: str, roleId: str): + """ + **Delete an existing role for this organization.** + https://developer.cisco.com/meraki/api-v1/#!delete-organization-camera-role + + - organizationId (string): Organization ID + - roleId (string): Role ID + """ + + metadata = { + "tags": ["camera", "configure", "roles"], + "operation": "deleteOrganizationCameraRole", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + roleId = urllib.parse.quote(str(roleId), safe="") + resource = f"/organizations/{organizationId}/camera/roles/{roleId}" + + return self._session.delete(metadata, resource) + + def updateOrganizationCameraRole(self, organizationId: str, roleId: str, **kwargs): + """ + **Update an existing role in this organization.** + https://developer.cisco.com/meraki/api-v1/#!update-organization-camera-role + + - organizationId (string): Organization ID + - roleId (string): Role ID + - name (string): The name of the new role. Must be unique. + - appliedOnDevices (array): Device tag on which this specified permission is applied. + - appliedOnNetworks (array): Network tag on which this specified permission is applied. + - appliedOrgWide (array): Permissions to be applied org wide. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["camera", "configure", "roles"], + "operation": "updateOrganizationCameraRole", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + roleId = urllib.parse.quote(str(roleId), safe="") + resource = f"/organizations/{organizationId}/camera/roles/{roleId}" + + body_params = [ + "name", + "appliedOnDevices", + "appliedOnNetworks", + "appliedOrgWide", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateOrganizationCameraRole: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) diff --git a/meraki/aio/api/campusGateway.py b/meraki/aio/api/campusGateway.py new file mode 100644 index 00000000..ed7cee55 --- /dev/null +++ b/meraki/aio/api/campusGateway.py @@ -0,0 +1,195 @@ +import urllib + + +class AsyncCampusGateway: + def __init__(self, session): + super().__init__() + self._session = session + + def createNetworkCampusGatewayCluster( + self, networkId: str, name: str, uplinks: list, tunnels: list, nameservers: dict, portChannels: list, **kwargs + ): + """ + **Create a cluster and add campus gateways to it** + https://developer.cisco.com/meraki/api-v1/#!create-network-campus-gateway-cluster + + - networkId (string): Network ID + - name (string): Name of the new cluster + - uplinks (array): Uplink interface settings of the cluster + - tunnels (array): Tunnel interface settings of the cluster: Reuse uplink or specify tunnel interface + - nameservers (object): Nameservers of the cluster + - portChannels (array): Port channel settings of the cluster + - devices (array): Devices to be added to the cluster + - notes (string): Notes about cluster with max size of 511 characters allowed + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["campusGateway", "configure", "clusters"], + "operation": "createNetworkCampusGatewayCluster", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/campusGateway/clusters" + + body_params = [ + "name", + "uplinks", + "tunnels", + "nameservers", + "portChannels", + "devices", + "notes", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createNetworkCampusGatewayCluster: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def updateNetworkCampusGatewayCluster(self, networkId: str, clusterId: str, **kwargs): + """ + **Update a cluster and add/remove campus gateways to/from it** + https://developer.cisco.com/meraki/api-v1/#!update-network-campus-gateway-cluster + + - networkId (string): Network ID + - clusterId (string): Cluster ID + - name (string): Name of the cluster + - uplinks (array): Uplink interface settings of the cluster + - tunnels (array): Tunnel interface settings of the cluster: Reuse uplink or specify tunnel interface + - nameservers (object): Nameservers of the cluster + - portChannels (array): Port channel settings of the cluster + - devices (array): Devices in the cluster + - notes (string): Notes about cluster with max size of 511 characters allowed + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["campusGateway", "configure", "clusters"], + "operation": "updateNetworkCampusGatewayCluster", + } + networkId = urllib.parse.quote(str(networkId), safe="") + clusterId = urllib.parse.quote(str(clusterId), safe="") + resource = f"/networks/{networkId}/campusGateway/clusters/{clusterId}" + + body_params = [ + "name", + "uplinks", + "tunnels", + "nameservers", + "portChannels", + "devices", + "notes", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkCampusGatewayCluster: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def getOrganizationCampusGatewayClusters(self, organizationId: str, total_pages=1, direction="next", **kwargs): + """ + **Get the details of campus gateway clusters** + https://developer.cisco.com/meraki/api-v1/#!get-organization-campus-gateway-clusters + + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - networkIds (array): Networks for which information should be gathered. + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 100. Default is 50. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["campusGateway", "configure", "clusters"], + "operation": "getOrganizationCampusGatewayClusters", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/campusGateway/clusters" + + query_params = [ + "networkIds", + "perPage", + "startingAfter", + "endingBefore", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "networkIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getOrganizationCampusGatewayClusters: ignoring unrecognized kwargs: {invalid}") + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationCampusGatewayDevicesUplinksLocalOverridesByDevice( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): + """ + **Uplink overrides configured locally on Campus Gateway devices in an organization.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-campus-gateway-devices-uplinks-local-overrides-by-device + + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - serials (array): A list of serial numbers. The returned devices will be filtered to only include these serials. + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 1000. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["campusGateway", "configure", "devices", "uplinks", "localOverrides", "byDevice"], + "operation": "getOrganizationCampusGatewayDevicesUplinksLocalOverridesByDevice", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/campusGateway/devices/uplinks/localOverrides/byDevice" + + query_params = [ + "serials", + "perPage", + "startingAfter", + "endingBefore", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "serials", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationCampusGatewayDevicesUplinksLocalOverridesByDevice: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) diff --git a/meraki/aio/api/cellularGateway.py b/meraki/aio/api/cellularGateway.py index 9a3105e5..8dd32f04 100644 --- a/meraki/aio/api/cellularGateway.py +++ b/meraki/aio/api/cellularGateway.py @@ -1,3 +1,6 @@ +import urllib + + class AsyncCellularGateway: def __init__(self, session): super().__init__() @@ -8,14 +11,15 @@ def getDeviceCellularGatewayLan(self, serial: str): **Show the LAN Settings of a MG** https://developer.cisco.com/meraki/api-v1/#!get-device-cellular-gateway-lan - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['cellularGateway', 'configure', 'lan'], - 'operation': 'getDeviceCellularGatewayLan' + "tags": ["cellularGateway", "configure", "lan"], + "operation": "getDeviceCellularGatewayLan", } - resource = f'/devices/{serial}/cellularGateway/lan' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellularGateway/lan" return self._session.get(metadata, resource) @@ -24,7 +28,7 @@ def updateDeviceCellularGatewayLan(self, serial: str, **kwargs): **Update the LAN Settings for a single MG.** https://developer.cisco.com/meraki/api-v1/#!update-device-cellular-gateway-lan - - serial (string): (required) + - serial (string): Serial - reservedIpRanges (array): list of all reserved IP ranges for a single MG - fixedIpAssignments (array): list of all fixed IP assignments for a single MG """ @@ -32,14 +36,24 @@ def updateDeviceCellularGatewayLan(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'lan'], - 'operation': 'updateDeviceCellularGatewayLan' + "tags": ["cellularGateway", "configure", "lan"], + "operation": "updateDeviceCellularGatewayLan", } - resource = f'/devices/{serial}/cellularGateway/lan' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellularGateway/lan" - body_params = ['reservedIpRanges', 'fixedIpAssignments', ] + body_params = [ + "reservedIpRanges", + "fixedIpAssignments", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDeviceCellularGatewayLan: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getDeviceCellularGatewayPortForwardingRules(self, serial: str): @@ -47,14 +61,15 @@ def getDeviceCellularGatewayPortForwardingRules(self, serial: str): **Returns the port forwarding rules for a single MG.** https://developer.cisco.com/meraki/api-v1/#!get-device-cellular-gateway-port-forwarding-rules - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['cellularGateway', 'configure', 'portForwardingRules'], - 'operation': 'getDeviceCellularGatewayPortForwardingRules' + "tags": ["cellularGateway", "configure", "portForwardingRules"], + "operation": "getDeviceCellularGatewayPortForwardingRules", } - resource = f'/devices/{serial}/cellularGateway/portForwardingRules' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellularGateway/portForwardingRules" return self._session.get(metadata, resource) @@ -63,21 +78,32 @@ def updateDeviceCellularGatewayPortForwardingRules(self, serial: str, **kwargs): **Updates the port forwarding rules for a single MG.** https://developer.cisco.com/meraki/api-v1/#!update-device-cellular-gateway-port-forwarding-rules - - serial (string): (required) + - serial (string): Serial - rules (array): An array of port forwarding params """ kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'portForwardingRules'], - 'operation': 'updateDeviceCellularGatewayPortForwardingRules' + "tags": ["cellularGateway", "configure", "portForwardingRules"], + "operation": "updateDeviceCellularGatewayPortForwardingRules", } - resource = f'/devices/{serial}/cellularGateway/portForwardingRules' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellularGateway/portForwardingRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateDeviceCellularGatewayPortForwardingRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkCellularGatewayConnectivityMonitoringDestinations(self, networkId: str): @@ -85,14 +111,15 @@ def getNetworkCellularGatewayConnectivityMonitoringDestinations(self, networkId: **Return the connectivity testing destinations for an MG network** https://developer.cisco.com/meraki/api-v1/#!get-network-cellular-gateway-connectivity-monitoring-destinations - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['cellularGateway', 'configure', 'connectivityMonitoringDestinations'], - 'operation': 'getNetworkCellularGatewayConnectivityMonitoringDestinations' + "tags": ["cellularGateway", "configure", "connectivityMonitoringDestinations"], + "operation": "getNetworkCellularGatewayConnectivityMonitoringDestinations", } - resource = f'/networks/{networkId}/cellularGateway/connectivityMonitoringDestinations' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/connectivityMonitoringDestinations" return self._session.get(metadata, resource) @@ -101,21 +128,32 @@ def updateNetworkCellularGatewayConnectivityMonitoringDestinations(self, network **Update the connectivity testing destinations for an MG network** https://developer.cisco.com/meraki/api-v1/#!update-network-cellular-gateway-connectivity-monitoring-destinations - - networkId (string): (required) + - networkId (string): Network ID - destinations (array): The list of connectivity monitoring destinations """ kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'connectivityMonitoringDestinations'], - 'operation': 'updateNetworkCellularGatewayConnectivityMonitoringDestinations' + "tags": ["cellularGateway", "configure", "connectivityMonitoringDestinations"], + "operation": "updateNetworkCellularGatewayConnectivityMonitoringDestinations", } - resource = f'/networks/{networkId}/cellularGateway/connectivityMonitoringDestinations' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/connectivityMonitoringDestinations" - body_params = ['destinations', ] + body_params = [ + "destinations", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkCellularGatewayConnectivityMonitoringDestinations: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkCellularGatewayDhcp(self, networkId: str): @@ -123,14 +161,15 @@ def getNetworkCellularGatewayDhcp(self, networkId: str): **List common DHCP settings of MGs** https://developer.cisco.com/meraki/api-v1/#!get-network-cellular-gateway-dhcp - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['cellularGateway', 'configure', 'dhcp'], - 'operation': 'getNetworkCellularGatewayDhcp' + "tags": ["cellularGateway", "configure", "dhcp"], + "operation": "getNetworkCellularGatewayDhcp", } - resource = f'/networks/{networkId}/cellularGateway/dhcp' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/dhcp" return self._session.get(metadata, resource) @@ -139,23 +178,34 @@ def updateNetworkCellularGatewayDhcp(self, networkId: str, **kwargs): **Update common DHCP settings of MGs** https://developer.cisco.com/meraki/api-v1/#!update-network-cellular-gateway-dhcp - - networkId (string): (required) - - dhcpLeaseTime (string): DHCP Lease time for all MG of the network. It can be '30 minutes', '1 hour', '4 hours', '12 hours', '1 day' or '1 week'. - - dnsNameservers (string): DNS name servers mode for all MG of the network. It can take 4 different values: 'upstream_dns', 'google_dns', 'opendns', 'custom'. - - dnsCustomNameservers (array): list of fixed IP representing the the DNS Name servers when the mode is 'custom' + - networkId (string): Network ID + - dhcpLeaseTime (string): DHCP Lease time for all MG of the network. Possible values are '30 minutes', '1 hour', '4 hours', '12 hours', '1 day' or '1 week'. + - dnsNameservers (string): DNS name servers mode for all MG of the network. Possible values are: 'upstream_dns', 'google_dns', 'opendns', 'custom'. + - dnsCustomNameservers (array): list of fixed IPs representing the the DNS Name servers when the mode is 'custom' """ kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'dhcp'], - 'operation': 'updateNetworkCellularGatewayDhcp' + "tags": ["cellularGateway", "configure", "dhcp"], + "operation": "updateNetworkCellularGatewayDhcp", } - resource = f'/networks/{networkId}/cellularGateway/dhcp' - - body_params = ['dhcpLeaseTime', 'dnsNameservers', 'dnsCustomNameservers', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/dhcp" + + body_params = [ + "dhcpLeaseTime", + "dnsNameservers", + "dnsCustomNameservers", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkCellularGatewayDhcp: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getNetworkCellularGatewaySubnetPool(self, networkId: str): @@ -163,14 +213,15 @@ def getNetworkCellularGatewaySubnetPool(self, networkId: str): **Return the subnet pool and mask configured for MGs in the network.** https://developer.cisco.com/meraki/api-v1/#!get-network-cellular-gateway-subnet-pool - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['cellularGateway', 'configure', 'subnetPool'], - 'operation': 'getNetworkCellularGatewaySubnetPool' + "tags": ["cellularGateway", "configure", "subnetPool"], + "operation": "getNetworkCellularGatewaySubnetPool", } - resource = f'/networks/{networkId}/cellularGateway/subnetPool' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/subnetPool" return self._session.get(metadata, resource) @@ -179,7 +230,7 @@ def updateNetworkCellularGatewaySubnetPool(self, networkId: str, **kwargs): **Update the subnet pool and mask configuration for MGs in the network.** https://developer.cisco.com/meraki/api-v1/#!update-network-cellular-gateway-subnet-pool - - networkId (string): (required) + - networkId (string): Network ID - mask (integer): Mask used for the subnet of all MGs in this network. - cidr (string): CIDR of the pool of subnets. Each MG in this network will automatically pick a subnet from this pool. """ @@ -187,14 +238,26 @@ def updateNetworkCellularGatewaySubnetPool(self, networkId: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'subnetPool'], - 'operation': 'updateNetworkCellularGatewaySubnetPool' + "tags": ["cellularGateway", "configure", "subnetPool"], + "operation": "updateNetworkCellularGatewaySubnetPool", } - resource = f'/networks/{networkId}/cellularGateway/subnetPool' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/subnetPool" - body_params = ['mask', 'cidr', ] + body_params = [ + "mask", + "cidr", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateNetworkCellularGatewaySubnetPool: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkCellularGatewayUplink(self, networkId: str): @@ -202,14 +265,15 @@ def getNetworkCellularGatewayUplink(self, networkId: str): **Returns the uplink settings for your MG network.** https://developer.cisco.com/meraki/api-v1/#!get-network-cellular-gateway-uplink - - networkId (string): (required) + - networkId (string): Network ID """ metadata = { - 'tags': ['cellularGateway', 'configure', 'uplink'], - 'operation': 'getNetworkCellularGatewayUplink' + "tags": ["cellularGateway", "configure", "uplink"], + "operation": "getNetworkCellularGatewayUplink", } - resource = f'/networks/{networkId}/cellularGateway/uplink' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/uplink" return self._session.get(metadata, resource) @@ -218,19 +282,454 @@ def updateNetworkCellularGatewayUplink(self, networkId: str, **kwargs): **Updates the uplink settings for your MG network.** https://developer.cisco.com/meraki/api-v1/#!update-network-cellular-gateway-uplink - - networkId (string): (required) + - networkId (string): Network ID - bandwidthLimits (object): The bandwidth settings for the 'cellular' uplink """ kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'uplink'], - 'operation': 'updateNetworkCellularGatewayUplink' + "tags": ["cellularGateway", "configure", "uplink"], + "operation": "updateNetworkCellularGatewayUplink", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/uplink" + + body_params = [ + "bandwidthLimits", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateNetworkCellularGatewayUplink: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def getOrganizationCellularGatewayEsimsInventory(self, organizationId: str, **kwargs): + """ + **The eSIM inventory of a given organization.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-cellular-gateway-esims-inventory + + - organizationId (string): Organization ID + - eids (array): Optional parameter to filter the results by EID. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "inventory"], + "operation": "getOrganizationCellularGatewayEsimsInventory", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/inventory" + + query_params = [ + "eids", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "eids", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationCellularGatewayEsimsInventory: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def updateOrganizationCellularGatewayEsimsInventory(self, organizationId: str, id: str, **kwargs): + """ + **Toggle the status of an eSIM** + https://developer.cisco.com/meraki/api-v1/#!update-organization-cellular-gateway-esims-inventory + + - organizationId (string): Organization ID + - id (string): ID + - status (string): Status the eSIM will be updated to + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "inventory"], + "operation": "updateOrganizationCellularGatewayEsimsInventory", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/inventory/{id}" + + body_params = [ + "status", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationCellularGatewayEsimsInventory: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def getOrganizationCellularGatewayEsimsServiceProviders(self, organizationId: str): + """ + **Service providers customers can add accounts for.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-cellular-gateway-esims-service-providers + + - organizationId (string): Organization ID + """ + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "serviceProviders"], + "operation": "getOrganizationCellularGatewayEsimsServiceProviders", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders" + + return self._session.get(metadata, resource) + + def getOrganizationCellularGatewayEsimsServiceProvidersAccounts(self, organizationId: str, **kwargs): + """ + **Inventory of service provider accounts tied to the organization.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-cellular-gateway-esims-service-providers-accounts + + - organizationId (string): Organization ID + - accountIds (array): Optional parameter to filter the results by service provider account IDs. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts"], + "operation": "getOrganizationCellularGatewayEsimsServiceProvidersAccounts", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts" + + query_params = [ + "accountIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "accountIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationCellularGatewayEsimsServiceProvidersAccounts: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def createOrganizationCellularGatewayEsimsServiceProvidersAccount( + self, organizationId: str, accountId: str, apiKey: str, serviceProvider: dict, title: str, username: str, **kwargs + ): + """ + **Add a service provider account.** + https://developer.cisco.com/meraki/api-v1/#!create-organization-cellular-gateway-esims-service-providers-account + + - organizationId (string): Organization ID + - accountId (string): Service provider account ID + - apiKey (string): Service provider account API key + - serviceProvider (object): Service Provider information + - title (string): Service provider account name + - username (string): Service provider account username + """ + + kwargs = locals() + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts"], + "operation": "createOrganizationCellularGatewayEsimsServiceProvidersAccount", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts" + + body_params = [ + "accountId", + "apiKey", + "serviceProvider", + "title", + "username", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createOrganizationCellularGatewayEsimsServiceProvidersAccount: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getOrganizationCellularGatewayEsimsServiceProvidersAccountsCommunicationPlans( + self, organizationId: str, accountIds: list, **kwargs + ): + """ + **The communication plans available for a given provider.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-cellular-gateway-esims-service-providers-accounts-communication-plans + + - organizationId (string): Organization ID + - accountIds (array): Account IDs that communication plans will be fetched for + """ + + kwargs = locals() + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts", "communicationPlans"], + "operation": "getOrganizationCellularGatewayEsimsServiceProvidersAccountsCommunicationPlans", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/communicationPlans" + + query_params = [ + "accountIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "accountIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationCellularGatewayEsimsServiceProvidersAccountsCommunicationPlans: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def getOrganizationCellularGatewayEsimsServiceProvidersAccountsRatePlans( + self, organizationId: str, accountIds: list, **kwargs + ): + """ + **The rate plans available for a given provider.** + https://developer.cisco.com/meraki/api-v1/#!get-organization-cellular-gateway-esims-service-providers-accounts-rate-plans + + - organizationId (string): Organization ID + - accountIds (array): Account IDs that rate plans will be fetched for + """ + + kwargs = locals() + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts", "ratePlans"], + "operation": "getOrganizationCellularGatewayEsimsServiceProvidersAccountsRatePlans", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/ratePlans" + + query_params = [ + "accountIds", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "accountIds", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationCellularGatewayEsimsServiceProvidersAccountsRatePlans: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def updateOrganizationCellularGatewayEsimsServiceProvidersAccount(self, organizationId: str, accountId: str, **kwargs): + """ + **Edit service provider account info stored in Meraki's database.** + https://developer.cisco.com/meraki/api-v1/#!update-organization-cellular-gateway-esims-service-providers-account + + - organizationId (string): Organization ID + - accountId (string): Account ID + - title (string): Service provider account name used on the Meraki UI + - apiKey (string): Service provider account API key + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts"], + "operation": "updateOrganizationCellularGatewayEsimsServiceProvidersAccount", } - resource = f'/networks/{networkId}/cellularGateway/uplink' + organizationId = urllib.parse.quote(str(organizationId), safe="") + accountId = urllib.parse.quote(str(accountId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/{accountId}" + + body_params = [ + "title", + "apiKey", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationCellularGatewayEsimsServiceProvidersAccount: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) - body_params = ['bandwidthLimits', ] + def deleteOrganizationCellularGatewayEsimsServiceProvidersAccount(self, organizationId: str, accountId: str): + """ + **Remove a service provider account's integration with the Dashboard.** + https://developer.cisco.com/meraki/api-v1/#!delete-organization-cellular-gateway-esims-service-providers-account + + - organizationId (string): Organization ID + - accountId (string): Account ID + """ + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts"], + "operation": "deleteOrganizationCellularGatewayEsimsServiceProvidersAccount", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + accountId = urllib.parse.quote(str(accountId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/{accountId}" + + return self._session.delete(metadata, resource) + + def createOrganizationCellularGatewayEsimsSwap(self, organizationId: str, swaps: list, **kwargs): + """ + **Swap which profile an eSIM uses.** + https://developer.cisco.com/meraki/api-v1/#!create-organization-cellular-gateway-esims-swap + + - organizationId (string): Organization ID + - swaps (array): Each object represents a swap for one eSIM + """ + + kwargs = locals() + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "swap"], + "operation": "createOrganizationCellularGatewayEsimsSwap", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/swap" + + body_params = [ + "swaps", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) \ No newline at end of file + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createOrganizationCellularGatewayEsimsSwap: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def updateOrganizationCellularGatewayEsimsSwap(self, id: str, organizationId: str): + """ + **Get the status of a profile swap.** + https://developer.cisco.com/meraki/api-v1/#!update-organization-cellular-gateway-esims-swap + + - id (string): eSIM EID + - organizationId (string): Organization ID + """ + + metadata = { + "tags": ["cellularGateway", "configure", "esims", "swap"], + "operation": "updateOrganizationCellularGatewayEsimsSwap", + } + id = urllib.parse.quote(str(id), safe="") + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/swap/{id}" + + return self._session.put(metadata, resource) + + def getOrganizationCellularGatewayUplinkStatuses(self, organizationId: str, total_pages=1, direction="next", **kwargs): + """ + **List the uplink status of every Meraki MG cellular gateway in the organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-cellular-gateway-uplink-statuses + + - organizationId (string): Organization ID + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 1000. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - networkIds (array): A list of network IDs. The returned devices will be filtered to only include these networks. + - serials (array): A list of serial numbers. The returned devices will be filtered to only include these serials. + - iccids (array): A list of ICCIDs. The returned devices will be filtered to only include these ICCIDs. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["cellularGateway", "monitor", "uplink", "statuses"], + "operation": "getOrganizationCellularGatewayUplinkStatuses", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/uplink/statuses" + + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + "networkIds", + "serials", + "iccids", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "networkIds", + "serials", + "iccids", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getOrganizationCellularGatewayUplinkStatuses: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) diff --git a/meraki/aio/api/devices.py b/meraki/aio/api/devices.py index 5a94be7c..1c02af9c 100644 --- a/meraki/aio/api/devices.py +++ b/meraki/aio/api/devices.py @@ -1,3 +1,6 @@ +import urllib + + class AsyncDevices: def __init__(self, session): super().__init__() @@ -8,14 +11,15 @@ def getDevice(self, serial: str): **Return a single device** https://developer.cisco.com/meraki/api-v1/#!get-device - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['devices', 'configure'], - 'operation': 'getDevice' + "tags": ["devices", "configure"], + "operation": "getDevice", } - resource = f'/devices/{serial}' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}" return self._session.get(metadata, resource) @@ -24,7 +28,7 @@ def updateDevice(self, serial: str, **kwargs): **Update the attributes of a device** https://developer.cisco.com/meraki/api-v1/#!update-device - - serial (string): (required) + - serial (string): Serial - name (string): The name of a device - tags (array): The list of tags of a device - lat (number): The latitude of a device @@ -32,21 +36,38 @@ def updateDevice(self, serial: str, **kwargs): - address (string): The address of a device - notes (string): The notes for the device. String. Limited to 255 characters. - moveMapMarker (boolean): Whether or not to set the latitude and longitude of a device based on the new address. Only applies when lat and lng are not specified. - - switchProfileId (string): The ID of a switch profile to bind to the device (for available switch profiles, see the 'Switch Profiles' endpoint). Use null to unbind the switch device from the current profile. For a device to be bindable to a switch profile, it must (1) be a switch, and (2) belong to a network that is bound to a configuration template. + - switchProfileId (string): The ID of a switch template to bind to the device (for available switch templates, see the 'Switch Templates' endpoint). Use null to unbind the switch device from the current profile. For a device to be bindable to a switch template, it must (1) be a switch, and (2) belong to a network that is bound to a configuration template. - floorPlanId (string): The floor plan to associate to this device. null disassociates the device from the floorplan. """ kwargs.update(locals()) metadata = { - 'tags': ['devices', 'configure'], - 'operation': 'updateDevice' + "tags": ["devices", "configure"], + "operation": "updateDevice", } - resource = f'/devices/{serial}' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}" - body_params = ['name', 'tags', 'lat', 'lng', 'address', 'notes', 'moveMapMarker', 'switchProfileId', 'floorPlanId', ] + body_params = [ + "name", + "tags", + "lat", + "lng", + "address", + "notes", + "moveMapMarker", + "switchProfileId", + "floorPlanId", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDevice: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def blinkDeviceLeds(self, serial: str, **kwargs): @@ -54,7 +75,7 @@ def blinkDeviceLeds(self, serial: str, **kwargs): **Blink the LEDs on a device** https://developer.cisco.com/meraki/api-v1/#!blink-device-leds - - serial (string): (required) + - serial (string): Serial - duration (integer): The duration in seconds. Must be between 5 and 120. Default is 20 seconds - period (integer): The period in milliseconds. Must be between 100 and 1000. Default is 160 milliseconds - duty (integer): The duty cycle as the percent active. Must be between 10 and 90. Default is 50. @@ -63,22 +84,160 @@ def blinkDeviceLeds(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'liveTools'], - 'operation': 'blinkDeviceLeds' + "tags": ["devices", "liveTools"], + "operation": "blinkDeviceLeds", } - resource = f'/devices/{serial}/blinkLeds' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/blinkLeds" - body_params = ['duration', 'period', 'duty', ] + body_params = [ + "duration", + "period", + "duty", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"blinkDeviceLeds: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def updateDeviceCellularGeolocations(self, serial: str, enabled: bool, **kwargs): + """ + **Update the enablement of the geolocation feature for a device** + https://developer.cisco.com/meraki/api-v1/#!update-device-cellular-geolocations + + - serial (string): Serial + - enabled (boolean): Required parameter for the state to update the geolocation settings to (true to enable, false to disable) + """ + + kwargs = locals() + + metadata = { + "tags": ["devices", "configure", "cellular", "geolocations"], + "operation": "updateDeviceCellularGeolocations", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellular/geolocations" + + body_params = [ + "enabled", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDeviceCellularGeolocations: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def getDeviceCellularSims(self, serial: str): + """ + **Return the SIM and APN configurations for a cellular device.** + https://developer.cisco.com/meraki/api-v1/#!get-device-cellular-sims + + - serial (string): Serial + """ + + metadata = { + "tags": ["devices", "configure", "cellular", "sims"], + "operation": "getDeviceCellularSims", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellular/sims" + + return self._session.get(metadata, resource) + + def updateDeviceCellularSims(self, serial: str, **kwargs): + """ + **Updates the SIM and APN configurations for a cellular device.** + https://developer.cisco.com/meraki/api-v1/#!update-device-cellular-sims + + - serial (string): Serial + - sims (array): List of SIMs. If a SIM was previously configured and not specified in this request, it will remain unchanged. + - simOrdering (array): Specifies the ordering of all SIMs for an MG: primary, secondary, and not-in-use (when applicable). It's required for devices with 3 or more SIMs and can be used in place of 'isPrimary' for dual-SIM devices. Use the raw eSIM slot value for the device, such as 'sim2' or 'sim3'. Sim failover will occur only between primary and secondary sim slots. + - simFailover (object): SIM Failover settings. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "configure", "cellular", "sims"], + "operation": "updateDeviceCellularSims", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellular/sims" + + body_params = [ + "sims", + "simOrdering", + "simFailover", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDeviceCellularSims: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def createDeviceCellularUplinksBandsMasksUpdate(self, serial: str, slot: str, type: str, masked: list, **kwargs): + """ + **Update the cellular band masks for a device** + https://developer.cisco.com/meraki/api-v1/#!create-device-cellular-uplinks-bands-masks-update + + - serial (string): Serial + - slot (string): Required parameter for the SIM slot to update the cellular band mask for + - type (string): Required parameter for the signal type to update the cellular band mask for + - masked (array): Required parameter for the band identifiers to mask for the given SIM slot and signal type. For LTE use bands identifiers like '30', for 5G use band identifiers like 'n30', or use 'all' to mask all bands for that signal type. Maximum 256 bands. + """ + + kwargs = locals() + + if "slot" in kwargs: + options = ["sim1", "sim2", "sim3"] + assert kwargs["slot"] in options, f'''"slot" cannot be "{kwargs["slot"]}", & must be set to one of: {options}''' + if "type" in kwargs: + options = ["5GNSA", "5GSA", "LTE"] + assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}''' + + metadata = { + "tags": ["devices", "configure", "cellular", "uplinks", "bands", "masks", "update"], + "operation": "createDeviceCellularUplinksBandsMasksUpdate", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellular/uplinks/bands/masks/update" + + body_params = [ + "slot", + "type", + "masked", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createDeviceCellularUplinksBandsMasksUpdate: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def getDeviceClients(self, serial: str, **kwargs): """ - **List the clients of a device, up to a maximum of a month ago. The usage of each client is returned in kilobytes. If the device is a switch, the switchport is returned; otherwise the switchport field is null.** + **List the clients of a device, up to a maximum of a month ago** https://developer.cisco.com/meraki/api-v1/#!get-device-clients - - serial (string): (required) + - serial (string): Serial - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 31 days from today. - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameter t0. The value must be in seconds and be less than or equal to 31 days. The default is 1 day. """ @@ -86,61 +245,840 @@ def getDeviceClients(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'monitor', 'clients'], - 'operation': 'getDeviceClients' + "tags": ["devices", "monitor", "clients"], + "operation": "getDeviceClients", } - resource = f'/devices/{serial}/clients' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/clients" - query_params = ['t0', 'timespan', ] + query_params = [ + "t0", + "timespan", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getDeviceClients: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) + def createDeviceLiveToolsArpTable(self, serial: str, **kwargs): + """ + **Enqueue a job to perform a ARP table request for the device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-arp-table + + - serial (string): Serial + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "arpTable"], + "operation": "createDeviceLiveToolsArpTable", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/arpTable" + + body_params = [ + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsArpTable: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsArpTable(self, serial: str, arpTableId: str): + """ + **Return an ARP table live tool job.** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-arp-table + + - serial (string): Serial + - arpTableId (string): Arp table ID + """ + + metadata = { + "tags": ["devices", "liveTools", "arpTable"], + "operation": "getDeviceLiveToolsArpTable", + } + serial = urllib.parse.quote(str(serial), safe="") + arpTableId = urllib.parse.quote(str(arpTableId), safe="") + resource = f"/devices/{serial}/liveTools/arpTable/{arpTableId}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsCableTest(self, serial: str, ports: list, **kwargs): + """ + **Enqueue a job to perform a cable test for the device on the specified ports** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-cable-test + + - serial (string): Serial + - ports (array): A list of ports for which to perform the cable test. For Catalyst switches, IOS interface names are also supported, such as "GigabitEthernet1/0/8", "Gi1/0/8", or even "1/0/8". + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "cableTest"], + "operation": "createDeviceLiveToolsCableTest", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/cableTest" + + body_params = [ + "ports", + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsCableTest: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsCableTest(self, serial: str, id: str): + """ + **Return a cable test live tool job.** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-cable-test + + - serial (string): Serial + - id (string): ID + """ + + metadata = { + "tags": ["devices", "liveTools", "cableTest"], + "operation": "getDeviceLiveToolsCableTest", + } + serial = urllib.parse.quote(str(serial), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/devices/{serial}/liveTools/cableTest/{id}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsLedsBlink(self, serial: str, duration: int, **kwargs): + """ + **Enqueue a job to blink LEDs on a device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-leds-blink + + - serial (string): Serial + - duration (integer): The duration in seconds to blink LEDs. + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "leds", "blink"], + "operation": "createDeviceLiveToolsLedsBlink", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/leds/blink" + + body_params = [ + "duration", + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsLedsBlink: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsLedsBlink(self, serial: str, ledsBlinkId: str): + """ + **Return a blink LEDs job** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-leds-blink + + - serial (string): Serial + - ledsBlinkId (string): Leds blink ID + """ + + metadata = { + "tags": ["devices", "liveTools", "leds", "blink"], + "operation": "getDeviceLiveToolsLedsBlink", + } + serial = urllib.parse.quote(str(serial), safe="") + ledsBlinkId = urllib.parse.quote(str(ledsBlinkId), safe="") + resource = f"/devices/{serial}/liveTools/leds/blink/{ledsBlinkId}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsMacTable(self, serial: str, **kwargs): + """ + **Enqueue a job to request the MAC table from the device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-mac-table + + - serial (string): Serial + - mac (string): Optional parameter to filter MAC table entries by MAC address. Must be a colon-delimited six-octet MAC address, for example '00:11:22:a0:b1:c2'. Matching is case-insensitive. + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "macTable"], + "operation": "createDeviceLiveToolsMacTable", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/macTable" + + body_params = [ + "mac", + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsMacTable: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsMacTable(self, serial: str, macTableId: str): + """ + **Return a MAC table live tool job.** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-mac-table + + - serial (string): Serial + - macTableId (string): Mac table ID + """ + + metadata = { + "tags": ["devices", "liveTools", "macTable"], + "operation": "getDeviceLiveToolsMacTable", + } + serial = urllib.parse.quote(str(serial), safe="") + macTableId = urllib.parse.quote(str(macTableId), safe="") + resource = f"/devices/{serial}/liveTools/macTable/{macTableId}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsMulticastRouting(self, serial: str, **kwargs): + """ + **Enqueue a job to perform a Multicast routing request for the device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-multicast-routing + + - serial (string): Serial + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "multicastRouting"], + "operation": "createDeviceLiveToolsMulticastRouting", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/multicastRouting" + + body_params = [ + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createDeviceLiveToolsMulticastRouting: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsMulticastRouting(self, serial: str, multicastRoutingId: str): + """ + **Return a Multicast routing live tool job.** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-multicast-routing + + - serial (string): Serial + - multicastRoutingId (string): Multicast routing ID + """ + + metadata = { + "tags": ["devices", "liveTools", "multicastRouting"], + "operation": "getDeviceLiveToolsMulticastRouting", + } + serial = urllib.parse.quote(str(serial), safe="") + multicastRoutingId = urllib.parse.quote(str(multicastRoutingId), safe="") + resource = f"/devices/{serial}/liveTools/multicastRouting/{multicastRoutingId}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsPing(self, serial: str, target: str, **kwargs): + """ + **Enqueue a job to ping a target host from the device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-ping + + - serial (string): Serial + - target (string): FQDN, IPv4 or IPv6 address + - count (integer): Count parameter to pass to ping. [1..5], default 5 + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "ping"], + "operation": "createDeviceLiveToolsPing", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/ping" + + body_params = [ + "target", + "count", + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsPing: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsPing(self, serial: str, id: str): + """ + **Return a ping job** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-ping + + - serial (string): Serial + - id (string): ID + """ + + metadata = { + "tags": ["devices", "liveTools", "ping"], + "operation": "getDeviceLiveToolsPing", + } + serial = urllib.parse.quote(str(serial), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/devices/{serial}/liveTools/ping/{id}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsPingDevice(self, serial: str, **kwargs): + """ + **Enqueue a job to check connectivity status to the device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-ping-device + + - serial (string): Serial + - count (integer): Count parameter to pass to ping. [1..5], default 5 + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "pingDevice"], + "operation": "createDeviceLiveToolsPingDevice", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/pingDevice" + + body_params = [ + "count", + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsPingDevice: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsPingDevice(self, serial: str, id: str): + """ + **Return a ping device job** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-ping-device + + - serial (string): Serial + - id (string): ID + """ + + metadata = { + "tags": ["devices", "liveTools", "pingDevice"], + "operation": "getDeviceLiveToolsPingDevice", + } + serial = urllib.parse.quote(str(serial), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/devices/{serial}/liveTools/pingDevice/{id}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsPortsCycle(self, serial: str, ports: list, **kwargs): + """ + **Enqueue a job to perform a cycle port for the device on the specified ports** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-ports-cycle + + - serial (string): Serial + - ports (array): A list of ports to cycle. For Catalyst switches, IOS interface names are also supported, such as "GigabitEthernet1/0/8", "Gi1/0/8", or even "1/0/8". + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "ports", "cycle"], + "operation": "createDeviceLiveToolsPortsCycle", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/ports/cycle" + + body_params = [ + "ports", + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsPortsCycle: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsPortsCycle(self, serial: str, id: str): + """ + **Return a cycle port live tool job.** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-ports-cycle + + - serial (string): Serial + - id (string): ID + """ + + metadata = { + "tags": ["devices", "liveTools", "ports", "cycle"], + "operation": "getDeviceLiveToolsPortsCycle", + } + serial = urllib.parse.quote(str(serial), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/devices/{serial}/liveTools/ports/cycle/{id}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsPortsStatus(self, serial: str, **kwargs): + """ + **Enqueue a job to retrieve port status for a device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-ports-status + + - serial (string): Serial + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "ports", "status"], + "operation": "createDeviceLiveToolsPortsStatus", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/ports/status" + + body_params = [ + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsPortsStatus: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsPortsStatus(self, serial: str, jobId: str): + """ + **Return a port status live tool job.** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-ports-status + + - serial (string): Serial + - jobId (string): Job ID + """ + + metadata = { + "tags": ["devices", "liveTools", "ports", "status"], + "operation": "getDeviceLiveToolsPortsStatus", + } + serial = urllib.parse.quote(str(serial), safe="") + jobId = urllib.parse.quote(str(jobId), safe="") + resource = f"/devices/{serial}/liveTools/ports/status/{jobId}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsPowerUsage(self, serial: str, **kwargs): + """ + **Enqueues a live tool job that retrieves details about a device's overall power usage** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-power-usage + + - serial (string): Serial + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "power", "usage"], + "operation": "createDeviceLiveToolsPowerUsage", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/power/usage" + + body_params = [ + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsPowerUsage: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsPowerUsage(self, serial: str, jobId: str): + """ + **Retrieve the status and results of a previously created live tool job fetching details about a device's overall power usage.** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-power-usage + + - serial (string): Serial + - jobId (string): Job ID + """ + + metadata = { + "tags": ["devices", "liveTools", "power", "usage"], + "operation": "getDeviceLiveToolsPowerUsage", + } + serial = urllib.parse.quote(str(serial), safe="") + jobId = urllib.parse.quote(str(jobId), safe="") + resource = f"/devices/{serial}/liveTools/power/usage/{jobId}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsRoutingTableLookup(self, serial: str, **kwargs): + """ + **Enqueue a job to perform a routing table lookup request for a device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-routing-table-lookup + + - serial (string): Serial + - type (string): The type of route defined + - destination (object): The destination IP or subnet to lookup + - nextHop (object): The next hop to lookup + - vpn (object): VPN related search criteria + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + if "type" in kwargs: + options = [ + "BGP", + "EIGRP", + "HSRP", + "IGRP", + "ISIS", + "LISP", + "NAT", + "ND", + "NHRP", + "OMP", + "OSPF", + "RIP", + "default WAN", + "direct", + "static", + ] + assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}''' + + metadata = { + "tags": ["devices", "liveTools", "routingTable", "lookups"], + "operation": "createDeviceLiveToolsRoutingTableLookup", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/routingTable/lookups" + + body_params = [ + "type", + "destination", + "nextHop", + "vpn", + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createDeviceLiveToolsRoutingTableLookup: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsRoutingTableLookup(self, serial: str, id: str): + """ + **Return a routing table live tool lookup job for a device** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-routing-table-lookup + + - serial (string): Serial + - id (string): ID + """ + + metadata = { + "tags": ["devices", "liveTools", "routingTable", "lookups"], + "operation": "getDeviceLiveToolsRoutingTableLookup", + } + serial = urllib.parse.quote(str(serial), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/devices/{serial}/liveTools/routingTable/lookups/{id}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsRoutingTableSummary(self, serial: str, **kwargs): + """ + **Enqueue a routing table summary job for a device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-routing-table-summary + + - serial (string): Serial + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "routingTable", "summaries"], + "operation": "createDeviceLiveToolsRoutingTableSummary", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/routingTable/summaries" + + body_params = [ + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createDeviceLiveToolsRoutingTableSummary: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsRoutingTableSummary(self, serial: str, id: str): + """ + **Return the status and result of a routing table summary job** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-routing-table-summary + + - serial (string): Serial + - id (string): ID + """ + + metadata = { + "tags": ["devices", "liveTools", "routingTable", "summaries"], + "operation": "getDeviceLiveToolsRoutingTableSummary", + } + serial = urllib.parse.quote(str(serial), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/devices/{serial}/liveTools/routingTable/summaries/{id}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsThroughputTest(self, serial: str, **kwargs): + """ + **Enqueue a job to test a device throughput, the test will run for 10 secs to test throughput** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-throughput-test + + - serial (string): Serial + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "throughputTest"], + "operation": "createDeviceLiveToolsThroughputTest", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/throughputTest" + + body_params = [ + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsThroughputTest: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsThroughputTest(self, serial: str, throughputTestId: str): + """ + **Return a throughput test job** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-throughput-test + + - serial (string): Serial + - throughputTestId (string): Throughput test ID + """ + + metadata = { + "tags": ["devices", "liveTools", "throughputTest"], + "operation": "getDeviceLiveToolsThroughputTest", + } + serial = urllib.parse.quote(str(serial), safe="") + throughputTestId = urllib.parse.quote(str(throughputTestId), safe="") + resource = f"/devices/{serial}/liveTools/throughputTest/{throughputTestId}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsWakeOnLan(self, serial: str, vlanId: int, mac: str, **kwargs): + """ + **Enqueue a job to send a Wake-on-LAN packet from the device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-wake-on-lan + + - serial (string): Serial + - vlanId (integer): The target's VLAN (1 to 4094) + - mac (string): The target's MAC address + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "wakeOnLan"], + "operation": "createDeviceLiveToolsWakeOnLan", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/wakeOnLan" + + body_params = [ + "vlanId", + "mac", + "callback", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"createDeviceLiveToolsWakeOnLan: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsWakeOnLan(self, serial: str, wakeOnLanId: str): + """ + **Return a Wake-on-LAN job** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-wake-on-lan + + - serial (string): Serial + - wakeOnLanId (string): Wake on lan ID + """ + + metadata = { + "tags": ["devices", "liveTools", "wakeOnLan"], + "operation": "getDeviceLiveToolsWakeOnLan", + } + serial = urllib.parse.quote(str(serial), safe="") + wakeOnLanId = urllib.parse.quote(str(wakeOnLanId), safe="") + resource = f"/devices/{serial}/liveTools/wakeOnLan/{wakeOnLanId}" + + return self._session.get(metadata, resource) + def getDeviceLldpCdp(self, serial: str): """ **List LLDP and CDP information for a device** https://developer.cisco.com/meraki/api-v1/#!get-device-lldp-cdp - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['devices', 'monitor', 'lldpCdp'], - 'operation': 'getDeviceLldpCdp' + "tags": ["devices", "monitor", "lldpCdp"], + "operation": "getDeviceLldpCdp", } - resource = f'/devices/{serial}/lldpCdp' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/lldpCdp" return self._session.get(metadata, resource) def getDeviceLossAndLatencyHistory(self, serial: str, ip: str, **kwargs): """ - **Get the uplink loss percentage and latency in milliseconds for a wired network device.** + **Get the uplink loss percentage and latency in milliseconds, and goodput in kilobits per second for MX, MG and Z devices.** https://developer.cisco.com/meraki/api-v1/#!get-device-loss-and-latency-history - - serial (string): (required) + - serial (string): Serial - ip (string): The destination IP used to obtain the requested stats. This is required. - - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 365 days from today. + - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 60 days from today. - t1 (string): The end of the timespan for the data. t1 can be a maximum of 31 days after t0. - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 31 days. The default is 1 day. - resolution (integer): The time resolution in seconds for returned data. The valid resolutions are: 60, 600, 3600, 86400. The default is 60. - - uplink (string): The WAN uplink used to obtain the requested stats. Valid uplinks are wan1, wan2, cellular. The default is wan1. + - uplink (string): The WAN uplink used to obtain the requested stats. Valid uplinks are wan1, wan2, wan3, cellular, wan4. The default is wan1. """ kwargs.update(locals()) - if 'uplink' in kwargs: - options = ['wan1', 'wan2', 'cellular'] - assert kwargs['uplink'] in options, f'''"uplink" cannot be "{kwargs['uplink']}", & must be set to one of: {options}''' + if "uplink" in kwargs: + options = ["cellular", "wan1", "wan2", "wan3", "wan4"] + assert kwargs["uplink"] in options, ( + f'''"uplink" cannot be "{kwargs["uplink"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['devices', 'monitor', 'lossAndLatencyHistory'], - 'operation': 'getDeviceLossAndLatencyHistory' + "tags": ["devices", "monitor", "uplinks", "lossAndLatencyHistory"], + "operation": "getDeviceLossAndLatencyHistory", } - resource = f'/devices/{serial}/lossAndLatencyHistory' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/lossAndLatencyHistory" - query_params = ['t0', 't1', 'timespan', 'resolution', 'uplink', 'ip', ] + query_params = [ + "t0", + "t1", + "timespan", + "resolution", + "uplink", + "ip", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"getDeviceLossAndLatencyHistory: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) def getDeviceManagementInterface(self, serial: str): @@ -148,14 +1086,15 @@ def getDeviceManagementInterface(self, serial: str): **Return the management interface settings for a device** https://developer.cisco.com/meraki/api-v1/#!get-device-management-interface - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['devices', 'configure', 'managementInterface'], - 'operation': 'getDeviceManagementInterface' + "tags": ["devices", "configure", "managementInterface"], + "operation": "getDeviceManagementInterface", } - resource = f'/devices/{serial}/managementInterface' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/managementInterface" return self._session.get(metadata, resource) @@ -164,7 +1103,7 @@ def updateDeviceManagementInterface(self, serial: str, **kwargs): **Update the management interface settings for a device** https://developer.cisco.com/meraki/api-v1/#!update-device-management-interface - - serial (string): (required) + - serial (string): Serial - wan1 (object): WAN 1 settings - wan2 (object): WAN 2 settings (only for MX devices) """ @@ -172,14 +1111,24 @@ def updateDeviceManagementInterface(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'configure', 'managementInterface'], - 'operation': 'updateDeviceManagementInterface' + "tags": ["devices", "configure", "managementInterface"], + "operation": "updateDeviceManagementInterface", } - resource = f'/devices/{serial}/managementInterface' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/managementInterface" - body_params = ['wan1', 'wan2', ] + body_params = [ + "wan1", + "wan2", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning(f"updateDeviceManagementInterface: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def rebootDevice(self, serial: str): @@ -187,13 +1136,14 @@ def rebootDevice(self, serial: str): **Reboot a device** https://developer.cisco.com/meraki/api-v1/#!reboot-device - - serial (string): (required) + - serial (string): Serial """ metadata = { - 'tags': ['devices', 'liveTools'], - 'operation': 'rebootDevice' + "tags": ["devices", "liveTools"], + "operation": "rebootDevice", } - resource = f'/devices/{serial}/reboot' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/reboot" - return self._session.post(metadata, resource) \ No newline at end of file + return self._session.post(metadata, resource) diff --git a/meraki/aio/api/insight.py b/meraki/aio/api/insight.py index e840233e..98345d24 100644 --- a/meraki/aio/api/insight.py +++ b/meraki/aio/api/insight.py @@ -1,101 +1,196 @@ +import urllib + + class AsyncInsight: def __init__(self, session): super().__init__() self._session = session + def getNetworkInsightApplicationHealthByTime(self, networkId: str, applicationId: str, **kwargs): + """ + **Get application health by time** + https://developer.cisco.com/meraki/api-v1/#!get-network-insight-application-health-by-time + + - networkId (string): Network ID + - applicationId (string): Application ID + - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 7 days from today. + - t1 (string): The end of the timespan for the data. t1 can be a maximum of 7 days after t0. + - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 7 days. The default is 2 hours. + - resolution (integer): The time resolution in seconds for returned data. The valid resolutions are: 60, 300, 3600, 86400. The default is 300. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["insight", "monitor", "applications", "healthByTime"], + "operation": "getNetworkInsightApplicationHealthByTime", + } + networkId = urllib.parse.quote(str(networkId), safe="") + applicationId = urllib.parse.quote(str(applicationId), safe="") + resource = f"/networks/{networkId}/insight/applications/{applicationId}/healthByTime" + + query_params = [ + "t0", + "t1", + "timespan", + "resolution", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + if self._session._validate_kwargs: + all_params = query_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getNetworkInsightApplicationHealthByTime: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def getOrganizationInsightApplications(self, organizationId: str): + """ + **List all Insight tracked applications** + https://developer.cisco.com/meraki/api-v1/#!get-organization-insight-applications + + - organizationId (string): Organization ID + """ + + metadata = { + "tags": ["insight", "configure", "applications"], + "operation": "getOrganizationInsightApplications", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/insight/applications" + + return self._session.get(metadata, resource) + def getOrganizationInsightMonitoredMediaServers(self, organizationId: str): """ - **List the monitored media servers for this organization. Only valid for organizations with Meraki Insight.** + **List the monitored media servers for this organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-insight-monitored-media-servers - - organizationId (string): (required) + - organizationId (string): Organization ID """ metadata = { - 'tags': ['insight', 'configure', 'monitoredMediaServers'], - 'operation': 'getOrganizationInsightMonitoredMediaServers' + "tags": ["insight", "configure", "monitoredMediaServers"], + "operation": "getOrganizationInsightMonitoredMediaServers", } - resource = f'/organizations/{organizationId}/insight/monitoredMediaServers' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/insight/monitoredMediaServers" return self._session.get(metadata, resource) - def createOrganizationInsightMonitoredMediaServer(self, organizationId: str, name: str, address: str): + def createOrganizationInsightMonitoredMediaServer(self, organizationId: str, name: str, address: str, **kwargs): """ - **Add a media server to be monitored for this organization. Only valid for organizations with Meraki Insight.** + **Add a media server to be monitored for this organization** https://developer.cisco.com/meraki/api-v1/#!create-organization-insight-monitored-media-server - - organizationId (string): (required) + - organizationId (string): Organization ID - name (string): The name of the VoIP provider - address (string): The IP address (IPv4 only) or hostname of the media server to monitor + - bestEffortMonitoringEnabled (boolean): Indicates that if the media server doesn't respond to ICMP pings, the nearest hop will be used in its stead. """ - kwargs = locals() + kwargs.update(locals()) metadata = { - 'tags': ['insight', 'configure', 'monitoredMediaServers'], - 'operation': 'createOrganizationInsightMonitoredMediaServer' + "tags": ["insight", "configure", "monitoredMediaServers"], + "operation": "createOrganizationInsightMonitoredMediaServer", } - resource = f'/organizations/{organizationId}/insight/monitoredMediaServers' - - body_params = ['name', 'address', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/insight/monitoredMediaServers" + + body_params = [ + "name", + "address", + "bestEffortMonitoringEnabled", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"createOrganizationInsightMonitoredMediaServer: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def getOrganizationInsightMonitoredMediaServer(self, organizationId: str, monitoredMediaServerId: str): """ - **Return a monitored media server for this organization. Only valid for organizations with Meraki Insight.** + **Return a monitored media server for this organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-insight-monitored-media-server - - organizationId (string): (required) - - monitoredMediaServerId (string): (required) + - organizationId (string): Organization ID + - monitoredMediaServerId (string): Monitored media server ID """ metadata = { - 'tags': ['insight', 'configure', 'monitoredMediaServers'], - 'operation': 'getOrganizationInsightMonitoredMediaServer' + "tags": ["insight", "configure", "monitoredMediaServers"], + "operation": "getOrganizationInsightMonitoredMediaServer", } - resource = f'/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe="") + resource = f"/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}" return self._session.get(metadata, resource) def updateOrganizationInsightMonitoredMediaServer(self, organizationId: str, monitoredMediaServerId: str, **kwargs): """ - **Update a monitored media server for this organization. Only valid for organizations with Meraki Insight.** + **Update a monitored media server for this organization** https://developer.cisco.com/meraki/api-v1/#!update-organization-insight-monitored-media-server - - organizationId (string): (required) - - monitoredMediaServerId (string): (required) + - organizationId (string): Organization ID + - monitoredMediaServerId (string): Monitored media server ID - name (string): The name of the VoIP provider - address (string): The IP address (IPv4 only) or hostname of the media server to monitor + - bestEffortMonitoringEnabled (boolean): Indicates that if the media server doesn't respond to ICMP pings, the nearest hop will be used in its stead. """ kwargs.update(locals()) metadata = { - 'tags': ['insight', 'configure', 'monitoredMediaServers'], - 'operation': 'updateOrganizationInsightMonitoredMediaServer' + "tags": ["insight", "configure", "monitoredMediaServers"], + "operation": "updateOrganizationInsightMonitoredMediaServer", } - resource = f'/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}' - - body_params = ['name', 'address', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe="") + resource = f"/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}" + + body_params = [ + "name", + "address", + "bestEffortMonitoringEnabled", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + if self._session._validate_kwargs: + all_params = [] + body_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"updateOrganizationInsightMonitoredMediaServer: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def deleteOrganizationInsightMonitoredMediaServer(self, organizationId: str, monitoredMediaServerId: str): """ - **Delete a monitored media server from this organization. Only valid for organizations with Meraki Insight.** + **Delete a monitored media server from this organization** https://developer.cisco.com/meraki/api-v1/#!delete-organization-insight-monitored-media-server - - organizationId (string): (required) - - monitoredMediaServerId (string): (required) + - organizationId (string): Organization ID + - monitoredMediaServerId (string): Monitored media server ID """ metadata = { - 'tags': ['insight', 'configure', 'monitoredMediaServers'], - 'operation': 'deleteOrganizationInsightMonitoredMediaServer' + "tags": ["insight", "configure", "monitoredMediaServers"], + "operation": "deleteOrganizationInsightMonitoredMediaServer", } - resource = f'/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe="") + resource = f"/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}" - return self._session.delete(metadata, resource) \ No newline at end of file + return self._session.delete(metadata, resource) diff --git a/meraki/aio/api/licensing.py b/meraki/aio/api/licensing.py new file mode 100644 index 00000000..553f47ea --- /dev/null +++ b/meraki/aio/api/licensing.py @@ -0,0 +1,346 @@ +import urllib + + +class AsyncLicensing: + def __init__(self, session): + super().__init__() + self._session = session + + def getAdministeredLicensingSubscriptionEntitlements(self, **kwargs): + """ + **Retrieve the list of purchasable entitlements** + https://developer.cisco.com/meraki/api-v1/#!get-administered-licensing-subscription-entitlements + + - skus (array): Filter to entitlements with the specified SKUs + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["licensing", "configure", "subscription", "entitlements"], + "operation": "getAdministeredLicensingSubscriptionEntitlements", + } + resource = "/administered/licensing/subscription/entitlements" + + query_params = [ + "skus", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "skus", + ] + for k, v in kwargs.items(): + if k.strip() in array_params: + params[f"{k.strip()}[]"] = kwargs[f"{k}"] + params.pop(k.strip()) + + if self._session._validate_kwargs: + all_params = query_params + array_params + invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"] + if invalid and self._session._logger: + self._session._logger.warning( + f"getAdministeredLicensingSubscriptionEntitlements: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def getAdministeredLicensingSubscriptionSubscriptions( + self, organizationIds: list, total_pages=1, direction="next", **kwargs + ): + """ + **List available subscriptions** + https://developer.cisco.com/meraki/api-v1/#!get-administered-licensing-subscription-subscriptions + + - organizationIds (array): Organizations to get associated subscriptions for + - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages + - direction (string): direction to paginate, either "next" (default) or "prev" page + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 1000. + - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it. + - subscriptionIds (array): List of subscription ids to fetch + - statuses (array): List of statuses that returned subscriptions can have + - productTypes (array): List of product types that returned subscriptions need to have entitlements for. + - skus (array): List of SKUs that returned subscriptions need to have entitlements for. + - name (string): Search for subscription name + - startDate (object or string): Filter subscriptions by start date, ISO 8601 format. To filter with a range of dates, use 'startDate[