├── .github └── workflows │ ├── codeql-analysis.yml │ ├── issue_notify.yml │ ├── release.yml │ └── verify.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── doc ├── Makefile ├── _static │ └── ns1.png ├── api │ ├── config.rst │ ├── modules.rst │ ├── ns1.rst │ ├── records.rst │ ├── redirect.rst │ ├── rest.rst │ └── zones.rst ├── conf.py ├── configuration.rst ├── features.rst ├── index.rst └── usage.rst ├── examples ├── alerts.py ├── async-twisted.py ├── billing_usage.py ├── clone-record.py ├── config.py ├── data.py ├── datasets.py ├── errors-and-debugging.py ├── importzone.db ├── rate-limiting.py ├── redirect.py ├── stats.py ├── team.py ├── zone-import.py └── zones.py ├── ns1 ├── __init__.py ├── acls.py ├── config.py ├── dataset.py ├── helpers.py ├── monitoring.py ├── records.py ├── redirect.py ├── rest │ ├── __init__.py │ ├── account.py │ ├── acls.py │ ├── alerts.py │ ├── apikey.py │ ├── billing_usage.py │ ├── data.py │ ├── datasets.py │ ├── errors.py │ ├── monitoring.py │ ├── permissions.py │ ├── rate_limiting.py │ ├── records.py │ ├── redirect.py │ ├── resource.py │ ├── stats.py │ ├── team.py │ ├── transport │ │ ├── README │ │ ├── __init__.py │ │ ├── base.py │ │ ├── basic.py │ │ ├── requests.py │ │ └── twisted.py │ ├── tsig.py │ ├── user.py │ ├── views.py │ └── zones.py ├── views.py └── zones.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py └── unit │ ├── test_acl.py │ ├── test_alerts.py │ ├── test_apikey.py │ ├── test_billing_usage.py │ ├── test_config.py │ ├── test_data.py │ ├── test_datasets.py │ ├── test_helpers.py │ ├── test_init.py │ ├── test_monitoring.py │ ├── test_redirect.py │ ├── test_resource.py │ ├── test_stats.py │ ├── test_team.py │ ├── test_tsig.py │ ├── test_twisted.py │ ├── test_user.py │ ├── test_views.py │ └── test_zone.py └── tox.ini /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '41 11 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | queries: ns1/NS1QLPacks/codeql/${{ matrix.language }}/NS1-security.qls@main 54 | config-file: ns1/NS1QLPacks/codeql/NS1-codeql-config.yml@main 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v1 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 https://git.io/JvXDl 63 | 64 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 65 | # and modify them (or add more) to build your code if your project 66 | # uses a compiled language 67 | 68 | #- run: | 69 | # make bootstrap 70 | # make release 71 | 72 | - name: Perform CodeQL Analysis 73 | uses: github/codeql-action/analyze@v1 74 | -------------------------------------------------------------------------------- /.github/workflows/issue_notify.yml: -------------------------------------------------------------------------------- 1 | # This workflow is triggered by github issue and creates a jira ticket in the respective configured account 2 | # 3 | name: issue_notify 4 | on: 5 | issues: 6 | types: [opened] 7 | workflow_dispatch: 8 | jobs: 9 | jira_job: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Jira Login 13 | uses: atlassian/gajira-login@v2.0.0 14 | env: 15 | JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL}} 16 | JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL}} 17 | JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN}} 18 | - name: Jira Create issue 19 | id: jira_ticket 20 | uses: atlassian/gajira-create@v2.0.1 21 | with: 22 | project: ${{secrets.JIRA_PROJECT_KEY}} 23 | issuetype: Bug 24 | summary: '[ns1-python] ${{github.event.issue.title}}' 25 | description: ${{github.event.issue.body}} see more at ${{github.event.issue.html_url}} 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build distribution 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: "ubuntu-latest" 8 | 9 | steps: 10 | - name: Checkout source 11 | uses: actions/checkout@v2 12 | 13 | - name: Set up Python 3.8 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: 3.8 17 | 18 | - name: Install build dependencies 19 | run: python -m pip install build wheel 20 | 21 | - name: Build distributions 22 | shell: bash -l {0} 23 | run: python setup.py sdist bdist_wheel 24 | 25 | - name: Publish package to PyPI 26 | if: github.repository == 'ns1/ns1-python' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 27 | uses: pypa/gh-action-pypi-publish@v1.5.0 28 | with: 29 | user: __token__ 30 | password: ${{ secrets.ns1_python_publish }} 31 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: NS1 Python SDK 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: Set up Python 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: 3.8 15 | - name: Install dependencies 16 | run: | 17 | python setup.py install 18 | python setup.py bdist_wheel 19 | python -m pip install --upgrade pip 20 | pip install flake8 black 21 | - name: Lint with flake8 22 | run: | 23 | # Ignore the line length rule because black will handle that 24 | # and flake8 doesn't allow long comment lines. 25 | flake8 . --exclude=.tox --exclude=.eggs --count --show-source --statistics --extend-ignore=E501 26 | - name: Lint with black 27 | run: | 28 | black . --check -l 79 --diff 29 | 30 | test: 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | os: [ubuntu-latest] 35 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 36 | 37 | steps: 38 | - uses: actions/checkout@v1 39 | - name: Set up Python 40 | uses: actions/setup-python@v4 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | - name: Install dependencies 44 | run: | 45 | pip install --upgrade setuptools 46 | python setup.py install 47 | python setup.py bdist_wheel 48 | python -m pip install --upgrade pip 49 | pip install pytest mock 50 | - name: Test with pytest 51 | run: | 52 | pytest 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | doc/_build/ 53 | 54 | # PyBuilder 55 | target/ 56 | 57 | # Virtual environments 58 | venv/ 59 | 60 | # Editors 61 | .vscode/ 62 | .idea 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - '2.7' 5 | - '3.7' 6 | - '3.8' 7 | - '3.9' 8 | - '3.10' 9 | install: 10 | - pip install tox-travis Sphinx 11 | script: 12 | - tox 13 | - python setup.py install 14 | - python setup.py bdist_wheel 15 | - cd doc 16 | - make html 17 | notifications: 18 | slack: 19 | secure: kXWHt2FwGzohgwmwDH262R3B359iRmsjPE/wF90ur6/TOfTvxuZicpPOVWsglgDgVP92zMklwgOs941IJmg4VVvqjuvDYeaMB+KLHvxb4Vl0pOg7mLpOXmIVt3NwL3+miSwoQ24XHJb6vGzubeHAjSXpD0N1tVxb792DvHztDTo= 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.24.0 (March 20th, 2025) 2 | 3 | ENHANCEMENTS: 4 | * Adds support for BillingUsage 5 | 6 | ## 0.23.0 (Dec 9th, 2024) 7 | 8 | ENHANCEMENTS: 9 | * Adds support for Alerts 10 | 11 | ## 0.22.0 (Oct 29th, 2024) 12 | 13 | ENHANCEMENTS: 14 | * Adds support for specifying a list of views when creating zones with or without a provided zone file. 15 | * Adds support for specifying a zone name other than the FQDN when creating zones with or without a provided zone file. 16 | * A specified list of networks for a zone was only applied to zone creation when a zone file was not provided. 17 | 18 | ## 0.21.0 (July 19th, 2024) 19 | 20 | ENHANCEMENTS: 21 | * Adds support for new split monitoring permissions create_jobs, update_jobs and delete_jobs 22 | * Remove DDI (DHCP & IPAM) code 23 | 24 | ## 0.20.0 (June 19th, 2024) 25 | 26 | ENHANCEMENTS: 27 | * Adds support for Redirects 28 | 29 | ## 0.19.1 (May 15th, 2024) 30 | 31 | BUG FIXES: 32 | * Fixed twisted transport to pass correct encoding on the body. 33 | 34 | ## 0.19.0 (February 14th, 2024) 35 | 36 | ENHANCEMENTS: 37 | * Adds support for Datasets 38 | 39 | BUG FIXES: 40 | * Drop support for EOL Python 2.7 and 3.7. Add support for Python 3.11 41 | and 3.12. 42 | * Deprecate "writeLocked" keys, which did not actually do anything. 43 | 44 | ## 0.18.0 (August 23, 2022) 45 | * Add usage stats pagination support 46 | * Drop support for EOL Python 3.6, add support for Python 3.7 to 3.10 47 | 48 | ## 0.17.4 (July 5, 2022) 49 | * Fix release tag 50 | 51 | ## 0.17.3 (June 27, 2022) 52 | ENHANCEMENTS: 53 | * Add support for DHCP objects 54 | * Add ability to set `tags` and `primary_master` for DNS zones 55 | * Add support for DNS Views and ACLs 56 | 57 | ## 0.17.2 (March 30, 2022) 58 | ENHANCEMENTS: 59 | * Adds support for TSIG 60 | 61 | ## 0.17.1 (October 27, 2021) 62 | BUG FIXES: 63 | * Fixes a casing issue on a search parameter 64 | 65 | ## 0.17.0 (October 27, 2021) 66 | ENHANCEMENTS 67 | * Move from deprecated search endpoint to supported search endpoint 68 | 69 | ## 0.16.1 (September 1, 2021) 70 | 71 | ENHANCEMENTS 72 | * Re-use connections with Session objects in RequestsTransport 73 | 74 | ## 0.16.0 (May 18, 2020) 75 | 76 | ENHANCEMENTS 77 | * Added tags to ipam/dhcp resources 78 | 79 | ## 0.15.0 (February 20, 2020) 80 | 81 | ENHANCEMENTS 82 | 83 | * Support monitoring regions endpoint [#55](https://github.com/ns1/ns1-python/pull/55) 84 | * Support job types endpoint [#55](https://github.com/ns1/ns1-python/pull/55) 85 | * Support for following pagination in the endpoints that have it. Off by 86 | default to avoid breaking changes. Enable in config by setting 87 | `follow_pagination` to True. [#56](https://github.com/ns1/ns1-python/pull/56) 88 | * Clarify usage caveats in loadRecord docstring [#58](https://github.com/ns1/ns1-python/pull/58) 89 | 90 | ## 0.14.0 (February 03, 2020) 91 | 92 | ENHANCEMENTS: 93 | 94 | * Add REST support for teams, users, and API keys 95 | * various IPAM features added 96 | * support for rate limit "strategies" [#47](https://github.com/ns1/ns1-python/pull/47) 97 | * codebase linted (w/black) and GH action for keeping it that way 98 | * project status added to README 99 | 100 | BUG FIXES: 101 | 102 | * wrong args passed to reservation.delete [#42](https://github.com/ns1/ns1-python/pull/42) 103 | 104 | POTENTIAL BREAKING CHANGES: 105 | 106 | * Changes to ipam.Address model for (private DNS) v2.2, v2.1 users should stick 107 | to the previous SDK version (v0.13.0) [#41](https://github.com/ns1/ns1-python/pull/41) 108 | 109 | ## 0.13.0 (November 05, 2019) 110 | 111 | ENHANCEMENTS: 112 | 113 | * Add a helper class for concurrency [#40](https://github.com/ns1/ns1-python/pull/40) 114 | * Add `update` methods to scopes and reservations [#39](https://github.com/ns1/ns1-python/pull/39) 115 | 116 | ## 0.12.0 (September 04, 2019) 117 | 118 | ENHANCEMENTS: 119 | 120 | * Add (required) `sourcetype` arg to `source.update`. API requires it, although it cannot be changed. [#38](https://github.com/ns1/ns1-python/pull/38) 121 | * Add lease reporting endpoint [#37](https://github.com/ns1-python/pull/37) 122 | 123 | IMPROVEMENTS: 124 | 125 | * Add unit tests for data (source) [#38](https://github.com/ns1-python/pull/38) 126 | 127 | ## 0.11.0 (August 05, 2019) 128 | 129 | ENHANCEMENTS: 130 | 131 | * Added `use_client_subnet` (alias for `use_csubnet`) parameter to `records` resource [#36](https://github.com/ns1/ns1-python/pull/36). Thanks to @ignatenkobrain! 132 | * Added `primary` parameter to `zones` resource [#35](https://github.com/ns1/ns1-python/pull/35) 133 | 134 | IMPROVEMENTS: 135 | 136 | * Allow Scopegroups to be loaded by ID [#33](https://github.com/ns1/ns1-python/pull/33) 137 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 NSONE, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ns1/ns1-python.svg?branch=master)](https://travis-ci.org/ns1/ns1-python) [![Docs](https://readthedocs.org/projects/ns1-python/badge/?version=latest)](https://ns1-python.readthedocs.io/en/latest/) 2 | 3 | NS1 Python SDK 4 | ============== 5 | 6 | > This project is in [active development](https://github.com/ns1/community/blob/master/project_status/ACTIVE_DEVELOPMENT.md). 7 | 8 | A Python SDK for accessing NS1, the Data Driven DNS platform. 9 | 10 | About 11 | ===== 12 | 13 | This package provides a python SDK for accessing the NS1 DNS platform 14 | and includes both a simple NS1 REST API wrapper as well as a higher level 15 | interface for managing zones, records, data feeds, and more. 16 | It supports synchronous and asynchronous transports. 17 | 18 | Python 3.8+ is supported. Automated tests are currently run 19 | against 3.8, 3.9, 3.10, 3.11, 3.12 and 3.13 20 | 21 | Installation 22 | ============ 23 | 24 | $ pip install ns1-python 25 | 26 | Dependencies 27 | ============ 28 | 29 | None, but supports different transport backends. Currently supported: 30 | 31 | * [requests](http://docs.python-requests.org/en/latest/) (synchronous, the 32 | default if available) 33 | * urllib (synchronous, the default if requests isn't available) 34 | * [twisted](https://twistedmatrix.com/) (asynchronous, requires 2.7 or 3.5+) 35 | 36 | Other transports are easy to add, see 37 | [transport](https://github.com/ns1/ns1-python/tree/master/ns1/rest/transport) 38 | 39 | Examples 40 | ======== 41 | 42 | See the [examples directory](https://github.com/ns1/ns1-python/tree/master/examples) 43 | 44 | Documentation 45 | ============= 46 | 47 | If you don't yet have an NS1 account, [signup here (free)](https://ns1.com/signup/) 48 | 49 | You'll need an API Key. To create one, login to [the portal](https://my.nsone.net/) 50 | and click on the Account button in the top right. Select Settings & Users, then 51 | add a new API Key at the bottom. 52 | 53 | * [Documentation at ReadTheDocs](https://ns1-python.readthedocs.org/en/latest/) 54 | * [NS1 REST API Documentation](https://ns1.com/api/) 55 | 56 | Tests 57 | ===== 58 | 59 | Unit tests use `pytest` (`pip install pytest`). 2.7 also requires `mock` to be 60 | installed (`pip install mock`). 61 | 62 | Tests should, of course, run and pass under python 2 and 3. We use tox to 63 | automate test runs and virtualenv setup, see `tox.ini` for config. 64 | 65 | Contributions 66 | ============= 67 | Pull Requests and issues are welcome. See the 68 | [NS1 Contribution Guidelines](https://github.com/ns1/community) for more 69 | information. 70 | 71 | ### Editing the docs 72 | 73 | You can create or edit NS1-python documentation by downloading the repo onto your machine and using an editor such as VSCode. 74 | 75 | ### Creating Pull Requests 76 | 77 | 1. When you're ready to submit your changes, add a descriptive title and comments to summarize the changes made. 78 | 2. Select **Create a new branch for this commit and start a pull request**. 79 | 3. Check the **Propose file change** button. 80 | 4. Scroll down to compare changes with the original document. 81 | 5. Select **Create pull request**. 82 | 83 | Our CI process will lint and check for formatting issues with `flake8` and 84 | `black`. 85 | It is suggested to run these checks prior to submitting a pull request and fix 86 | any issues: 87 | ``` 88 | pip install flake8 black 89 | flake8 . --count --show-source --statistics --extend-ignore=E501 90 | black . --check -l 79 --diff 91 | ``` 92 | -------------------------------------------------------------------------------- /doc/_static/ns1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns1/ns1-python/506c5ac3b90e748fec83c38bb864259435061832/doc/_static/ns1.png -------------------------------------------------------------------------------- /doc/api/config.rst: -------------------------------------------------------------------------------- 1 | ns1.config 2 | ========== 3 | 4 | This object is used to configure the SDK and REST client. It handles multiple 5 | API keys via a simple selection mechanism (keyID). 6 | 7 | Sample: 8 | 9 | .. code-block:: json 10 | 11 | { 12 | "default_key": "account2", 13 | "verbosity": 5, 14 | "keys": { 15 | "account1": { 16 | "key": "<>", 17 | "desc": "account number 1", 18 | }, 19 | "account2": { 20 | "key": "<>", 21 | "desc": "account number 2", 22 | } 23 | }, 24 | "cli": { 25 | "output_format": "text" 26 | } 27 | } 28 | 29 | .. automodule:: ns1.config 30 | :members: 31 | :undoc-members: 32 | :show-inheritance: 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /doc/api/modules.rst: -------------------------------------------------------------------------------- 1 | ns1 package 2 | =========== 3 | 4 | .. toctree:: 5 | :maxdepth: 5 6 | 7 | ns1 8 | config 9 | zones 10 | records 11 | rest 12 | redirect 13 | -------------------------------------------------------------------------------- /doc/api/ns1.rst: -------------------------------------------------------------------------------- 1 | ns1.NS1 2 | ======= 3 | 4 | This top level object is used to initialize and coordinate access to the 5 | NS1 platform. With it, you create objects for accessing either the basic 6 | REST interface, or the high level objects such as Zone and Record. 7 | 8 | .. automodule:: ns1 9 | :members: 10 | :undoc-members: 11 | :show-inheritance: 12 | -------------------------------------------------------------------------------- /doc/api/records.rst: -------------------------------------------------------------------------------- 1 | ns1.records 2 | =========== 3 | 4 | Object representing a single DNS record in a zone of a specific type. 5 | 6 | .. note:: 7 | 8 | Answers to a record (the `answers` kwarg) should be passed as one of the following four structures, depending on how advanced the configuration for the answer needs to be: 9 | 10 | 1. A single string that is coerced to a single answer with no other fields e.g. meta. For example: `"1.1.1.1"` 11 | 2. A list of single strings that is coerced to several answers with no other fields e.g. meta. For example: `["1.1.1.1", "2.2.2.2"]` 12 | 3. A list of lists. In this case there will be as many answers as are in the outer list, and the 13 | answers themselves are used verbatim from the inner list (e.g. may 14 | have MX style `[10, '1.1.1.1]`), but no other fields e.g. meta. 15 | You must use this form for MX records, and if there is only one 16 | answer it still must be wrapped in an outer list. 17 | 4. A list of dicts. In this case it expects the full rest model and passes it along unchanged. You must use this 18 | form for any advanced record config like meta data or data feeds. 19 | 20 | .. code-block:: python 21 | 22 | # Example of an advanced answer configuration (list of dicts) 23 | record = yield zone.add_A('record', 24 | [{'answer': ['1.1.1.1'], 25 | 'meta': { 26 | 'up': False 27 | } 28 | }, 29 | {'answer': ['9.9.9.9'], 30 | 'meta': { 31 | 'up': True 32 | } 33 | }], 34 | filters=[{'up': {}}]) 35 | 36 | .. automodule:: ns1.records 37 | :members: 38 | :undoc-members: 39 | :show-inheritance: 40 | 41 | 42 | -------------------------------------------------------------------------------- /doc/api/redirect.rst: -------------------------------------------------------------------------------- 1 | ns1.redirect 2 | ========= 3 | 4 | Redirect is an object representing a single redirect; RedirectCertificate represents a redirect certificate 5 | and the Redirect.retrieveCertificate() method can be used to retrieve it. 6 | 7 | .. note:: 8 | 9 | The mandatory fields are domain, path and target, which describe a redirect in the form ``domain/path -> target``. 10 | 11 | By default, unless *https_enabled* is set to False, HTTPS will be enabled on the source domain: once there is a 12 | certificate for the source domain, all redirects using it are automatically HTTPS enabled, regardless of the value 13 | of *https_enabled*. 14 | 15 | The possible values for *forwarding_mode* are (see https://www.ibm.com/docs/en/ns1-connect?topic=redirects-path-query-forwarding): 16 | 17 | * ``all``: the entire URL path included in incoming requests to the source URL is appended to the target URL. 18 | * ``none``: no part of the requested URL path should be appended to the target URL. 19 | * ``capture``: only the segment of the requested URL path matching the wildcard segment defined in the source URL should be appended to the target URL. 20 | 21 | The possible values for *forwarding_type* are (see https://www.ibm.com/docs/en/ns1-connect?topic=redirects-configuring-url-redirect): 22 | 23 | * ``permanent``: answer clients with HTTP 301 Moved Permanently. 24 | * ``temporary``: answer clients with HTTP 302 Found. 25 | * ``masking``: answer clients with HTTP 200 OK and include the target in a frame. 26 | 27 | 28 | .. automodule:: ns1.redirect 29 | :members: 30 | :undoc-members: 31 | :show-inheritance: 32 | 33 | 34 | -------------------------------------------------------------------------------- /doc/api/rest.rst: -------------------------------------------------------------------------------- 1 | ns1.rest 2 | ======== 3 | 4 | A thin layer over the NS1 REST API 5 | 6 | .. automodule:: ns1.rest.errors 7 | :members: 8 | :undoc-members: 9 | :show-inheritance: 10 | 11 | .. automodule:: ns1.rest.resource 12 | :members: 13 | :undoc-members: 14 | :show-inheritance: 15 | 16 | .. automodule:: ns1.rest.data 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | 21 | .. automodule:: ns1.rest.stats 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | 26 | .. automodule:: ns1.rest.records 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | .. automodule:: ns1.rest.zones 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | .. automodule:: ns1.rest.team 37 | :members: 38 | :undoc-members: 39 | :show-inheritance: 40 | 41 | .. automodule:: ns1.rest.user 42 | :members: 43 | :undoc-members: 44 | :show-inheritance: 45 | 46 | .. automodule:: ns1.rest.apikey 47 | :members: 48 | :undoc-members: 49 | :show-inheritance: 50 | 51 | .. automodule:: ns1.rest.redirect 52 | :members: 53 | :undoc-members: 54 | :show-inheritance: 55 | -------------------------------------------------------------------------------- /doc/api/zones.rst: -------------------------------------------------------------------------------- 1 | ns1.zones 2 | ========= 3 | 4 | Object representing a single DNS zone. 5 | 6 | .. note:: 7 | 8 | Answers to a record (the `answers` kwarg) should be passed as one of the following four structures, depending on how advanced the configuration for the answer needs to be: 9 | 10 | 1. A single string that is coerced to a single answer with no other fields e.g. meta. For example: `"1.1.1.1"` 11 | 2. A list of single strings that is coerced to several answers with no other fields e.g. meta. For example: `["1.1.1.1", "2.2.2.2"]` 12 | 3. A list of lists. In this case there will be as many answers as are in the outer list, and the 13 | answers themselves are used verbatim from the inner list (e.g. may 14 | have MX style `[10, '1.1.1.1]`), but no other fields e.g. meta. 15 | You must use this form for MX records, and if there is only one 16 | answer it still must be wrapped in an outer list. 17 | 4. A list of dicts. In this case it expects the full rest model and passes it along unchanged. You must use this 18 | form for any advanced record config like meta data or data feeds. 19 | 20 | .. code-block:: python 21 | 22 | # Example of an advanced answer configuration (list of dicts) 23 | record = yield zone.add_A('record', 24 | [{'answer': ['1.1.1.1'], 25 | 'meta': { 26 | 'up': False 27 | } 28 | }, 29 | {'answer': ['9.9.9.9'], 30 | 'meta': { 31 | 'up': True 32 | } 33 | }], 34 | filters=[{'up': {}}]) 35 | 36 | 37 | .. automodule:: ns1.zones 38 | :members: 39 | :undoc-members: 40 | :show-inheritance: 41 | 42 | 43 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # NS1 Python SDK documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Apr 24 17:58:26 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | import os 15 | import sys 16 | 17 | sys.path[0:0] = [os.path.abspath("..")] 18 | import ns1 # noqa 19 | 20 | extensions = [ 21 | "sphinx.ext.autodoc", 22 | "sphinx.ext.doctest", 23 | "sphinx.ext.intersphinx", 24 | "sphinx.ext.todo", 25 | "sphinx.ext.coverage", 26 | ] 27 | 28 | # Add any paths that contain templates here, relative to this directory. 29 | templates_path = ["_templates"] 30 | 31 | # The suffix of source filenames. 32 | source_suffix = ".rst" 33 | 34 | # The master toctree document. 35 | master_doc = "index" 36 | 37 | # General information about the project. 38 | project = "NS1 Python SDK" 39 | copyright = "2014, NSONE, Inc." 40 | 41 | # The version info for the project you're documenting, acts as replacement for 42 | # |version| and |release|, also used in various other places throughout the 43 | # built documents. 44 | # 45 | # The short X.Y version. 46 | version = ns1.version 47 | # The full version, including alpha/beta/rc tags. 48 | release = ns1.version 49 | 50 | # List of patterns, relative to source directory, that match files and 51 | # directories to ignore when looking for source files. 52 | exclude_patterns = ["_build"] 53 | 54 | # The name of the Pygments (syntax highlighting) style to use. 55 | pygments_style = "sphinx" 56 | 57 | 58 | # -- Options for HTML output ---------------------------------------------- 59 | 60 | # The theme to use for HTML and HTML Help pages. See the documentation for 61 | # a list of builtin themes. 62 | html_theme = "default" 63 | 64 | # Add any paths that contain custom static files (such as style sheets) here, 65 | # relative to this directory. They are copied after the builtin static files, 66 | # so a file named "default.css" will overwrite the builtin "default.css". 67 | html_static_path = ["_static"] 68 | 69 | # Output file base name for HTML help builder. 70 | htmlhelp_basename = "NS1PythonSDKdoc" 71 | 72 | 73 | # -- Options for LaTeX output --------------------------------------------- 74 | 75 | latex_elements = {} 76 | 77 | # Grouping the document tree into LaTeX files. List of tuples 78 | # (source start file, target name, title, 79 | # author, documentclass [howto, manual, or own class]). 80 | latex_documents = [ 81 | ( 82 | "index", 83 | "NS1PythonSDK.tex", 84 | "NS1 Python SDK Documentation", 85 | "NSONE, Inc.", 86 | "manual", 87 | ), 88 | ] 89 | 90 | # -- Options for manual page output --------------------------------------- 91 | 92 | # One entry per manual page. List of tuples 93 | # (source start file, name, description, authors, manual section). 94 | man_pages = [ 95 | ( 96 | "index", 97 | "ns1pythonsdk", 98 | "NS1 Python SDK Documentation", 99 | ["NSONE, Inc."], 100 | 1, 101 | ) 102 | ] 103 | 104 | 105 | # -- Options for Texinfo output ------------------------------------------- 106 | 107 | # Grouping the document tree into Texinfo files. List of tuples 108 | # (source start file, target name, title, author, 109 | # dir menu entry, description, category) 110 | texinfo_documents = [ 111 | ( 112 | "index", 113 | "NS1PythonSDK", 114 | "NS1 Python SDK Documentation", 115 | "NSONE, Inc.", 116 | "NS1PythonSDK", 117 | "One line description of project.", 118 | "Miscellaneous", 119 | ), 120 | ] 121 | 122 | 123 | # Example configuration for intersphinx: refer to the Python standard library. 124 | intersphinx_mapping = {"http://docs.python.org/": None} 125 | 126 | autoclass_content = "both" 127 | -------------------------------------------------------------------------------- /doc/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | Configuring the SDK can be done programmatically and/or via loading (and saving) simple 5 | JSON text configuration files. At a minimum, the NS1 API key to access the REST API must 6 | be specified. 7 | 8 | 9 | Loading From a File 10 | ------------------- 11 | 12 | By default, configuration is loaded from the file ``~/.nsone``; that is, a file called 13 | ``.nsone`` in the home directory of the user calling the script. 14 | 15 | .. code-block:: python 16 | 17 | # to load an explicit configuration file: 18 | api = NS1(configFile='/etc/ns1/api.json') 19 | 20 | From an API Key 21 | --------------- 22 | 23 | .. code-block:: python 24 | 25 | # to generate a configuration based on an api key 26 | api = NS1(apiKey='<>') 27 | 28 | JSON File Format 29 | ---------------- 30 | 31 | This example shows two different API keys. Which to use can be selected at runtime, see :mod:`ns1.config` 32 | 33 | .. code-block:: json 34 | 35 | { 36 | "default_key": "account2", 37 | "verbosity": 5, 38 | "keys": { 39 | "account1": { 40 | "key": "<>", 41 | "desc": "account number 1", 42 | }, 43 | "account2": { 44 | "key": "<>", 45 | "desc": "account number 2", 46 | }, 47 | }, 48 | "cli": { 49 | "output_format": "text" 50 | } 51 | } 52 | 53 | More 54 | ---- 55 | 56 | There are more examples in the `config.py example `_. 57 | For the full Config object reference API, see :mod:`ns1.config` 58 | 59 | -------------------------------------------------------------------------------- /doc/features.rst: -------------------------------------------------------------------------------- 1 | Features 2 | ======== 3 | 4 | * Extensive config system with support for multiple API keys 5 | * High level interface to Zones and Records 6 | * Low level REST wrapper for all other functionality 7 | * Extendable transport system with synchronous and asynchronous transports 8 | 9 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | Python SDK for NS1 DNS Platform 2 | =============================== 3 | 4 | .. image:: _static/ns1.png 5 | :target: https://ns1.com/ 6 | 7 | 8 | About 9 | ----- 10 | 11 | This package provides an SDK for accessing the NS1 DNS platform 12 | and includes both a simple NS1 REST API wrapper as well as a higher level 13 | interface for managing zones, records, data feeds, and more. 14 | It supports synchronous and asynchronous transports. 15 | 16 | Both python 2.7 and 3.3 are supported. 17 | 18 | Install with:: 19 | 20 | $ pip install ns1-python 21 | 22 | 23 | Quick Start 24 | ----------- 25 | 26 | First, you'll need an API Key. To create one, login to `the portal `_ and 27 | click on the Account button in the top right. Select Settings & Users, then add a new 28 | API Key at the bottom. 29 | 30 | 31 | Simple example: 32 | 33 | .. code-block:: python 34 | 35 | from ns1 import NS1 36 | 37 | api = NS1(apiKey='<>') 38 | zone = api.createZone('example.com', nx_ttl=3600) 39 | print(zone) 40 | record = zone.add_A('honey', ['1.2.3.4', '5.6.7.8']) 41 | print(record) 42 | 43 | Note that all zone and record changes propagate in real time throughout the NS1 platform. 44 | 45 | There are more examples in the `examples directory `_. 46 | 47 | Contributions 48 | ------------- 49 | 50 | We welcome contributions! Please `fork on GitHub `_ and submit a Pull Request. 51 | 52 | Contents 53 | -------- 54 | 55 | .. toctree:: 56 | :maxdepth: 1 57 | 58 | features 59 | configuration 60 | usage 61 | 62 | 63 | Reference 64 | --------- 65 | 66 | .. toctree:: 67 | 68 | api/modules 69 | 70 | Indices and tables 71 | ================== 72 | 73 | * :ref:`genindex` 74 | * :ref:`modindex` 75 | * :ref:`search` 76 | 77 | -------------------------------------------------------------------------------- /doc/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | There are many examples of usage in the `examples directory `_. 5 | 6 | 7 | -------------------------------------------------------------------------------- /examples/alerts.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1 import NS1 8 | 9 | # NS1 will use config in ~/.nsone by default 10 | api = NS1() 11 | 12 | # to specify an apikey here instead, use: 13 | # api = NS1(apiKey='<>') 14 | 15 | # to load an alternate configuration file: 16 | # api = NS1(configFile='/etc/ns1/api.json') 17 | 18 | # turn on "follow pagination". This will handle paginated responses for 19 | # zone list and the records for a zone retrieve. It's off by default to 20 | # avoid a breaking change 21 | config = api.config 22 | config["follow_pagination"] = True 23 | 24 | # create a new zone, get a Zone object back 25 | # to use in a new alert 26 | zone = api.createZone( 27 | "example-secondary.com", 28 | secondary={ 29 | "enabled": True, 30 | "primary_ip": "198.51.100.12", 31 | "primary_port": 53, 32 | "tsig": { 33 | "enabled": False, 34 | }, 35 | }, 36 | ) 37 | print("Created zone: %s" % zone["name"]) 38 | 39 | # Create a notifier list. 40 | nl = api.notifylists().create( 41 | body={ 42 | "name": "example", 43 | "notify_list": [ 44 | {"type": "email", "config": {"email": "user@example.com"}} 45 | ], 46 | } 47 | ) 48 | print("Created notifier list with id: %s" % nl["id"]) 49 | nl_id = nl["id"] 50 | 51 | # Create an alert 52 | newAlert = api.alerts().create( 53 | name="example_alert", 54 | type="zone", 55 | subtype="transfer_failed", 56 | zone_names=["example-secondary.com"], 57 | notifier_list_ids=[nl_id], 58 | ) 59 | alert_id = newAlert["id"] 60 | print("Created alert with id: %s" % alert_id) 61 | 62 | # List alerts. 63 | alertList = api.alerts().list() 64 | print(alertList) 65 | for alert in alertList: 66 | print(alert["name"]) 67 | 68 | # Clean up. 69 | api.alerts().delete(alert_id) 70 | api.notifylists().delete(nl_id) 71 | zone.delete() 72 | -------------------------------------------------------------------------------- /examples/async-twisted.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | ########### 8 | # TWISTED # 9 | ########### 10 | 11 | from ns1 import NS1, Config 12 | from twisted.internet import defer, reactor 13 | 14 | config = Config() 15 | # load default config 16 | config.loadFromFile(Config.DEFAULT_CONFIG_FILE) 17 | # to load directly from apikey instead, use 18 | # config.createFromAPIKey('<>') 19 | 20 | # override default synchronous transport. note, this would normally go 21 | # in config file. 22 | config["transport"] = "twisted" 23 | api = NS1(config=config) 24 | 25 | 26 | @defer.inlineCallbacks 27 | def getQPS(): 28 | # when twisted transport is in use, all of the NS1 methods return 29 | # Deferred. yield them to gather the results, or add callbacks/errbacks 30 | # to be run when results are available 31 | zone = yield api.loadZone("test.com") 32 | qps = yield zone.qps() 33 | defer.returnValue(qps) 34 | 35 | 36 | def gotQPS(result): 37 | print("current QPS for test.com: %s" % result["qps"]) 38 | reactor.stop() 39 | 40 | 41 | def handleError(failure): 42 | print(failure) 43 | reactor.stop() 44 | 45 | 46 | qps = getQPS() 47 | qps.addCallback(gotQPS) 48 | qps.addErrback(handleError) 49 | 50 | reactor.run() 51 | -------------------------------------------------------------------------------- /examples/billing_usage.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2025 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1 import NS1 8 | import datetime 9 | 10 | # NS1 will use config in ~/.nsone by default 11 | api = NS1() 12 | 13 | # to specify an apikey here instead, use: 14 | 15 | # from ns1 import Config 16 | # config = Config() 17 | # config.createFromAPIKey('<>') 18 | # api = NS1(config=config) 19 | 20 | config = api.config 21 | 22 | from_unix = int( 23 | datetime.datetime.fromisoformat("2025-02-01 00:00:00").strftime("%s") 24 | ) 25 | to_unix = int( 26 | datetime.datetime.fromisoformat("2025-02-28 23:59:59").strftime("%s") 27 | ) 28 | 29 | ############################ 30 | # GET BILLING USAGE LIMITS # 31 | ############################ 32 | 33 | limits = api.billing_usage().getLimits(from_unix, to_unix) 34 | print("### USAGE LIMITS ###") 35 | print(limits) 36 | print("####################") 37 | 38 | ################################### 39 | # GET BILLING USAGE FOR QUERIES # 40 | ################################### 41 | 42 | usg = api.billing_usage().getQueriesUsage(from_unix, to_unix) 43 | print("### QUERIES USAGE ###") 44 | print(usg) 45 | print("####################") 46 | 47 | ################################### 48 | # GET BILLING USAGE FOR DECISIONS # 49 | ################################### 50 | 51 | usg = api.billing_usage().getDecisionsUsage(from_unix, to_unix) 52 | print("### DECISIONS USAGE ###") 53 | print(usg) 54 | print("####################") 55 | 56 | ################################### 57 | # GET BILLING USAGE FOR MONITORS # 58 | ################################### 59 | 60 | usg = api.billing_usage().getMonitorsUsage() 61 | print("### MONITORS USAGE ###") 62 | print(usg) 63 | print("####################") 64 | 65 | ################################### 66 | # GET BILLING USAGE FOR FILER CHAINS # 67 | ################################### 68 | 69 | usg = api.billing_usage().getMonitorsUsage() 70 | print("### FILTER CHAINS USAGE ###") 71 | print(usg) 72 | print("####################") 73 | 74 | ################################### 75 | # GET BILLING USAGE FOR RECORDS # 76 | ################################### 77 | 78 | usg = api.billing_usage().getRecordsUsage() 79 | print("### RECORDS USAGE ###") 80 | print(usg) 81 | print("####################") 82 | -------------------------------------------------------------------------------- /examples/clone-record.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1 import NS1 8 | 9 | # NS1 will use config in ~/.nsone by default 10 | api = NS1() 11 | 12 | # to specify an apikey here instead, use: 13 | # api = NS1(apiKey='<>') 14 | 15 | # to load an alternate configuration file: 16 | # api = NS1(configFile='/etc/ns1/api.json') 17 | 18 | # load a zone 19 | zone = api.loadZone("test.com") 20 | 21 | # create a complex record 22 | zone.add_A( 23 | "complex", 24 | [ 25 | {"answer": ["1.1.1.1"], "meta": {"up": False, "country": ["US"]}}, 26 | {"answer": ["9.9.9.9"], "meta": {"up": True, "country": ["FR"]}}, 27 | ], 28 | use_csubnet=True, 29 | filters=[{"geotarget_country": {}}, {"select_first_n": {"N": 1}}], 30 | ) 31 | 32 | # copy it to another record: old domain, new domain, record type 33 | newrec = zone.cloneRecord("complex", "copy", "A") 34 | print(newrec) 35 | 36 | # you can also copy it to a different zone 37 | newrec = zone.cloneRecord("complex", "complex", "A", zone="example.com") 38 | print(newrec) 39 | -------------------------------------------------------------------------------- /examples/config.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1 import NS1, Config 8 | 9 | # you can either build your own config, or let NS1 build a default one for 10 | # you. the latter is easier and works like this: 11 | 12 | # NS1 will use config in ~/.nsone by default 13 | api = NS1() 14 | 15 | # to specify an apikey here instead, use: 16 | api = NS1(apiKey="<>") 17 | 18 | # to load an alternate configuration file: 19 | api = NS1(configFile="/etc/ns1/api.json") 20 | 21 | # to load a specific keyID inside of your config file (see config format 22 | # in docs), use this. this only makes sense for config file loads, not 23 | # apiKey loads: 24 | api = NS1(keyID="all-access") 25 | 26 | # if you have special needs, build your own Config object and pass it to 27 | # NS1: 28 | config = Config() 29 | config.createFromAPIKey("<>") 30 | config["verbosity"] = 5 31 | config["transport"] = "twisted" 32 | api = NS1(config=config) 33 | 34 | # you can get the current config object NS1 is using via 35 | config = api.config 36 | 37 | # change config variables 38 | config["verbosity"] = 5 39 | 40 | # write out new config files 41 | config.write("/tmp/newconfig.json") 42 | 43 | # the config file format supports different apiKeys (see docs) using keyID 44 | 45 | # get the current keyID 46 | print(config.getCurrentKeyID()) 47 | 48 | # use a different keyID 49 | config.useKeyID("read-access") 50 | -------------------------------------------------------------------------------- /examples/data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1 import NS1 8 | 9 | # NS1 will use config in ~/.nsone by default 10 | api = NS1() 11 | 12 | # to specify an apikey here instead, use: 13 | 14 | # from ns1 import Config 15 | # config = Config() 16 | # config.createFromAPIKey('<>') 17 | # api = NS1(config=config) 18 | 19 | # create a zone to play in 20 | zone = api.createZone("testzone.com") 21 | 22 | # create an NS1 API data source 23 | sourceAPI = api.datasource() 24 | s = sourceAPI.create("my api source", "nsone_v1") 25 | sourceID = s["id"] 26 | 27 | # create feeds which will drive the meta data for each answer 28 | # we'll use the id of these feeds when we connect the feeds to the 29 | # answer meta below 30 | feedAPI = api.datafeed() 31 | feed1 = feedAPI.create( 32 | sourceID, "feed to server1", config={"label": "server1"} 33 | ) 34 | 35 | feed2 = feedAPI.create( 36 | sourceID, "feed to server2", config={"label": "server2"} 37 | ) 38 | 39 | # create a record to connect to this source, with two answers 40 | # specify the up filter so we can send traffic to only those nodes 41 | # which are known to be up. we'll start with just the second answer up. 42 | # each 'up' meta value is a feed pointer, pointing to the feeds we 43 | # created above 44 | record = zone.add_A( 45 | "record", 46 | [ 47 | {"answer": ["1.1.1.1"], "meta": {"up": {"feed": feed1["id"]}}}, 48 | {"answer": ["9.9.9.9"], "meta": {"up": {"feed": feed2["id"]}}}, 49 | ], 50 | filters=[{"up": {}}], 51 | ) 52 | 53 | # now publish an update via feed to the records. here we push to both 54 | # feeds at once, but you can push to one or the other individually as well 55 | sourceAPI.publish( 56 | sourceID, {"server1": {"up": True}, "server2": {"up": False}} 57 | ) 58 | 59 | # NS1 will instantly notify DNS servers at the edges, causing traffic to be 60 | # sent to server1, and ceasing traffic to server2 61 | 62 | # Disconnect feed1 from datasource. 63 | # feedAPI.delete(sourceID, feed1['id']) 64 | -------------------------------------------------------------------------------- /examples/datasets.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | import time 8 | 9 | from ns1 import NS1 10 | 11 | # NS1 will use config in ~/.nsone by default 12 | api = NS1() 13 | 14 | # to specify an apikey here instead, use: 15 | 16 | # from ns1 import Config 17 | # config = Config() 18 | # config.createFromAPIKey('<>') 19 | # api = NS1(config=config) 20 | 21 | config = api.config 22 | 23 | ######################### 24 | # LOAD / CREATE DATASET # 25 | ######################### 26 | 27 | # create a dataset 28 | dt = api.datasets().create( 29 | name="my dataset", 30 | datatype={ 31 | "type": "num_queries", 32 | "scope": "account", 33 | }, 34 | repeat=None, 35 | timeframe={"aggregation": "monthly", "cycles": 1}, 36 | export_type="csv", 37 | recipient_emails=None, 38 | ) 39 | print(dt) 40 | 41 | # to load an existing dataset, get a Dataset object back 42 | dt = api.datasets().retrieve(dt.get("id")) 43 | print(dt) 44 | 45 | ###################### 46 | # DOWNLOAD REPORTS # 47 | ###################### 48 | 49 | while True: 50 | print("waiting for report to be generated...") 51 | time.sleep(5) 52 | 53 | dt = api.datasets().retrieve(dt.get("id")) 54 | reports = dt.get("reports") 55 | if reports is None: 56 | continue 57 | 58 | status = reports[0].get("status") 59 | if status == "available": 60 | print("report generation completed") 61 | break 62 | 63 | if status == "failed": 64 | print("failed to generate report") 65 | exit(1) 66 | 67 | report = api.datasets().retrieveReport(dt.get("id"), reports[0].get("id")) 68 | file_path = "%s.%s" % (dt.get("name"), dt.get("export_type")) 69 | 70 | with open(file_path, "w") as file: 71 | file.write(report) 72 | 73 | print("dataset report saved to", file_path) 74 | -------------------------------------------------------------------------------- /examples/errors-and-debugging.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | import logging 8 | from ns1 import NS1, Config 9 | 10 | # to enable verbose logging, set 'verbosity' in the config and use 11 | # the standard python logging system 12 | 13 | config = Config() 14 | config.createFromAPIKey("<>") 15 | config["verbosity"] = 5 16 | logging.basicConfig(level=logging.DEBUG) 17 | print(config) 18 | api = NS1(config=config) 19 | 20 | # now all requests will show up in the logging system 21 | 22 | # exception handling: 23 | # the follow exceptions may be thrown 24 | # from ns1.rest.errors import ResourceException, \ 25 | # RateLimitException, AuthException 26 | 27 | # ResourceException is the base exception (Auth and RateLimit extend it) 28 | # it (and therefore they) have the properties message, response, body 29 | 30 | # AuthException is raised when apikey is incorrect or the key doesn't 31 | # have permission to the requested resource 32 | 33 | # RateLimitException is raised when rate limit is exceed 34 | # you can access the properties by, limit, and period to calculate backoff 35 | 36 | # ResourceException is raised in any other exception situation 37 | -------------------------------------------------------------------------------- /examples/importzone.db: -------------------------------------------------------------------------------- 1 | $TTL 86400 ; 24 hours could have been written as 24h or 1d 2 | ; $TTL used for all RRs without explicit TTL value 3 | $ORIGIN example2.com. 4 | @ 1D IN SOA ns1.example2.com. hostmaster.example.com. ( 5 | 2014022401 ; serial 6 | 3H ; refresh 7 | 15 ; retry 8 | 1w ; expire 9 | 3h ; minimum 10 | ) 11 | IN NS ns1.example2.com. ; in the domain 12 | IN NS ns2.smokeyjoe.com. ; external to domain 13 | IN MX 10 mail.another.com. ; external mail provider 14 | ; server host definitions 15 | ns1 IN A 192.168.0.1 ;name server definition 16 | www IN A 192.168.0.2 ;web server definition 17 | ftp IN CNAME www.example.com. ;ftp server definition 18 | ; non server domain hosts 19 | bill IN A 192.168.0.3 20 | fred IN A 192.168.0.4 21 | -------------------------------------------------------------------------------- /examples/rate-limiting.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1 import NS1, Config 8 | 9 | 10 | # Two rate limit strategies, intended to avoid triggering 429 in the first 11 | # place, are included. Strategies can be set in config, and can be used with 12 | # all transports. 13 | 14 | # The default rate limiting strategy is "none". On 429 response, a 15 | # RateLimitException is raised, and it is the client's responsibility 16 | # to handle that. 17 | 18 | 19 | def rate_limit_strategy_solo_example(): 20 | """ 21 | This strategy sleeps a bit after each request, based on analysis of the 22 | rate-limiting headers on the response. This is intended for use when we 23 | have a single process/worker hitting the API. 24 | """ 25 | config = _get_config() 26 | config["rate_limit_strategy"] = "solo" 27 | 28 | api = NS1(config=config) 29 | zones_list = api.zones().list() 30 | for z in zones_list: 31 | print(z["zone"]) 32 | zone = api.zones().retrieve(z["zone"]) 33 | print(zone) 34 | 35 | 36 | def rate_limit_strategy_concurrent_example(): 37 | """ 38 | This strategy sleeps a bit after each request, based on analysis of the 39 | rate-limiting headers on the response, and the provided "parallelism" 40 | number. This is intended for use when we have multiple workers hitting the 41 | API concurrently. 42 | """ 43 | config = _get_config() 44 | config["rate_limit_strategy"] = "concurrent" 45 | # number of workers 46 | config["parallelism"] = 11 47 | 48 | api = NS1(config=config) 49 | zones_list = api.zones().list() 50 | for z in zones_list: 51 | print(z["zone"]) 52 | zone = api.zones().retrieve(z["zone"]) 53 | print(zone) 54 | 55 | 56 | def _get_config(): 57 | config = Config() 58 | 59 | # load default config 60 | config.loadFromFile(Config.DEFAULT_CONFIG_FILE) 61 | # to load directly from apikey instead, use 62 | # config.createFromAPIKey('<>') 63 | 64 | return config 65 | 66 | 67 | if __name__ == "main": 68 | rate_limit_strategy_solo_example() 69 | rate_limit_strategy_concurrent_example() 70 | -------------------------------------------------------------------------------- /examples/redirect.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1 import NS1 8 | 9 | # NS1 will use config in ~/.nsone by default 10 | api = NS1() 11 | 12 | # to specify an apikey here instead, use: 13 | # api = NS1(apiKey='<>') 14 | 15 | # to load an alternate configuration file: 16 | # api = NS1(configFile='/etc/ns1/api.json') 17 | 18 | # turn on "follow pagination". This will handle paginated responses for 19 | # redirect and certificate list. It's off by default. 20 | config = api.config 21 | config["follow_pagination"] = True 22 | 23 | redirects = api.redirects() 24 | certificates = api.redirect_certificates() 25 | 26 | ########## 27 | # CREATE # 28 | ########## 29 | 30 | # a redirect can only be created on an existing zone 31 | zone = api.createZone("example.com", nx_ttl=3600) 32 | 33 | # the simplest redirect, https://domain/path -> target, will have https_enabled=True 34 | # so it will also create a certificate for the domain 35 | redirect_https = redirects.create( 36 | domain="source.domain.example.com", 37 | path="/path", 38 | target="https://www.ibm.com/products/ns1-connect", 39 | ) 40 | 41 | # an http redirect, http://domain/path -> target, will not hold any certificate for the domain 42 | redirect_http = redirects.create( 43 | domain="source.domain.example.com", 44 | path="/all/*", 45 | target="http://httpforever.com/", 46 | https_enabled=False, 47 | ) 48 | 49 | # requesting the certificate manually so that we can use a wildcard; 50 | # note that this wildcard does not include *.domain.example.com, the previous domain 51 | certificate_wildcard = certificates.create("*.example.com") 52 | redirect_allsettings = redirects.create( 53 | certificate_id=certificate_wildcard["id"], 54 | domain="files.example.com", 55 | path="*.rpm", 56 | target="https://rpmfind.net/", 57 | https_enabled=True, 58 | https_forced=True, 59 | query_forwarding=False, 60 | forwarding_mode="all", 61 | forwarding_type="permanent", 62 | tags=["test", "me"], 63 | ) 64 | 65 | ########## 66 | # SEARCH # 67 | ########## 68 | 69 | # search; we can also use list() to get all redirects 70 | reds = redirects.searchSource("example.com") 71 | print(reds["total"], len(reds["results"])) 72 | 73 | certs = certificates.search("example.com") 74 | print(certs["total"], len(certs["results"])) 75 | 76 | ################# 77 | # READ / UPDATE # 78 | ################# 79 | 80 | # read 81 | redirect_tmp = redirects.retrieve(redirect_allsettings["id"]) 82 | print(redirect_tmp) 83 | 84 | # update 85 | redirect_tmp = redirects.update( 86 | redirect_tmp, 87 | forwarding_type="temporary", 88 | ) 89 | print(redirect_tmp) 90 | 91 | ########## 92 | # DELETE # 93 | ########## 94 | 95 | # delete redirects 96 | redirects.delete(redirect_https["id"]) 97 | redirects.delete(redirect_http["id"]) 98 | redirects.delete(redirect_allsettings["id"]) 99 | 100 | # also revoke certificate; 101 | # note that the domain in redirect_http is the same so the certificate is also the same 102 | certificates.delete(redirect_https["certificate_id"]) 103 | certificates.delete(redirect_allsettings["certificate_id"]) 104 | 105 | api.zones().delete("example.com") 106 | -------------------------------------------------------------------------------- /examples/stats.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1 import NS1 8 | 9 | # NS1 will use config in ~/.nsone by default 10 | api = NS1() 11 | 12 | # to specify an apikey here instead, use: 13 | # api = NS1(apiKey='<>') 14 | 15 | # to load an alternate configuration file: 16 | # api = NS1(configFile='/etc/ns1/api.json') 17 | 18 | zone = api.loadZone("test.com") 19 | qps = zone.qps() 20 | print("current QPS for test.com: %s" % qps["qps"]) 21 | usage = zone.usage() 22 | print("test.com usage: %s" % usage) 23 | usage = zone.usage(period="30d") 24 | print("test.com 30 d usage: %s" % usage) 25 | 26 | rec = zone.loadRecord("foo", "A") 27 | rqps = rec.qps() 28 | print("current QPS for foo.test.com: %s" % rqps["qps"]) 29 | usage = rec.usage() 30 | print("foo.test.com usage: %s" % usage) 31 | usage = rec.usage(period="30d") 32 | print("foo.test.com 30 d usage: %s" % usage) 33 | -------------------------------------------------------------------------------- /examples/team.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | import uuid 7 | 8 | from ns1 import NS1 9 | 10 | # NS1 will use config in ~/.nsone by default 11 | api = NS1() 12 | 13 | # to specify an apikey here instead, use: 14 | # api = NS1(apiKey='<>') 15 | 16 | # to load an alternate configuration file: 17 | # api = NS1(configFile='/etc/ns1/api.json') 18 | 19 | ######################## 20 | # CREATE / UPDATE TEAM # 21 | ######################## 22 | teamAPI = api.team() 23 | 24 | # create a new team 25 | # you can also specify an ip_whitelist and permissions 26 | # if left blank, all permissions will be defaulted to false 27 | team = teamAPI.create("test-team") 28 | print(team) 29 | teamID = team["id"] 30 | 31 | # modify some of the default permissions 32 | perms = team["permissions"] 33 | perms["data"]["push_to_datafeeds"] = True 34 | perms["account"]["view_invoices"] = True 35 | 36 | # update the team with new permissions 37 | team = teamAPI.update(teamID, permissions=perms) 38 | print(team) 39 | 40 | ###################### 41 | # USERS AND API KEYS # 42 | ###################### 43 | 44 | userAPI = api.user() 45 | 46 | # create a uuid to use as the username 47 | uid = str(uuid.uuid1()).replace("-", "_")[:32] 48 | 49 | # create a new user 50 | # you can also specify ip_whitelist, ip_whitelist_strict, notify, 51 | # and permissions 52 | # if left blank, all permissions will be defaulted to false 53 | user = userAPI.create("example", uid, "email@ns1.io") 54 | print(user) 55 | 56 | # update the user and assign it to the team previously created 57 | userAPI.update(uid, teams=[teamID]) 58 | user = userAPI.retrieve(uid) 59 | 60 | # the user will inherit the permissions of the team 61 | print(user) 62 | 63 | apikeyAPI = api.apikey() 64 | 65 | # create a new apikey 66 | # you can also specify ip_whitelist, ip_whitelist_strict, and permissions 67 | # if left blank, all permissions will be defaulted to false 68 | apikey = apikeyAPI.create("example-key") 69 | print(apikey) 70 | 71 | # update the apikey and assign it to the team previously created 72 | apikey = apikeyAPI.update(apikey["id"], teams=[teamID]) 73 | 74 | # the key will inherit the permissions of the team 75 | print(apikey) 76 | 77 | ############ 78 | # CLEAN UP # 79 | ############ 80 | 81 | # delete the created resources 82 | userAPI.delete(uid) 83 | apikeyAPI.delete(apikey["id"]) 84 | teamAPI.delete(teamID) 85 | -------------------------------------------------------------------------------- /examples/zone-import.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1 import NS1 8 | 9 | # NS1 will use config in ~/.nsone by default 10 | api = NS1() 11 | 12 | # to specify an apikey here instead, use: 13 | # api = NS1(apiKey='<>') 14 | 15 | # to load an alternate configuration file: 16 | # api = NS1(configFile='/etc/ns1/api.json') 17 | 18 | # import a zone from the included example zone definition 19 | zone = api.createZone("example2.com", zoneFile="./importzone.db") 20 | print(zone) 21 | 22 | # delete a whole zone, including all records, data feeds, etc. this is 23 | # immediate and irreversible, so be careful! 24 | zone.delete() 25 | -------------------------------------------------------------------------------- /examples/zones.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1 import NS1 8 | 9 | # NS1 will use config in ~/.nsone by default 10 | api = NS1() 11 | 12 | # to specify an apikey here instead, use: 13 | # api = NS1(apiKey='<>') 14 | 15 | # to load an alternate configuration file: 16 | # api = NS1(configFile='/etc/ns1/api.json') 17 | 18 | # turn on "follow pagination". This will handle paginated responses for 19 | # zone list and the records for a zone retrieve. It's off by default to 20 | # avoid a breaking change 21 | config = api.config 22 | config["follow_pagination"] = True 23 | 24 | ###################### 25 | # LOAD / CREATE ZONE # 26 | ###################### 27 | 28 | # to load an existing zone, get a Zone object back 29 | test_zone = api.loadZone("test.com") 30 | 31 | # or create a new zone, get a Zone object back 32 | # you can specify options like retry, refresh, expiry, nx_ttl, etc 33 | zone = api.createZone("example.com", nx_ttl=3600) 34 | print(zone) 35 | 36 | # once you have a Zone, you can access all the zone information via the 37 | # data property 38 | print(zone.data["dns_servers"]) 39 | 40 | ############### 41 | # ADD RECORDS # 42 | ############### 43 | 44 | # in general, use add_XXXX to add a new record to a zone, where XXXX represents 45 | # the record type. all of these take optional named parameters like 46 | # ttl, use_csubnet, feed, networks, meta, regions, filters 47 | # all of these return Record objects 48 | 49 | # add an A record with a single static answer 50 | rec = zone.add_A("orchid", "2.2.2.2") 51 | print(rec) 52 | 53 | # add an A record with two static answers 54 | zone.add_A("honey", ["1.2.3.4", "5.6.7.8"]) 55 | 56 | # add a cname 57 | zone.add_CNAME("pot", "honey.example.com") 58 | 59 | # add an MX with two answers, priority 5 and 10 60 | zone.add_MX( 61 | "example.com", [[5, "mail1.example.com"], [10, "mail2.example.com"]] 62 | ) 63 | 64 | # add a AAAA, specify ttl of 300 seconds 65 | zone.add_AAAA("honey6", "2607:f8b0:4006:806::1010", ttl=300) 66 | 67 | # add an A record using full answer format to specify 2 answers with meta data. 68 | # ensure edns-client-subnet is in use, and add two filters: geotarget_country, 69 | # and select_first_n, which has a filter config option N set to 1 70 | zone.add_A( 71 | "bumble", 72 | [ 73 | {"answer": ["1.1.1.1"], "meta": {"up": False, "country": ["US"]}}, 74 | {"answer": ["9.9.9.9"], "meta": {"up": True, "country": ["FR"]}}, 75 | ], 76 | use_csubnet=True, 77 | filters=[{"geotarget_country": {}}, {"select_first_n": {"N": 1}}], 78 | ) 79 | 80 | # zone usage 81 | print(zone.qps()) 82 | 83 | ########################### 84 | # LOAD and UPDATE RECORDS # 85 | ########################### 86 | 87 | # if you don't have a Record object yet, you can load it in one of two ways: 88 | # 1) directly from the top level NS1 object by specifying name and type 89 | rec = api.loadRecord("honey.example.com", "A") 90 | print(rec) 91 | # 2) if you have a Zone object already, you can load it from that 92 | rec = zone.loadRecord("honey", "A") 93 | print(rec) 94 | 95 | # you can access all the record information via the data property 96 | print(rec.data["answers"]) 97 | 98 | # add answer(s) to existing answer list 99 | rec.addAnswers("4.4.4.4") 100 | rec.addAnswers(["3.4.5.6", "4.5.6.8"]) 101 | print(rec.data["answers"]) 102 | 103 | # update the full answer list 104 | rec.update(answers=["6.6.6.6", "7.7.7.7"]) 105 | print(rec.data["answers"]) 106 | 107 | # set filters, ttl 108 | rec.update( 109 | filters=[{"geotarget_country": {}}, {"select_first_n": {"N": 1}}], ttl=10 110 | ) 111 | 112 | # update record level (as opposed to zone or answer level) meta data 113 | rec.update(meta={"up": False}) 114 | 115 | # update answer level meta data directly. note this is better done through 116 | # a data feed (see examples/data.py), which allows changing the meta data 117 | # values individually, without having to set the answer block 118 | rec = zone.loadRecord("bumble", "A") 119 | print(rec.data["answers"]) 120 | rec.update( 121 | answers=[ 122 | {"answer": ["1.1.1.1"], "meta": {"up": True, "country": ["US"]}}, 123 | {"answer": ["9.9.9.9"], "meta": {"up": False, "country": ["FR"]}}, 124 | ] 125 | ) 126 | print(rec.data["answers"]) 127 | 128 | # record usage 129 | print(rec.qps()) 130 | 131 | 132 | ########## 133 | # DELETE # 134 | ########## 135 | 136 | # delete a single record 137 | rec.delete() 138 | 139 | # delete a whole zone, including all records, data feeds, etc. this is 140 | # immediate and irreversible, so be careful! 141 | zone.delete() 142 | -------------------------------------------------------------------------------- /ns1/acls.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from ns1.rest.acls import Acls 7 | 8 | 9 | class AclException(Exception): 10 | pass 11 | 12 | 13 | class Acl(object): 14 | def __init__(self, config, acl): 15 | self._rest = Acls(config) 16 | self.config = config 17 | self.acl = acl 18 | self.data = None 19 | 20 | def __repr__(self): 21 | return "" % self.acl 22 | 23 | def __getitem__(self, item): 24 | return self.data.get(item, None) 25 | 26 | def reload(self, callback=None, errback=None): 27 | return self.load(reload=True, callback=callback, errback=errback) 28 | 29 | def load(self, callback=None, errback=None, reload=False): 30 | if not reload and self.data: 31 | raise AclException("acl already loaded") 32 | 33 | def success(result, *args): 34 | self.data = result 35 | if callback: 36 | return callback(self) 37 | else: 38 | return self 39 | 40 | return self._rest.retrieve(self.acl, callback=success, errback=errback) 41 | 42 | def delete(self, callback=None, errback=None): 43 | return self._rest.delete(self.acl, callback=callback, errback=errback) 44 | 45 | def update(self, callback=None, errback=None, **kwargs): 46 | if not self.data: 47 | raise AclException("acl not loaded") 48 | 49 | def success(result, *args): 50 | self.data = result 51 | if callback: 52 | return callback(self) 53 | else: 54 | return self 55 | 56 | return self._rest.update( 57 | self.acl, callback=success, errback=errback, **kwargs 58 | ) 59 | 60 | def create(self, callback=None, errback=None, **kwargs): 61 | if self.data: 62 | raise AclException("acl already loaded") 63 | 64 | def success(result, *args): 65 | self.data = result 66 | if callback: 67 | return callback(self) 68 | else: 69 | return self 70 | 71 | return self._rest.create( 72 | self.acl, callback=success, errback=errback, **kwargs 73 | ) 74 | -------------------------------------------------------------------------------- /ns1/dataset.py: -------------------------------------------------------------------------------- 1 | from ns1.rest.datasets import Datasets 2 | 3 | 4 | class DatasetException(Exception): 5 | pass 6 | 7 | 8 | class Dataset(object): 9 | """ 10 | High level object representing a dataset. 11 | """ 12 | 13 | def __init__(self, config): 14 | """ 15 | Create a new high level Dataset object 16 | :param ns1.config.Config config: config object 17 | """ 18 | self._rest = Datasets(config) 19 | self.config = config 20 | self.data = None 21 | 22 | def __repr__(self): 23 | return "" % ( 24 | self.__getitem__("id"), 25 | self.__getitem__("name"), 26 | self.__getitem__("datatype"), 27 | self.__getitem__("repeat"), 28 | self.__getitem__("timeframe"), 29 | self.__getitem__("export_type"), 30 | self.__getitem__("recipient_emails"), 31 | ) 32 | 33 | def __getitem__(self, item: str): 34 | if not self.data: 35 | raise DatasetException("dataset not loaded") 36 | return self.data.get(item, None) 37 | 38 | def reload(self, callback=None, errback=None): 39 | """ 40 | Reload dataset data from the API. 41 | :param callback: function call back once the call has completed 42 | :param errback: function call back if the call fails 43 | """ 44 | return self.load(reload=True, callback=callback, errback=errback) 45 | 46 | def load(self, id: str = None, callback=None, errback=None, reload=False): 47 | """ 48 | Load dataset data from the API. 49 | :param str id: dataset id to load 50 | :param callback: function call back once the call has completed 51 | :param bool reload: whether to reuse the instance data instead of fetching it from the server 52 | """ 53 | if not reload and self.data: 54 | return self.data 55 | if id is None and self.data: 56 | id = self.__getitem__("id") 57 | if id is None: 58 | raise DatasetException("no dataset id: did you mean to create?") 59 | 60 | def success(result: dict, *args): 61 | self.data = result 62 | if callback: 63 | return callback(self) 64 | else: 65 | return self 66 | 67 | return self._rest.retrieve(id, callback=success, errback=errback) 68 | 69 | def loadFromDict(self, dt: dict): 70 | """ 71 | Load dataset data from a dictionary. 72 | :param dict dt: dictionary containing *at least* either an id or domain/path/target 73 | """ 74 | if "id" in dt or ( 75 | "name" in dt 76 | and "datatype" in dt 77 | and "repeat" in dt 78 | and "timeframe" in dt 79 | and "export_type" in dt 80 | and "recipient_emails" in dt 81 | ): 82 | self.data = dt 83 | return self 84 | else: 85 | raise DatasetException("insufficient parameters") 86 | 87 | def delete(self, callback=None, errback=None): 88 | """ 89 | Delete the dataset. 90 | :param callback: function call back once the call has completed 91 | :param errback: function call back if the call fails 92 | """ 93 | id = self.__getitem__("id") 94 | return self._rest.delete(id, callback=callback, errback=errback) 95 | 96 | def create( 97 | self, 98 | name: str, 99 | datatype: dict, 100 | repeat: dict, 101 | timeframe: dict, 102 | export_type: str, 103 | recipient_emails: list, 104 | callback=None, 105 | errback=None, 106 | **kwargs 107 | ): 108 | """ 109 | Create a new dataset. Pass a list of keywords and their values to 110 | configure. For the list of keywords available for dataset configuration, 111 | see :attr:`ns1.rest.datasets.Datasets.PASSTHRU_FIELDS` 112 | :param str name: the name of the dataset 113 | :param str datatype: datatype settings to define the type of data to be pulled 114 | :param str repeat: repeat settings to define recurrent reports 115 | :param str timeframe: timeframe settings for the data to be pulled 116 | :param str export_type: output format of the report 117 | :param str recipient_emails: list of user emails that will receive a copy of the report 118 | :param callback: function call back once the call has completed 119 | :param errback: function call back if the call fails 120 | """ 121 | if self.data: 122 | raise DatasetException("dataset already loaded") 123 | 124 | return self._rest.create( 125 | name, 126 | datatype, 127 | repeat, 128 | timeframe, 129 | export_type, 130 | recipient_emails, 131 | callback=callback, 132 | errback=errback, 133 | **kwargs 134 | ) 135 | 136 | def listDatasets(self, callback=None, errback=None): 137 | """ 138 | Lists all datasets currently configured. 139 | :param callback: function call back once the call has completed 140 | :param errback: function call back if the call fails 141 | :return: a list of Dataset objects 142 | """ 143 | 144 | def success(result, *args): 145 | ret = [] 146 | for dt in result: 147 | ret.append(Dataset(self.config).loadFromDict(dt)) 148 | if callback: 149 | return callback(ret) 150 | else: 151 | return ret 152 | 153 | return Datasets(self.config).list(callback=success, errback=errback) 154 | 155 | def retrieveReport( 156 | self, rp_id: str, dt_id: str = None, callback=None, errback=None 157 | ): 158 | """ 159 | Retrieves a generated report given a dataset id and a report id 160 | :param str rp_id: the id of the generated report to download 161 | :param str dt_id: the id of the dataset that the above report belongs to 162 | :param callback: function call back once the call has completed 163 | :param errback: function call back if the call fails 164 | :return: generated report 165 | """ 166 | 167 | if dt_id is None and self.data: 168 | dt_id = self.__getitem__("id") 169 | if dt_id is None: 170 | raise DatasetException("no dataset id: did you mean to create?") 171 | 172 | return Datasets(self.config).retrieveReport( 173 | dt_id, rp_id, callback=callback, errback=errback 174 | ) 175 | -------------------------------------------------------------------------------- /ns1/helpers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from threading import Lock 4 | 5 | 6 | class SingletonMixin(object): 7 | """double-locked thread safe singleton""" 8 | 9 | _instance = None 10 | _lock = Lock() 11 | 12 | def __new__(cls, *args, **kwargs): 13 | if cls._instance is None: 14 | with cls._lock: 15 | if cls._instance is None: 16 | cls._instance = object.__new__(cls, *args, **kwargs) 17 | return cls._instance 18 | 19 | 20 | def get_next_page(headers): 21 | headers = {k.lower(): v for k, v in headers.items()} 22 | links = _parse_header_links(headers.get("link", "")) 23 | for link in links: 24 | if link.get("rel") == "next": 25 | return link.get("url").replace("http://", "https://") 26 | 27 | 28 | # cribbed from requests, since we don't want to require it as a dependency 29 | def _parse_header_links(value): 30 | """Return a dict of parsed link headers proxies. 31 | i.e. Link: ; rel=front; type="image/jpeg",; rel=back;type="image/jpeg" 32 | """ 33 | links = [] 34 | replace_chars = " '\"" 35 | for val in re.split(", *<", value): 36 | try: 37 | url, params = val.split(";", 1) 38 | except ValueError: 39 | url, params = val, "" 40 | link = {} 41 | link["url"] = url.strip("<> '\"") 42 | for param in params.split(";"): 43 | try: 44 | key, value = param.split("=") 45 | except ValueError: 46 | break 47 | link[key.strip(replace_chars)] = value.strip(replace_chars) 48 | links.append(link) 49 | return links 50 | -------------------------------------------------------------------------------- /ns1/monitoring.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1.rest.monitoring import Monitors 8 | 9 | 10 | class MonitorException(Exception): 11 | pass 12 | 13 | 14 | class Monitor(object): 15 | """ 16 | High level object representing a Monitor 17 | """ 18 | 19 | def __init__(self, config, data=None): 20 | """ 21 | Create a new high level Monitor object 22 | 23 | :param ns1.config.Config config: config object 24 | """ 25 | self._rest = Monitors(config) 26 | self.config = config 27 | if data: 28 | self.data = data 29 | else: 30 | self.data = None 31 | 32 | def __repr__(self): 33 | return "" % ( 34 | self.data["name"], 35 | self.data["id"], 36 | ) 37 | 38 | def __getitem__(self, item): 39 | return self.data.get(item, None) 40 | 41 | def reload(self, callback=None, errback=None): 42 | """ 43 | Reload monitor data from the API. 44 | """ 45 | return self.load(reload=True, callback=callback, errback=errback) 46 | 47 | def load(self, callback=None, errback=None, reload=False): 48 | """ 49 | Load monitor data from the API. 50 | """ 51 | if not reload and self.data: 52 | raise MonitorException("monitor already loaded") 53 | 54 | def success(result, *args): 55 | self.data = result 56 | if callback: 57 | return callback(self) 58 | else: 59 | return self 60 | 61 | return self._rest.retrieve( 62 | self.data["id"], callback=success, errback=errback 63 | ) 64 | 65 | def delete(self, callback=None, errback=None): 66 | """ 67 | Delete the monitor 68 | """ 69 | return self._rest.delete( 70 | self.data["id"], callback=callback, errback=errback 71 | ) 72 | 73 | def update(self, callback=None, errback=None, **kwargs): 74 | """ 75 | Update monitor configuration. Pass a list of keywords and their values to 76 | update. 77 | """ 78 | if not self.data: 79 | raise MonitorException("monitor not loaded") 80 | 81 | def success(result, *args): 82 | self.data = result 83 | if callback: 84 | return callback(self) 85 | else: 86 | return self 87 | 88 | return self._rest.update( 89 | self.data["id"], {}, callback=success, errback=errback, **kwargs 90 | ) 91 | 92 | def create(self, callback=None, errback=None, **kwargs): 93 | """ 94 | Create a new monitoring job. Pass a list of keywords and their values to 95 | configure 96 | """ 97 | if self.data: 98 | raise MonitorException("monitor already loaded") 99 | 100 | def success(result, *args): 101 | self.data = result 102 | if callback: 103 | return callback(self) 104 | else: 105 | return self 106 | 107 | return self._rest.create( 108 | {}, callback=success, errback=errback, **kwargs 109 | ) 110 | -------------------------------------------------------------------------------- /ns1/records.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from ns1.rest.records import Records 8 | from ns1.rest.stats import Stats 9 | 10 | 11 | class RecordException(Exception): 12 | pass 13 | 14 | 15 | class Record(object): 16 | """ 17 | High level object representing a Record 18 | """ 19 | 20 | def __init__(self, parentZone, domain, type): 21 | """ 22 | Create a new high level Record 23 | 24 | :param ns1.zones.Zone parentZone: the high level Zone parent object 25 | :param str domain: full domain name this record represents. if the \ 26 | domain does not end with the zone name, it is appended. 27 | :param str type: The DNS record type (A, MX, etc) 28 | """ 29 | self._rest = Records(parentZone.config) 30 | self.parentZone = parentZone 31 | if not domain.endswith(parentZone.zone): 32 | domain = domain + "." + parentZone.zone 33 | self.domain = domain 34 | self.type = type 35 | self.data = None 36 | 37 | def __repr__(self): 38 | return "" % (self.domain, self.type) 39 | 40 | def __getitem__(self, item): 41 | return self.data.get(item, None) 42 | 43 | def _parseModel(self, data): 44 | self.data = data 45 | self.answers = data["answers"] 46 | # XXX break out the rest? use getattr instead? 47 | 48 | def reload(self, callback=None, errback=None): 49 | """ 50 | Reload record data from the API. 51 | """ 52 | return self.load(reload=True, callback=callback, errback=errback) 53 | 54 | def load(self, callback=None, errback=None, reload=False): 55 | """ 56 | Load record data from the API. 57 | """ 58 | if not reload and self.data: 59 | raise RecordException("record already loaded") 60 | 61 | def success(result, *args): 62 | self._parseModel(result) 63 | if callback: 64 | return callback(self) 65 | else: 66 | return self 67 | 68 | return self._rest.retrieve( 69 | self.parentZone.zone, 70 | self.domain, 71 | self.type, 72 | callback=success, 73 | errback=errback, 74 | ) 75 | 76 | def delete(self, callback=None, errback=None): 77 | """ 78 | Delete the record from the zone, including all advanced configuration, 79 | meta data, etc. 80 | """ 81 | if not self.data: 82 | raise RecordException("record not loaded") 83 | 84 | def success(result, *args): 85 | if callback: 86 | return callback(result) 87 | else: 88 | return result 89 | 90 | return self._rest.delete( 91 | self.parentZone.zone, 92 | self.domain, 93 | self.type, 94 | callback=success, 95 | errback=errback, 96 | ) 97 | 98 | def update(self, callback=None, errback=None, **kwargs): 99 | """ 100 | Update record configuration. Pass list of keywords and their values to 101 | update. For the list of keywords available for zone configuration, see 102 | :attr:`ns1.rest.records.Records.INT_FIELDS`, 103 | :attr:`ns1.rest.records.Records.PASSTHRU_FIELDS`, 104 | :attr:`ns1.rest.records.Records.BOOL_FIELDS` 105 | """ 106 | if not self.data: 107 | raise RecordException("record not loaded") 108 | 109 | def success(result, *args): 110 | self._parseModel(result) 111 | if callback: 112 | return callback(self) 113 | else: 114 | return self 115 | 116 | return self._rest.update( 117 | self.parentZone.zone, 118 | self.domain, 119 | self.type, 120 | callback=success, 121 | errback=errback, 122 | **kwargs 123 | ) 124 | 125 | def create(self, callback=None, errback=None, **kwargs): 126 | """ 127 | Create new record. Pass a list of keywords and their values to 128 | config. For the list of keywords available for zone configuration, see 129 | :attr:`ns1.rest.records.Records.INT_FIELDS`, 130 | :attr:`ns1.rest.records.Records.PASSTHRU_FIELDS`, 131 | :attr:`ns1.rest.records.Records.BOOL_FIELDS` 132 | """ 133 | if self.data: 134 | raise RecordException("record already loaded") 135 | 136 | def success(result, *args): 137 | self._parseModel(result) 138 | if callback: 139 | return callback(self) 140 | else: 141 | return self 142 | 143 | return self._rest.create( 144 | self.parentZone.zone, 145 | self.domain, 146 | self.type, 147 | callback=success, 148 | errback=errback, 149 | **kwargs 150 | ) 151 | 152 | def qps(self, callback=None, errback=None): 153 | """ 154 | Return the current QPS for this record 155 | 156 | :rtype: dict 157 | :return: QPS information 158 | """ 159 | if not self.data: 160 | raise RecordException("record not loaded") 161 | stats = Stats(self.parentZone.config) 162 | return stats.qps( 163 | zone=self.parentZone.zone, 164 | domain=self.domain, 165 | type=self.type, 166 | callback=callback, 167 | errback=errback, 168 | ) 169 | 170 | def usage(self, callback=None, errback=None, **kwargs): 171 | """ 172 | Return the current usage information for this record 173 | 174 | :rtype: dict 175 | :return: usage information 176 | """ 177 | if not self.data: 178 | raise RecordException("record not loaded") 179 | stats = Stats(self.parentZone.config) 180 | return stats.usage( 181 | zone=self.parentZone.zone, 182 | domain=self.domain, 183 | type=self.type, 184 | callback=callback, 185 | errback=errback, 186 | **kwargs 187 | ) 188 | 189 | def addAnswers(self, answers, callback=None, errback=None, **kwargs): 190 | """ 191 | Add answers to the record. 192 | 193 | :param answers: answers structure. See the class note on answer format. 194 | """ 195 | if not self.data: 196 | raise RecordException("record not loaded") 197 | orig_answers = self.data["answers"] 198 | new_answers = self._rest._getAnswersForBody(answers) 199 | orig_answers.extend(new_answers) 200 | return self.update( 201 | answers=orig_answers, callback=callback, errback=errback, **kwargs 202 | ) 203 | -------------------------------------------------------------------------------- /ns1/rest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns1/ns1-python/506c5ac3b90e748fec83c38bb864259435061832/ns1/rest/__init__.py -------------------------------------------------------------------------------- /ns1/rest/account.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2015 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from . import resource 7 | 8 | 9 | class Plan(resource.BaseResource): 10 | ROOT = "account/plan" 11 | PASSTHRU_FIELDS = ["type", "period", "notes"] 12 | 13 | def retrieve(self, callback=None, errback=None): 14 | return self._make_request( 15 | "GET", "%s" % (self.ROOT), callback=callback, errback=errback 16 | ) 17 | -------------------------------------------------------------------------------- /ns1/rest/acls.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | 6 | from . import resource 7 | 8 | 9 | class Acls(resource.BaseResource): 10 | ROOT = "acls" 11 | PASSTHRU_FIELDS = ["src_prefixes", "tsig_keys", "gss_tsig_identities"] 12 | 13 | def _buildBody(self, acl_name, **kwargs): 14 | body = {} 15 | body["acl_name"] = acl_name 16 | self._buildStdBody(body, kwargs) 17 | return body 18 | 19 | def create(self, acl_name, callback=None, errback=None, **kwargs): 20 | body = self._buildBody(acl_name, **kwargs) 21 | 22 | return self.create_raw( 23 | acl_name, body, callback=callback, errback=errback, **kwargs 24 | ) 25 | 26 | def create_raw( 27 | self, acl_name, body, callback=None, errback=None, **kwargs 28 | ): 29 | return self._make_request( 30 | "PUT", 31 | "%s/%s" % (self.ROOT, acl_name), 32 | body=body, 33 | callback=callback, 34 | errback=errback, 35 | ) 36 | 37 | def update(self, acl_name, callback=None, errback=None, **kwargs): 38 | body = self._buildBody(acl_name, **kwargs) 39 | 40 | return self._make_request( 41 | "POST", 42 | "%s/%s" % (self.ROOT, acl_name), 43 | body=body, 44 | callback=callback, 45 | errback=errback, 46 | ) 47 | 48 | def delete(self, acl_name, callback=None, errback=None): 49 | return self._make_request( 50 | "DELETE", 51 | "%s/%s" % (self.ROOT, acl_name), 52 | callback=callback, 53 | errback=errback, 54 | ) 55 | 56 | def retrieve(self, acl_name, callback=None, errback=None): 57 | return self._make_request( 58 | "GET", 59 | "%s/%s" % (self.ROOT, acl_name), 60 | callback=callback, 61 | errback=errback, 62 | ) 63 | -------------------------------------------------------------------------------- /ns1/rest/alerts.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from . import resource 7 | 8 | 9 | class Alerts(resource.BaseResource): 10 | ROOT = "../alerting/v1/alerts" 11 | PASSTHRU_FIELDS = [ 12 | "name", 13 | "data", 14 | "notifier_list_ids", 15 | "record_ids", 16 | "zone_names", 17 | ] 18 | 19 | def _buildBody(self, alid, **kwargs): 20 | body = {} 21 | body["id"] = alid 22 | self._buildStdBody(body, kwargs) 23 | return body 24 | 25 | def list(self, callback=None, errback=None): 26 | data = self._make_request( 27 | "GET", 28 | "%s" % (self.ROOT), 29 | callback=callback, 30 | errback=errback, 31 | pagination_handler=alert_list_pagination, 32 | ) 33 | return data["results"] 34 | 35 | def update(self, alid, callback=None, errback=None, **kwargs): 36 | body = self._buildBody(alid, **kwargs) 37 | 38 | return self._make_request( 39 | "PATCH", 40 | "%s/%s" % (self.ROOT, alid), 41 | body=body, 42 | callback=callback, 43 | errback=errback, 44 | ) 45 | 46 | def create( 47 | self, name, type, subtype, callback=None, errback=None, **kwargs 48 | ): 49 | body = { 50 | "name": name, 51 | "type": type, 52 | "subtype": subtype, 53 | } 54 | self._buildStdBody(body, kwargs) 55 | return self._make_request( 56 | "POST", 57 | "%s" % (self.ROOT), 58 | body=body, 59 | callback=callback, 60 | errback=errback, 61 | ) 62 | 63 | def retrieve(self, alert_id, callback=None, errback=None): 64 | return self._make_request( 65 | "GET", 66 | "%s/%s" % (self.ROOT, alert_id), 67 | callback=callback, 68 | errback=errback, 69 | ) 70 | 71 | def delete(self, alert_id, callback=None, errback=None): 72 | return self._make_request( 73 | "DELETE", 74 | "%s/%s" % (self.ROOT, alert_id), 75 | callback=callback, 76 | errback=errback, 77 | ) 78 | 79 | def test(self, alert_id, callback=None, errback=None): 80 | return self._make_request( 81 | "POST", 82 | "%s/%s/test" % (self.ROOT, alert_id), 83 | callback=callback, 84 | errback=errback, 85 | ) 86 | 87 | 88 | # successive pages contain the next alerts in the results list 89 | def alert_list_pagination(curr_json, next_json): 90 | curr_json["results"].extend(next_json["results"]) 91 | return curr_json 92 | -------------------------------------------------------------------------------- /ns1/rest/apikey.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from . import permissions 7 | from . import resource 8 | 9 | 10 | class APIKey(resource.BaseResource): 11 | ROOT = "account/apikeys" 12 | PASSTHRU_FIELDS = [ 13 | "name", 14 | "teams", 15 | "ip_whitelist", 16 | "ip_whitelist_strict", 17 | "permissions", 18 | ] 19 | 20 | def create(self, name, callback=None, errback=None, **kwargs): 21 | body = {"name": name} 22 | 23 | if "permissions" not in kwargs: 24 | body["permissions"] = permissions._default_perms 25 | 26 | self._buildStdBody(body, kwargs) 27 | 28 | return self._make_request( 29 | "PUT", 30 | "%s" % (self.ROOT), 31 | body=body, 32 | callback=callback, 33 | errback=errback, 34 | ) 35 | 36 | def update(self, apikey_id, callback=None, errback=None, **kwargs): 37 | body = {} 38 | self._buildStdBody(body, kwargs) 39 | 40 | return self._make_request( 41 | "POST", 42 | "%s/%s" % (self.ROOT, apikey_id), 43 | body=body, 44 | callback=callback, 45 | errback=errback, 46 | ) 47 | 48 | def delete(self, apikey_id, callback=None, errback=None): 49 | return self._make_request( 50 | "DELETE", 51 | "%s/%s" % (self.ROOT, apikey_id), 52 | callback=callback, 53 | errback=errback, 54 | ) 55 | 56 | def list(self, callback=None, errback=None): 57 | return self._make_request( 58 | "GET", "%s" % self.ROOT, callback=callback, errback=errback 59 | ) 60 | 61 | def retrieve(self, apikey_id, callback=None, errback=None): 62 | return self._make_request( 63 | "GET", 64 | "%s/%s" % (self.ROOT, apikey_id), 65 | callback=callback, 66 | errback=errback, 67 | ) 68 | -------------------------------------------------------------------------------- /ns1/rest/billing_usage.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2025 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from . import resource 7 | import copy 8 | 9 | 10 | class BillingUsage(resource.BaseResource): 11 | ROOT = "billing-usage" 12 | 13 | def __init__(self, config): 14 | config = copy.deepcopy(config) 15 | config["api_version_before_resource"] = False 16 | super(BillingUsage, self).__init__(config) 17 | 18 | def getQueriesUsage(self, from_unix, to_unix, callback=None, errback=None): 19 | return self._make_request( 20 | "GET", 21 | f"{self.ROOT}/queries", 22 | callback=callback, 23 | errback=errback, 24 | params={"from": from_unix, "to": to_unix}, 25 | ) 26 | 27 | def getDecisionsUsage( 28 | self, from_unix, to_unix, callback=None, errback=None 29 | ): 30 | return self._make_request( 31 | "GET", 32 | f"{self.ROOT}/decisions", 33 | callback=callback, 34 | errback=errback, 35 | params={"from": from_unix, "to": to_unix}, 36 | ) 37 | 38 | def getRecordsUsage(self, callback=None, errback=None): 39 | return self._make_request( 40 | "GET", 41 | f"{self.ROOT}/records", 42 | callback=callback, 43 | errback=errback, 44 | params={}, 45 | ) 46 | 47 | def getMonitorsUsage(self, callback=None, errback=None): 48 | return self._make_request( 49 | "GET", 50 | f"{self.ROOT}/monitors", 51 | callback=callback, 52 | errback=errback, 53 | params={}, 54 | ) 55 | 56 | def getFilterChainsUsage(self, callback=None, errback=None): 57 | return self._make_request( 58 | "GET", 59 | f"{self.ROOT}/filter-chains", 60 | callback=callback, 61 | errback=errback, 62 | params={}, 63 | ) 64 | 65 | def getLimits(self, from_unix, to_unix, callback=None, errback=None): 66 | return self._make_request( 67 | "GET", 68 | f"{self.ROOT}/limits", 69 | callback=callback, 70 | errback=errback, 71 | params={"from": from_unix, "to": to_unix}, 72 | ) 73 | -------------------------------------------------------------------------------- /ns1/rest/data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from . import resource 7 | 8 | 9 | class Source(resource.BaseResource): 10 | ROOT = "data/sources" 11 | PASSTHRU_FIELDS = ["name", "config"] 12 | 13 | def list(self, callback=None, errback=None): 14 | return self._make_request( 15 | "GET", "%s" % (self.ROOT), callback=callback, errback=errback 16 | ) 17 | 18 | def retrieve(self, sourceid, callback=None, errback=None): 19 | return self._make_request( 20 | "GET", 21 | "%s/%s" % (self.ROOT, sourceid), 22 | callback=callback, 23 | errback=errback, 24 | ) 25 | 26 | def create(self, name, sourcetype, callback=None, errback=None, **kwargs): 27 | """ 28 | The only supported kwarg is `config`. 29 | """ 30 | body = {"name": name, "sourcetype": sourcetype} 31 | self._buildStdBody(body, kwargs) 32 | return self._make_request( 33 | "PUT", 34 | "%s" % (self.ROOT), 35 | body=body, 36 | callback=callback, 37 | errback=errback, 38 | ) 39 | 40 | def update( 41 | self, sourceid, sourcetype, callback=None, errback=None, **kwargs 42 | ): 43 | """ 44 | Note that `sourcetype` is required, but cannot be changed by this 45 | method. 46 | 47 | Supported kwargs are: `name`, `config`. 48 | """ 49 | body = {"sourcetype": sourcetype} 50 | self._buildStdBody(body, kwargs) 51 | return self._make_request( 52 | "POST", 53 | "%s/%s" % (self.ROOT, sourceid), 54 | body=body, 55 | callback=callback, 56 | errback=errback, 57 | ) 58 | 59 | def delete(self, sourceid, callback=None, errback=None): 60 | return self._make_request( 61 | "DELETE", 62 | "%s/%s" % (self.ROOT, sourceid), 63 | callback=callback, 64 | errback=errback, 65 | ) 66 | 67 | def publish(self, sourceid, data, callback=None, errback=None): 68 | return self._make_request( 69 | "POST", 70 | "feed/%s" % (sourceid), 71 | body=data, 72 | callback=callback, 73 | errback=errback, 74 | ) 75 | 76 | 77 | class Feed(resource.BaseResource): 78 | ROOT = "data/feeds" 79 | PASSTHRU_FIELDS = ["name", "config"] 80 | 81 | def list(self, sourceid, callback=None, errback=None): 82 | return self._make_request( 83 | "GET", 84 | "%s/%s" % (self.ROOT, sourceid), 85 | callback=callback, 86 | errback=errback, 87 | ) 88 | 89 | def retrieve(self, sourceid, feedid, callback=None, errback=None): 90 | return self._make_request( 91 | "GET", 92 | "%s/%s/%s" % (self.ROOT, sourceid, feedid), 93 | callback=callback, 94 | errback=errback, 95 | ) 96 | 97 | def create( 98 | self, sourceid, name, config, callback=None, errback=None, **kwargs 99 | ): 100 | body = { 101 | "name": name, 102 | "config": config, 103 | } 104 | self._buildStdBody(body, kwargs) 105 | return self._make_request( 106 | "PUT", 107 | "%s/%s" % (self.ROOT, sourceid), 108 | body=body, 109 | callback=callback, 110 | errback=errback, 111 | ) 112 | 113 | def update(self, sourceid, feedid, callback=None, errback=None, **kwargs): 114 | body = {"id": feedid} 115 | self._buildStdBody(body, kwargs) 116 | return self._make_request( 117 | "POST", 118 | "%s/%s/%s" % (self.ROOT, sourceid, feedid), 119 | body=body, 120 | callback=callback, 121 | errback=errback, 122 | ) 123 | 124 | def delete(self, sourceid, feedid, callback=None, errback=None): 125 | return self._make_request( 126 | "DELETE", 127 | "%s/%s/%s" % (self.ROOT, sourceid, feedid), 128 | callback=callback, 129 | errback=errback, 130 | ) 131 | -------------------------------------------------------------------------------- /ns1/rest/datasets.py: -------------------------------------------------------------------------------- 1 | from . import resource 2 | 3 | 4 | class Datasets(resource.BaseResource): 5 | ROOT = "datasets" 6 | 7 | PASSTHRU_FIELDS = [ 8 | "name", 9 | "datatype", 10 | "repeat", 11 | "timeframe", 12 | "export_type", 13 | "recipient_emails", 14 | ] 15 | 16 | def _buildBody( 17 | self, 18 | name: str, 19 | datatype: dict, 20 | repeat: dict, 21 | timeframe: dict, 22 | export_type: str, 23 | recipient_emails: list, 24 | **kwargs 25 | ): 26 | body = { 27 | "name": name, 28 | "datatype": datatype, 29 | "repeat": repeat, 30 | "timeframe": timeframe, 31 | "export_type": export_type, 32 | "recipient_emails": recipient_emails, 33 | } 34 | self._buildStdBody(body, kwargs) 35 | return body 36 | 37 | def create( 38 | self, 39 | name: str, 40 | datatype: dict, 41 | repeat: dict, 42 | timeframe: dict, 43 | export_type: str, 44 | recipient_emails: list, 45 | callback=None, 46 | errback=None, 47 | **kwargs 48 | ): 49 | body = self._buildBody( 50 | name, 51 | datatype, 52 | repeat, 53 | timeframe, 54 | export_type, 55 | recipient_emails, 56 | **kwargs 57 | ) 58 | return self._make_request( 59 | "PUT", 60 | "%s" % self.ROOT, 61 | body=body, 62 | callback=callback, 63 | errback=errback, 64 | ) 65 | 66 | def delete(self, dtId: str, callback=None, errback=None): 67 | return self._make_request( 68 | "DELETE", 69 | "%s/%s" % (self.ROOT, dtId), 70 | callback=callback, 71 | errback=errback, 72 | ) 73 | 74 | def list(self, callback=None, errback=None): 75 | return self._make_request( 76 | "GET", 77 | "%s" % self.ROOT, 78 | callback=callback, 79 | errback=errback, 80 | ) 81 | 82 | def retrieve(self, dtId: str, callback=None, errback=None): 83 | return self._make_request( 84 | "GET", 85 | "%s/%s" % (self.ROOT, dtId), 86 | callback=callback, 87 | errback=errback, 88 | ) 89 | 90 | def retrieveReport( 91 | self, dtId: str, rpId: str, callback=None, errback=None 92 | ): 93 | return self._make_request( 94 | "GET", 95 | "%s/%s/reports/%s" % (self.ROOT, dtId, rpId), 96 | callback=callback, 97 | errback=errback, 98 | skip_json_parsing=True, 99 | ) 100 | -------------------------------------------------------------------------------- /ns1/rest/errors.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | import json 7 | 8 | 9 | class ResourceException(Exception): 10 | def __init__(self, message, response=None, body=None): 11 | # if message is json error message, unwrap the actual message 12 | # otherwise, fall back to the whole body 13 | 14 | if body: 15 | try: 16 | jData = json.loads(body) 17 | self.message = "%s: %s" % (message, jData["message"]) 18 | except: # noqa 19 | self.message = message 20 | else: 21 | self.message = message 22 | self.response = response 23 | self.body = body 24 | 25 | def __repr__(self): 26 | m = self.message or "empty message" 27 | r = self.response or "empty response" 28 | 29 | if self.body and len(self.body) > 30: 30 | b = "%s..." % self.body[0:30] 31 | else: 32 | b = self.body or "empty body" 33 | 34 | return "" % ( 35 | m, 36 | r, 37 | b, 38 | ) 39 | 40 | def __str__(self): 41 | return self.message 42 | 43 | 44 | class AuthException(ResourceException): 45 | def __repr__(self): 46 | return "" 47 | 48 | def __str__(self): 49 | return "unauthorized" 50 | 51 | 52 | class RateLimitException(ResourceException): 53 | def __init__( 54 | self, 55 | message, 56 | response=None, 57 | body=None, 58 | by=None, 59 | limit=None, 60 | remaining=None, 61 | period=None, 62 | ): 63 | ResourceException.__init__(self, message, response, body) 64 | self.by = by 65 | self.limit = limit 66 | self.period = period 67 | self.remaining = remaining 68 | 69 | def __repr__(self): 70 | return "" % ( 71 | self.by, 72 | self.limit, 73 | self.period, 74 | self.remaining, 75 | ) 76 | -------------------------------------------------------------------------------- /ns1/rest/monitoring.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from . import resource 7 | 8 | 9 | class Monitors(resource.BaseResource): 10 | ROOT = "monitoring/jobs" 11 | PASSTHRU_FIELDS = [ 12 | "name", 13 | "config", 14 | "region_scope", 15 | "regions", 16 | "job_type", 17 | "policy", 18 | "notes", 19 | "rules", 20 | "notify_delay", 21 | "notify_list", 22 | ] 23 | INT_FIELDS = ["frequency", "notify_repeat"] 24 | BOOL_FIELDS = ["active", "rapid_recheck", "notify_regional"] 25 | 26 | def list(self, callback=None, errback=None): 27 | return self._make_request( 28 | "GET", "%s" % (self.ROOT), callback=callback, errback=errback 29 | ) 30 | 31 | def update(self, jobid, body, callback=None, errback=None, **kwargs): 32 | self._buildStdBody(body, kwargs) 33 | 34 | return self._make_request( 35 | "POST", 36 | "%s/%s" % (self.ROOT, jobid), 37 | body=body, 38 | callback=callback, 39 | errback=errback, 40 | ) 41 | 42 | def create(self, body, callback=None, errback=None, **kwargs): 43 | self._buildStdBody(body, kwargs) 44 | 45 | return self._make_request( 46 | "PUT", 47 | "%s" % (self.ROOT), 48 | body=body, 49 | callback=callback, 50 | errback=errback, 51 | ) 52 | 53 | def retrieve(self, jobid, callback=None, errback=None): 54 | return self._make_request( 55 | "GET", 56 | "%s/%s" % (self.ROOT, jobid), 57 | callback=callback, 58 | errback=errback, 59 | ) 60 | 61 | def delete(self, jobid, callback=None, errback=None): 62 | return self._make_request( 63 | "DELETE", 64 | "%s/%s" % (self.ROOT, jobid), 65 | callback=callback, 66 | errback=errback, 67 | ) 68 | 69 | 70 | class NotifyLists(resource.BaseResource): 71 | ROOT = "lists" 72 | PASSTHRU_FIELDS = [] 73 | 74 | def list(self, callback=None, errback=None): 75 | return self._make_request( 76 | "GET", "%s" % (self.ROOT), callback=callback, errback=errback 77 | ) 78 | 79 | def update(self, nlid, body, callback=None, errback=None, **kwargs): 80 | self._buildStdBody(body, kwargs) 81 | 82 | return self._make_request( 83 | "POST", 84 | "%s/%s" % (self.ROOT, nlid), 85 | body=body, 86 | callback=callback, 87 | errback=errback, 88 | ) 89 | 90 | def create(self, body, callback=None, errback=None): 91 | return self._make_request( 92 | "PUT", 93 | "%s" % (self.ROOT), 94 | body=body, 95 | callback=callback, 96 | errback=errback, 97 | ) 98 | 99 | def retrieve(self, nlid, callback=None, errback=None): 100 | return self._make_request( 101 | "GET", 102 | "%s/%s" % (self.ROOT, nlid), 103 | callback=callback, 104 | errback=errback, 105 | ) 106 | 107 | def delete(self, nlid, callback=None, errback=None): 108 | return self._make_request( 109 | "DELETE", 110 | "%s/%s" % (self.ROOT, nlid), 111 | callback=callback, 112 | errback=errback, 113 | ) 114 | 115 | 116 | class JobTypes(resource.BaseResource): 117 | ROOT = "monitoring/jobtypes" 118 | PASSTHRU_FIELDS = [] 119 | 120 | def list(self, callback=None, errback=None): 121 | return self._make_request( 122 | "GET", 123 | self.ROOT, 124 | callback=callback, 125 | errback=errback, 126 | ) 127 | 128 | 129 | class Regions(resource.BaseResource): 130 | ROOT = "monitoring/regions" 131 | PASSTHRU_FIELDS = [] 132 | 133 | def list(self, callback=None, errback=None): 134 | return self._make_request( 135 | "GET", 136 | self.ROOT, 137 | callback=callback, 138 | errback=errback, 139 | ) 140 | -------------------------------------------------------------------------------- /ns1/rest/permissions.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | _default_perms = { 8 | "data": { 9 | "push_to_datafeeds": False, 10 | "manage_datasources": False, 11 | "manage_datafeeds": False, 12 | }, 13 | "account": { 14 | "manage_plan": False, 15 | "manage_users": False, 16 | "view_invoices": False, 17 | "manage_teams": False, 18 | "manage_payment_methods": False, 19 | "manage_account_settings": False, 20 | "manage_apikeys": False, 21 | "view_activity_log": False, 22 | }, 23 | "monitoring": { 24 | "manage_jobs": False, 25 | "create_jobs": False, 26 | "update_jobs": False, 27 | "delete_jobs": False, 28 | "manage_lists": False, 29 | "view_jobs": False, 30 | }, 31 | "security": {"manage_global_2fa": False}, 32 | "dns": { 33 | "zones_allow": [], 34 | "manage_zones": False, 35 | "zones_deny": [], 36 | "view_zones": False, 37 | "zones_allow_by_default": False, 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /ns1/rest/rate_limiting.py: -------------------------------------------------------------------------------- 1 | """ 2 | NS1 rate limits via a "token bucket" scheme, and provides information about 3 | rate limiting in headers on the response. Token bucket can be thought of as an 4 | initially "full" bucket, where, if not full, tokens are replenished at some 5 | rate. This allows "bursting" requests until the bucket is empty, after which, 6 | you are limited to the rate of token replenishment. 7 | 8 | Here we define a few "strategies" that may be helpful in avoiding 429 responses 9 | from the API. 10 | 11 | Unfortunately, rate limiting is seperately "bucketed" per endpoint and method, 12 | and are not necessarily the same for all users. So the only way to know the 13 | status of a "bucket" is to make a request, and, for now, the strategies involve 14 | sleeping for some interval *after* we make requests. They are also not 15 | currently "bucket-aware". 16 | """ 17 | 18 | import logging 19 | 20 | from time import sleep 21 | 22 | LOG = logging.getLogger(__name__) 23 | 24 | 25 | def rate_limit_strategy_solo(): 26 | """ 27 | Sleep longer the closer we are to running out of tokens, but be blissfully 28 | unaware of anything else using up tokens. 29 | """ 30 | 31 | def solo_rate_limit_func(rl): 32 | if rl["remaining"] < 2: 33 | wait = rl["period"] 34 | else: 35 | wait = rl["period"] / rl["remaining"] 36 | LOG.debug("rate_limit_strategy_solo: sleeping for: {}s".format(wait)) 37 | sleep(wait) 38 | 39 | return solo_rate_limit_func 40 | 41 | 42 | def rate_limit_strategy_concurrent(parallelism): 43 | """ 44 | When we have equal or fewer tokens than workers, sleep for 45 | the token replenishment interval multiplied by the number of workers. 46 | 47 | For example, if we can make 10 requests in 60 seconds, a token is 48 | replenished every 6 seconds. If parallelism is 3, we will burst 7 requests, 49 | and subsequently each process will sleep for 18 seconds before making 50 | another request. 51 | """ 52 | 53 | def concurrent_rate_limit_func(rl): 54 | if rl["remaining"] <= parallelism: 55 | wait = (rl["period"] / rl["limit"]) * parallelism 56 | LOG.debug( 57 | "rate_limit_strategy_concurrent={}: sleeping for: {}s".format( 58 | parallelism, wait 59 | ) 60 | ) 61 | sleep(wait) 62 | 63 | return concurrent_rate_limit_func 64 | 65 | 66 | def default_rate_limit_func(rl): 67 | """ 68 | noop 69 | """ 70 | pass 71 | -------------------------------------------------------------------------------- /ns1/rest/records.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | try: 6 | from collections.abc import Iterable 7 | except ImportError: 8 | from collections import Iterable 9 | import sys 10 | 11 | from . import resource 12 | 13 | 14 | py_str = str if sys.version_info[0] == 3 else basestring # noqa: F821 15 | 16 | 17 | class Records(resource.BaseResource): 18 | ROOT = "zones" 19 | 20 | INT_FIELDS = ["ttl"] 21 | BOOL_FIELDS = ["use_client_subnet", "use_csubnet", "override_ttl"] 22 | PASSTHRU_FIELDS = ["networks", "meta", "regions", "link"] 23 | 24 | # answers must be: 25 | # 1) a single string 26 | # we coerce to a single answer with no other fields e.g. meta 27 | # 2) an iterable of single strings 28 | # we coerce to several answers with no other fields e.g. meta 29 | # 3) an iterable of iterables 30 | # we have as many answers as are in the outer iterable, and the 31 | # answers themselves are used verbatim from the inner iterable (e.g. may 32 | # have MX style [10, '1.1.1.1']), but no other fields e.g. meta 33 | # you must use this form for MX records, and if there is only one 34 | # answer it still must be wrapped in an outer iterable 35 | # 4) an iterable of dicts 36 | # we assume the full rest model and pass it in unchanged. must use this 37 | # form for any advanced record config like meta data or data feeds 38 | def _getAnswersForBody(self, answers): 39 | realAnswers = [] 40 | # simplest: they specify a single string ip 41 | 42 | if isinstance(answers, py_str): 43 | answers = [answers] 44 | # otherwise, we need an iterable 45 | elif not isinstance(answers, Iterable): 46 | raise Exception("invalid answers format (must be str or iterable)") 47 | # at this point we have a list. loop through and build out the answer 48 | # entries depending on contents 49 | 50 | for a in answers: 51 | if isinstance(a, py_str): 52 | realAnswers.append({"answer": [a]}) 53 | elif isinstance(a, (list, tuple)): 54 | realAnswers.append({"answer": a}) 55 | elif isinstance(a, dict): 56 | realAnswers.append(a) 57 | else: 58 | raise Exception( 59 | "invalid answers format: list must contain " 60 | "only str, list, or dict" 61 | ) 62 | 63 | return realAnswers 64 | 65 | # filters must be a list of dict which can have two forms: 66 | # 1) simple: each item in list is a dict with a single key and value. the 67 | # key is the name of the filter, the value is a dict of config 68 | # values (which may be empty {}) 69 | # 2) full: each item in the list is a dict of the full rest model for 70 | # filters (documented elsewhere) which is passed through. use this 71 | # for enabled/disabled or future fields not supported otherwise 72 | # 73 | def _getFiltersForBody(self, filters): 74 | realFilters = [] 75 | 76 | if type(filters) is not list: 77 | raise Exception("filter argument must be list of dict") 78 | 79 | for f in filters: 80 | if type(f) is not dict: 81 | raise Exception("filter items must be dict") 82 | 83 | if "filter" in f: 84 | # full 85 | realFilters.append(f) 86 | else: 87 | # simple, synthesize 88 | (fname, fconfig) = f.popitem() 89 | realFilters.append({"filter": fname, "config": fconfig}) 90 | 91 | return realFilters 92 | 93 | def _buildBody(self, zone, domain, type, **kwargs): 94 | body = {} 95 | body["zone"] = zone 96 | body["domain"] = domain 97 | body["type"] = type.upper() 98 | 99 | if "filters" in kwargs: 100 | body["filters"] = self._getFiltersForBody(kwargs["filters"]) 101 | 102 | if "answers" in kwargs: 103 | body["answers"] = self._getAnswersForBody(kwargs["answers"]) 104 | 105 | self._buildStdBody(body, kwargs) 106 | 107 | if "use_csubnet" in body: 108 | # key mapping 109 | body["use_client_subnet"] = body["use_csubnet"] 110 | del body["use_csubnet"] 111 | 112 | return body 113 | 114 | def create( 115 | self, zone, domain, type, callback=None, errback=None, **kwargs 116 | ): 117 | body = self._buildBody(zone, domain, type, **kwargs) 118 | 119 | return self.create_raw( 120 | zone, 121 | domain, 122 | type, 123 | body, 124 | callback=callback, 125 | errback=errback, 126 | **kwargs 127 | ) 128 | 129 | def create_raw( 130 | self, zone, domain, type, body, callback=None, errback=None, **kwargs 131 | ): 132 | return self._make_request( 133 | "PUT", 134 | "%s/%s/%s/%s" % (self.ROOT, zone, domain, type.upper()), 135 | body=body, 136 | callback=callback, 137 | errback=errback, 138 | ) 139 | 140 | def update( 141 | self, zone, domain, type, callback=None, errback=None, **kwargs 142 | ): 143 | body = self._buildBody(zone, domain, type, **kwargs) 144 | 145 | return self._make_request( 146 | "POST", 147 | "%s/%s/%s/%s" % (self.ROOT, zone, domain, type.upper()), 148 | body=body, 149 | callback=callback, 150 | errback=errback, 151 | ) 152 | 153 | def delete(self, zone, domain, type, callback=None, errback=None): 154 | return self._make_request( 155 | "DELETE", 156 | "%s/%s/%s/%s" % (self.ROOT, zone, domain, type.upper()), 157 | callback=callback, 158 | errback=errback, 159 | ) 160 | 161 | def retrieve(self, zone, domain, type, callback=None, errback=None): 162 | return self._make_request( 163 | "GET", 164 | "%s/%s/%s/%s" % (self.ROOT, zone, domain, type.upper()), 165 | callback=callback, 166 | errback=errback, 167 | ) 168 | -------------------------------------------------------------------------------- /ns1/rest/redirect.py: -------------------------------------------------------------------------------- 1 | from . import resource 2 | 3 | 4 | class Redirects(resource.BaseResource): 5 | ROOT = "redirect" 6 | SEARCH_ROOT = "redirect" 7 | 8 | PASSTHRU_FIELDS = [ 9 | "id", 10 | "certificate_id", 11 | "domain", 12 | "path", 13 | "target", 14 | "tags", 15 | "forwarding_mode", 16 | "forwarding_type", 17 | ] 18 | BOOL_FIELDS = ["https_enabled", "https_forced", "query_forwarding"] 19 | INT_FIELDS = ["last_updated"] 20 | 21 | def _buildBody(self, domain, path, target, **kwargs): 22 | body = { 23 | "domain": domain, 24 | "path": path, 25 | "target": target, 26 | } 27 | self._buildStdBody(body, kwargs) 28 | return body 29 | 30 | def import_file(self, cfg, cfgFile, callback=None, errback=None, **kwargs): 31 | files = [("cfgfile", (cfgFile, open(cfgFile, "rb"), "text/plain"))] 32 | return self._make_request( 33 | "PUT", 34 | "%s/importexport" % self.ROOT, 35 | files=files, 36 | callback=callback, 37 | errback=errback, 38 | ) 39 | 40 | def create( 41 | self, domain, path, target, callback=None, errback=None, **kwargs 42 | ): 43 | body = self._buildBody(domain, path, target, **kwargs) 44 | return self._make_request( 45 | "PUT", 46 | "%s" % self.ROOT, 47 | body=body, 48 | callback=callback, 49 | errback=errback, 50 | ) 51 | 52 | def update(self, cfg, callback=None, errback=None, **kwargs): 53 | self._buildStdBody(cfg, kwargs) 54 | return self._make_request( 55 | "POST", 56 | "%s/%s" % (self.ROOT, cfg["id"]), 57 | body=cfg, 58 | callback=callback, 59 | errback=errback, 60 | ) 61 | 62 | def delete(self, cfgId, callback=None, errback=None): 63 | return self._make_request( 64 | "DELETE", 65 | "%s/%s" % (self.ROOT, cfgId), 66 | callback=callback, 67 | errback=errback, 68 | ) 69 | 70 | def list(self, callback=None, errback=None): 71 | return self._make_request( 72 | "GET", 73 | "%s" % self.ROOT, 74 | callback=callback, 75 | errback=errback, 76 | pagination_handler=redirect_list_pagination, 77 | ) 78 | 79 | def retrieve(self, cfgId, callback=None, errback=None): 80 | return self._make_request( 81 | "GET", 82 | "%s/%s" % (self.ROOT, cfgId), 83 | callback=callback, 84 | errback=errback, 85 | ) 86 | 87 | def searchSource( 88 | self, 89 | query, 90 | max=None, 91 | callback=None, 92 | errback=None, 93 | ): 94 | request = "{}?source={}".format(self.SEARCH_ROOT, query) 95 | if max is not None: 96 | request += "&limit=" + str(max) 97 | return self._make_request( 98 | "GET", 99 | request, 100 | params={}, 101 | callback=callback, 102 | errback=errback, 103 | ) 104 | 105 | def searchTarget( 106 | self, 107 | query, 108 | max=None, 109 | callback=None, 110 | errback=None, 111 | ): 112 | request = "{}?target={}".format(self.SEARCH_ROOT, query) 113 | if max is not None: 114 | request += "&limit=" + str(max) 115 | return self._make_request( 116 | "GET", 117 | request, 118 | params={}, 119 | callback=callback, 120 | errback=errback, 121 | ) 122 | 123 | def searchTag( 124 | self, 125 | query, 126 | max=None, 127 | callback=None, 128 | errback=None, 129 | ): 130 | request = "{}?tag={}".format(self.SEARCH_ROOT, query) 131 | if max is not None: 132 | request += "&limit=" + str(max) 133 | return self._make_request( 134 | "GET", 135 | request, 136 | params={}, 137 | callback=callback, 138 | errback=errback, 139 | ) 140 | 141 | 142 | class RedirectCertificates(resource.BaseResource): 143 | ROOT = "redirect/certificates" 144 | SEARCH_ROOT = "redirect/certificates" 145 | 146 | PASSTHRU_FIELDS = [ 147 | "id", 148 | "domain", 149 | "certificate", 150 | "errors", 151 | ] 152 | BOOL_FIELDS = ["processing"] 153 | INT_FIELDS = [ 154 | "valid_from", 155 | "valid_until", 156 | "last_updated", 157 | ] 158 | 159 | def _buildBody(self, domain, **kwargs): 160 | body = { 161 | "domain": domain, 162 | } 163 | self._buildStdBody(body, kwargs) 164 | return body 165 | 166 | def create(self, domain, callback=None, errback=None, **kwargs): 167 | body = self._buildBody(domain, **kwargs) 168 | return self._make_request( 169 | "PUT", 170 | "%s" % self.ROOT, 171 | body=body, 172 | callback=callback, 173 | errback=errback, 174 | ) 175 | 176 | def update(self, certId, callback=None, errback=None, **kwargs): 177 | return self._make_request( 178 | "POST", 179 | "%s/%s" % (self.ROOT, certId), 180 | callback=callback, 181 | errback=errback, 182 | ) 183 | 184 | def delete(self, certId, callback=None, errback=None): 185 | return self._make_request( 186 | "DELETE", 187 | "%s/%s" % (self.ROOT, certId), 188 | callback=callback, 189 | errback=errback, 190 | ) 191 | 192 | def list(self, callback=None, errback=None): 193 | return self._make_request( 194 | "GET", 195 | "%s" % self.ROOT, 196 | callback=callback, 197 | errback=errback, 198 | pagination_handler=redirect_list_pagination, 199 | ) 200 | 201 | def retrieve(self, certId, callback=None, errback=None): 202 | return self._make_request( 203 | "GET", 204 | "%s/%s" % (self.ROOT, certId), 205 | callback=callback, 206 | errback=errback, 207 | ) 208 | 209 | def search( 210 | self, 211 | query, 212 | max=None, 213 | callback=None, 214 | errback=None, 215 | ): 216 | request = "{}?domain={}".format(self.SEARCH_ROOT, query) 217 | if max is not None: 218 | request += "&limit=" + str(max) 219 | return self._make_request( 220 | "GET", 221 | request, 222 | params={}, 223 | callback=callback, 224 | errback=errback, 225 | ) 226 | 227 | 228 | # successive pages extend the list and the count 229 | def redirect_list_pagination(curr_json, next_json): 230 | curr_json["count"] += next_json["count"] 231 | curr_json["results"].extend(next_json["results"]) 232 | return curr_json 233 | -------------------------------------------------------------------------------- /ns1/rest/resource.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014, 2025 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | import sys 8 | import logging 9 | import json 10 | from ns1 import version 11 | from ns1.rest.transport.base import TransportBase 12 | from ns1.rest.errors import ResourceException 13 | 14 | 15 | class BaseResource: 16 | DEFAULT_TRANSPORT = "requests" 17 | 18 | INT_FIELDS = [] 19 | BOOL_FIELDS = [] 20 | PASSTHRU_FIELDS = [] 21 | 22 | def __init__(self, config): 23 | """ 24 | 25 | :param ns1.config.Config config: config object used to build requests 26 | """ 27 | self._config = config 28 | self._log = logging.getLogger(__name__) 29 | # TODO verify we have a default key 30 | # get a transport. TODO make this static property? 31 | transport = self._config.get("transport", None) 32 | if transport is None: 33 | # for default transport: 34 | # if requests is available, use that. otherwise, basic 35 | from ns1.rest.transport.requests import have_requests 36 | 37 | if have_requests: 38 | transport = "requests" 39 | else: 40 | transport = "basic" 41 | if transport not in TransportBase.REGISTRY: 42 | raise ResourceException( 43 | "requested transport was not found: %s" % transport 44 | ) 45 | self._transport = TransportBase.REGISTRY[transport](self._config) 46 | 47 | def _buildStdBody(self, body, fields): 48 | for f in self.BOOL_FIELDS: 49 | if f in fields: 50 | body[f] = bool(fields[f]) 51 | for f in self.INT_FIELDS: 52 | if f in fields: 53 | body[f] = int(fields[f]) 54 | for f in self.PASSTHRU_FIELDS: 55 | if f in fields: 56 | body[f] = fields[f] 57 | 58 | def _make_url(self, path): 59 | if self._config["api_version_before_resource"]: 60 | return f"{self._config.getEndpoint()}/{self._config['api_version']}/{path}" 61 | 62 | resource, sub_resource = path.split("/", 1) 63 | 64 | return f"{self._config.getEndpoint()}/{resource}/{self._config['api_version']}/{sub_resource}" 65 | 66 | def _make_request(self, type, path, **kwargs): 67 | VERBS = ["GET", "POST", "DELETE", "PUT"] 68 | if type not in VERBS: 69 | raise Exception("invalid request method") 70 | # TODO don't assume this doesn't exist in kwargs 71 | kwargs["headers"] = { 72 | "User-Agent": "ns1-python %s python 0x%s %s" 73 | % (version, sys.hexversion, sys.platform), 74 | "X-NSONE-Key": self._config.getAPIKey(), 75 | } 76 | if "body" in kwargs: 77 | kwargs["data"] = json.dumps(kwargs["body"]) 78 | del kwargs["body"] 79 | return self._transport.send(type, self._make_url(path), **kwargs) 80 | -------------------------------------------------------------------------------- /ns1/rest/stats.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from . import resource 7 | 8 | try: 9 | from urllib.parse import urlencode 10 | except: # noqa 11 | from urllib import urlencode 12 | 13 | 14 | class Stats(resource.BaseResource): 15 | ROOT = "stats" 16 | 17 | def qps( 18 | self, zone=None, domain=None, type=None, callback=None, errback=None 19 | ): 20 | url = "" 21 | 22 | if zone is None: 23 | url = "%s/%s" % (self.ROOT, "qps") 24 | elif type is not None and domain is not None and zone is not None: 25 | url = "%s/%s/%s/%s/%s" % (self.ROOT, "qps", zone, domain, type) 26 | elif zone is not None: 27 | url = "%s/%s/%s" % (self.ROOT, "qps", zone) 28 | 29 | return self._make_request( 30 | "GET", url, callback=callback, errback=errback 31 | ) 32 | 33 | def usage( 34 | self, 35 | zone=None, 36 | domain=None, 37 | type=None, 38 | callback=None, 39 | errback=None, 40 | **kwargs 41 | ): 42 | url = "" 43 | 44 | if zone is None: 45 | url = "%s/%s" % (self.ROOT, "usage") 46 | elif type is not None and domain is not None and zone is not None: 47 | url = "%s/%s/%s/%s/%s" % (self.ROOT, "usage", zone, domain, type) 48 | elif zone is not None: 49 | url = "%s/%s/%s" % (self.ROOT, "usage", zone) 50 | args = {} 51 | 52 | if "period" in kwargs: 53 | args["period"] = kwargs["period"] 54 | 55 | for f in ["expand", "aggregate", "by_tier"]: 56 | if f in kwargs: 57 | args[f] = bool(kwargs[f]) 58 | 59 | return self._make_request( 60 | "GET", 61 | url + ("?" + urlencode(args) if args else ""), 62 | callback=callback, 63 | errback=errback, 64 | pagination_handler=stats_usage_pagination, 65 | ) 66 | 67 | 68 | # successive pages just extend the usage list 69 | def stats_usage_pagination(curr_json, next_json): 70 | curr_json.extend(next_json) 71 | return curr_json 72 | -------------------------------------------------------------------------------- /ns1/rest/team.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from . import permissions 7 | from . import resource 8 | 9 | 10 | class Team(resource.BaseResource): 11 | ROOT = "account/teams" 12 | PASSTHRU_FIELDS = ["name", "ip_whitelist", "permissions"] 13 | 14 | def create(self, name, callback=None, errback=None, **kwargs): 15 | body = {"name": name} 16 | 17 | if "permissions" not in kwargs: 18 | body["permissions"] = permissions._default_perms 19 | 20 | self._buildStdBody(body, kwargs) 21 | 22 | return self._make_request( 23 | "PUT", 24 | "%s" % (self.ROOT), 25 | body=body, 26 | callback=callback, 27 | errback=errback, 28 | ) 29 | 30 | def update(self, team_id, callback=None, errback=None, **kwargs): 31 | body = {"id": team_id} 32 | self._buildStdBody(body, kwargs) 33 | 34 | return self._make_request( 35 | "POST", 36 | "%s/%s" % (self.ROOT, team_id), 37 | body=body, 38 | callback=callback, 39 | errback=errback, 40 | ) 41 | 42 | def delete(self, team_id, callback=None, errback=None): 43 | return self._make_request( 44 | "DELETE", 45 | "%s/%s" % (self.ROOT, team_id), 46 | callback=callback, 47 | errback=errback, 48 | ) 49 | 50 | def list(self, callback=None, errback=None): 51 | return self._make_request( 52 | "GET", "%s" % self.ROOT, callback=callback, errback=errback 53 | ) 54 | 55 | def retrieve(self, team_id, callback=None, errback=None): 56 | return self._make_request( 57 | "GET", 58 | "%s/%s" % (self.ROOT, team_id), 59 | callback=callback, 60 | errback=errback, 61 | ) 62 | -------------------------------------------------------------------------------- /ns1/rest/transport/README: -------------------------------------------------------------------------------- 1 | Credits: Ideas here taken from transport system in raven, the python 2 | getsentry client. Find it here: 3 | https://github.com/getsentry/raven-python 4 | -------------------------------------------------------------------------------- /ns1/rest/transport/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | # flake8: noqa 8 | from ns1.rest.transport.basic import * 9 | 10 | # flake8: noqa 11 | from ns1.rest.transport.requests import * 12 | 13 | # flake8: noqa 14 | from ns1.rest.transport.twisted import * 15 | -------------------------------------------------------------------------------- /ns1/rest/transport/base.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | import copy 7 | import logging 8 | 9 | 10 | class TransportBase(object): 11 | REGISTRY = {} 12 | 13 | def __init__(self, config, module): 14 | self._config = config 15 | self._log = logging.getLogger(module) 16 | self._verify = not self._config.getKeyConfig().get( 17 | "ignore-ssl-errors", self._config.get("ignore-ssl-errors", False) 18 | ) 19 | self._rate_limit_func = self._config.getRateLimitingFunc() 20 | self._follow_pagination = self._config.get("follow_pagination", False) 21 | 22 | def _logHeaders(self, headers): 23 | if self._config["verbosity"] > 0: 24 | argcopy = copy.deepcopy(headers) 25 | argcopy["X-NSONE-Key"] = "" 26 | self._log.debug(argcopy) 27 | 28 | def send( 29 | self, 30 | method, 31 | url, 32 | headers=None, 33 | data=None, 34 | params=None, 35 | files=None, 36 | callback=None, 37 | errback=None, 38 | ): 39 | raise NotImplementedError() 40 | -------------------------------------------------------------------------------- /ns1/rest/transport/basic.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from __future__ import absolute_import 7 | 8 | from ns1.helpers import get_next_page 9 | from ns1.rest.transport.base import TransportBase 10 | from ns1.rest.errors import ( 11 | ResourceException, 12 | RateLimitException, 13 | AuthException, 14 | ) 15 | 16 | try: 17 | from urllib.request import build_opener, Request, HTTPSHandler 18 | from urllib.error import HTTPError 19 | except ImportError: 20 | from urllib2 import build_opener, Request, HTTPSHandler 21 | from urllib2 import HTTPError 22 | import json 23 | import socket 24 | import sys 25 | 26 | 27 | class BasicTransport(TransportBase): 28 | def __init__(self, config): 29 | TransportBase.__init__(self, config, self.__module__) 30 | self._timeout = self._config.get( 31 | "timeout", socket._GLOBAL_DEFAULT_TIMEOUT 32 | ) 33 | self._opener = self._set_opener() 34 | 35 | def _rateLimitHeaders(self, headers): 36 | return { 37 | "by": headers.get("x-ratelimit-by", "customer"), 38 | "limit": int(headers.get("x-ratelimit-limit", 10)), 39 | "period": int(headers.get("x-ratelimit-period", 1)), 40 | "remaining": int(headers.get("x-ratelimit-remaining", 100)), 41 | } 42 | 43 | def _set_opener(self): 44 | # Some changes to the ssl and urllib modules were introduced in Python 45 | # 2.7.9, so we work around those differences here. 46 | if ( 47 | sys.version_info.major == 2 and sys.version_info >= (2, 7, 9) 48 | ) or sys.version_info >= (3, 4, 0): 49 | import ssl 50 | 51 | context = ssl.create_default_context() 52 | if not self._verify: 53 | context.check_hostname = False 54 | context.verify_mode = ssl.CERT_NONE 55 | return build_opener(HTTPSHandler(context=context)) 56 | return build_opener(HTTPSHandler) 57 | 58 | def _send(self, url, headers, data, method, errback): 59 | def handleProblem(code, resp, msg): 60 | if errback: 61 | errback((resp, msg)) 62 | return 63 | 64 | if code == 429: 65 | hdrs = self._get_headers(resp) 66 | raise RateLimitException( 67 | "rate limit exceeded", 68 | resp, 69 | msg, 70 | by=hdrs.get("x-ratelimit-by", "customer"), 71 | limit=hdrs.get("x-ratelimit-limit", 10), 72 | period=hdrs.get("x-ratelimit-period", 1), 73 | remaining=hdrs.get("x-ratelimit-remaining", 100), 74 | ) 75 | elif code == 401: 76 | raise AuthException("unauthorized", resp, msg) 77 | else: 78 | raise ResourceException( 79 | "server error, status code: %s" % code, 80 | response=resp, 81 | body=msg, 82 | ) 83 | 84 | request = Request(url, headers=headers, data=data) 85 | request.get_method = lambda: method 86 | 87 | # Handle error and responses the same so we can 88 | # always pass the body to the handleProblem function 89 | try: 90 | resp = self._opener.open(request, timeout=self._timeout) 91 | body = resp.read() 92 | headers = self._get_headers(resp) 93 | except HTTPError as e: 94 | resp = e 95 | body = resp.read() 96 | headers = self._get_headers(resp) 97 | if not 200 <= resp.code < 300: 98 | handleProblem(resp.code, resp, body) 99 | finally: 100 | rate_limit_headers = self._rateLimitHeaders(headers) 101 | self._rate_limit_func(rate_limit_headers) 102 | 103 | # TODO make sure json is valid if there is a body 104 | if body: 105 | # decode since body is bytes in 3.3 106 | try: 107 | body = body.decode("utf-8") 108 | except AttributeError: 109 | pass 110 | try: 111 | return headers, json.loads(body) 112 | except ValueError: 113 | if errback: 114 | errback(resp) 115 | else: 116 | raise ResourceException( 117 | "invalid json in response", resp, body 118 | ) 119 | else: 120 | return headers, None 121 | 122 | def send( 123 | self, 124 | method, 125 | url, 126 | headers=None, 127 | data=None, 128 | files=None, 129 | params=None, 130 | callback=None, 131 | errback=None, 132 | pagination_handler=None, 133 | ): 134 | if headers is None: 135 | headers = {} 136 | if files is not None: 137 | # XXX 138 | raise Exception("file uploads not supported in BasicTransport yet") 139 | self._logHeaders(headers) 140 | self._log.debug("%s %s %s" % (method, url, data)) 141 | 142 | if sys.version_info.major >= 3 and isinstance(data, str): 143 | data = data.encode("utf-8") 144 | 145 | resp_headers, jsonOut = self._send(url, headers, data, method, errback) 146 | if self._follow_pagination and pagination_handler is not None: 147 | next_page = get_next_page(resp_headers) 148 | while next_page is not None: 149 | self._log.debug("following pagination to: %s" % (next_page)) 150 | next_headers, next_json = self._send( 151 | next_page, headers, data, method, errback 152 | ) 153 | jsonOut = pagination_handler(jsonOut, next_json) 154 | next_page = get_next_page(next_headers) 155 | 156 | if callback: 157 | return callback(jsonOut) 158 | return jsonOut 159 | 160 | def _get_headers(self, response): 161 | # works for 2 and 3 162 | return {k.lower(): v for k, v in response.headers.items()} 163 | 164 | 165 | TransportBase.REGISTRY["basic"] = BasicTransport 166 | -------------------------------------------------------------------------------- /ns1/rest/transport/requests.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from __future__ import absolute_import 7 | 8 | from ns1.helpers import get_next_page 9 | from ns1.rest.transport.base import TransportBase 10 | from ns1.rest.errors import ( 11 | ResourceException, 12 | RateLimitException, 13 | AuthException, 14 | ) 15 | 16 | try: 17 | import requests 18 | 19 | have_requests = True 20 | except ImportError: 21 | have_requests = False 22 | 23 | 24 | class RequestsTransport(TransportBase): 25 | def __init__(self, config): 26 | if not have_requests: 27 | raise ImportError("requests module required for RequestsTransport") 28 | TransportBase.__init__(self, config, self.__module__) 29 | self.session = requests.Session() 30 | self.REQ_MAP = { 31 | "GET": self.session.get, 32 | "POST": self.session.post, 33 | "DELETE": self.session.delete, 34 | "PUT": self.session.put, 35 | } 36 | self._timeout = self._config.get("timeout", None) 37 | if isinstance(self._timeout, list) and len(self._timeout) == 2: 38 | self._timeout = tuple(self._timeout) 39 | 40 | def _rateLimitHeaders(self, headers): 41 | return { 42 | "by": headers.get("X-RateLimit-By", "customer"), 43 | "limit": int(headers.get("X-RateLimit-Limit", 10)), 44 | "period": int(headers.get("X-RateLimit-Period", 1)), 45 | "remaining": int(headers.get("X-RateLimit-Remaining", 100)), 46 | } 47 | 48 | def _send( 49 | self, 50 | method, 51 | url, 52 | headers, 53 | data, 54 | files, 55 | params, 56 | errback, 57 | skip_json_parsing, 58 | ): 59 | resp = self.REQ_MAP[method]( 60 | url, 61 | headers=headers, 62 | verify=self._verify, 63 | data=data, 64 | files=files, 65 | params=params, 66 | timeout=self._timeout, 67 | ) 68 | 69 | response_headers = resp.headers 70 | rate_limit_headers = self._rateLimitHeaders(response_headers) 71 | self._rate_limit_func(rate_limit_headers) 72 | 73 | if resp.status_code < 200 or resp.status_code >= 300: 74 | if errback: 75 | errback(resp) 76 | return 77 | else: 78 | if resp.status_code == 429: 79 | raise RateLimitException( 80 | "rate limit exceeded", 81 | resp, 82 | resp.text, 83 | by=rate_limit_headers["by"], 84 | limit=rate_limit_headers["limit"], 85 | period=rate_limit_headers["period"], 86 | remaining=rate_limit_headers["remaining"], 87 | ) 88 | elif resp.status_code == 401: 89 | raise AuthException("unauthorized", resp, resp.text) 90 | else: 91 | raise ResourceException("server error", resp, resp.text) 92 | 93 | if resp.text and skip_json_parsing: 94 | return response_headers, resp.text 95 | 96 | # TODO make sure json is valid if a body is returned 97 | if resp.text: 98 | try: 99 | return response_headers, resp.json() 100 | except ValueError: 101 | if errback: 102 | errback(resp) 103 | return 104 | else: 105 | raise ResourceException( 106 | "invalid json in response", resp, resp.text 107 | ) 108 | else: 109 | return response_headers, None 110 | 111 | def send( 112 | self, 113 | method, 114 | url, 115 | headers=None, 116 | data=None, 117 | params=None, 118 | files=None, 119 | callback=None, 120 | errback=None, 121 | pagination_handler=None, 122 | skip_json_parsing=False, 123 | ): 124 | self._logHeaders(headers) 125 | 126 | resp_headers, jsonOut = self._send( 127 | method, 128 | url, 129 | headers, 130 | data, 131 | files, 132 | params, 133 | errback, 134 | skip_json_parsing, 135 | ) 136 | if self._follow_pagination and pagination_handler is not None: 137 | next_page = get_next_page(resp_headers) 138 | while next_page is not None: 139 | self._log.debug("following pagination to: %s" % next_page) 140 | next_headers, next_json = self._send( 141 | method, 142 | next_page, 143 | headers, 144 | data, 145 | files, 146 | params, 147 | errback, 148 | skip_json_parsing, 149 | ) 150 | jsonOut = pagination_handler(jsonOut, next_json) 151 | next_page = get_next_page(next_headers) 152 | 153 | if callback: 154 | return callback(jsonOut) 155 | return jsonOut 156 | 157 | 158 | TransportBase.REGISTRY["requests"] = RequestsTransport 159 | -------------------------------------------------------------------------------- /ns1/rest/tsig.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from . import permissions 7 | from . import resource 8 | 9 | 10 | class Tsig(resource.BaseResource): 11 | ROOT = "tsig" 12 | 13 | PASSTHRU_FIELDS = [ 14 | "key_name", 15 | "algorithm", 16 | "secret", 17 | "limit", 18 | "offset", 19 | ] 20 | 21 | def create( 22 | self, 23 | key_name, 24 | algorithm, 25 | secret, 26 | callback=None, 27 | errback=None, 28 | **kwargs 29 | ): 30 | body = {"algorithm": algorithm, "secret": secret} 31 | if "permissions" not in kwargs: 32 | body["permissions"] = permissions._default_perms 33 | 34 | self._buildStdBody(body, kwargs) 35 | 36 | return self._make_request( 37 | "PUT", 38 | "%s/%s" % (self.ROOT, key_name), 39 | body=body, 40 | callback=callback, 41 | errback=errback, 42 | ) 43 | 44 | def update( 45 | self, 46 | key_name, 47 | algorithm=None, 48 | secret=None, 49 | callback=None, 50 | errback=None, 51 | **kwargs 52 | ): 53 | body = {"algorithm": algorithm, "secret": secret} 54 | self._buildStdBody(body, kwargs) 55 | 56 | return self._make_request( 57 | "POST", 58 | "%s/%s" % (self.ROOT, key_name), 59 | body=body, 60 | callback=callback, 61 | errback=errback, 62 | ) 63 | 64 | def delete(self, tsig_name, callback=None, errback=None): 65 | return self._make_request( 66 | "DELETE", 67 | "%s/%s" % (self.ROOT, tsig_name), 68 | callback=callback, 69 | errback=errback, 70 | ) 71 | 72 | def list(self, callback=None, errback=None): 73 | return self._make_request( 74 | "GET", "%s" % self.ROOT, callback=callback, errback=errback 75 | ) 76 | 77 | def retrieve(self, tsig_name, callback=None, errback=None): 78 | return self._make_request( 79 | "GET", 80 | "%s/%s" % (self.ROOT, tsig_name), 81 | callback=callback, 82 | errback=errback, 83 | ) 84 | -------------------------------------------------------------------------------- /ns1/rest/user.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from . import permissions 7 | from . import resource 8 | 9 | 10 | class User(resource.BaseResource): 11 | ROOT = "account/users" 12 | PASSTHRU_FIELDS = [ 13 | "name", 14 | "username", 15 | "email", 16 | "teams", 17 | "notify", 18 | "ip_whitelist", 19 | "ip_whitelist_strict", 20 | "permissions", 21 | ] 22 | 23 | def create( 24 | self, name, username, email, callback=None, errback=None, **kwargs 25 | ): 26 | body = {"name": name, "username": username, "email": email} 27 | 28 | if "permissions" not in kwargs: 29 | body["permissions"] = permissions._default_perms 30 | 31 | self._buildStdBody(body, kwargs) 32 | 33 | return self._make_request( 34 | "PUT", 35 | "%s" % (self.ROOT), 36 | body=body, 37 | callback=callback, 38 | errback=errback, 39 | ) 40 | 41 | def update(self, username, callback=None, errback=None, **kwargs): 42 | body = {"username": username} 43 | self._buildStdBody(body, kwargs) 44 | 45 | return self._make_request( 46 | "POST", 47 | "%s/%s" % (self.ROOT, username), 48 | body=body, 49 | callback=callback, 50 | errback=errback, 51 | ) 52 | 53 | def delete(self, username, callback=None, errback=None): 54 | return self._make_request( 55 | "DELETE", 56 | "%s/%s" % (self.ROOT, username), 57 | callback=callback, 58 | errback=errback, 59 | ) 60 | 61 | def list(self, callback=None, errback=None): 62 | return self._make_request( 63 | "GET", "%s" % self.ROOT, callback=callback, errback=errback 64 | ) 65 | 66 | def retrieve(self, username, callback=None, errback=None): 67 | return self._make_request( 68 | "GET", 69 | "%s/%s" % (self.ROOT, username), 70 | callback=callback, 71 | errback=errback, 72 | ) 73 | -------------------------------------------------------------------------------- /ns1/rest/views.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | 6 | from . import resource 7 | 8 | 9 | class Views(resource.BaseResource): 10 | ROOT = "views" 11 | INT_FIELDS = [ 12 | "preference", 13 | ] 14 | PASSTHRU_FIELDS = [ 15 | "read_acls", 16 | "update_acls", 17 | "zones", 18 | "networks", 19 | ] 20 | 21 | def _buildBody(self, view_name, **kwargs): 22 | body = {} 23 | body["view_name"] = view_name 24 | self._buildStdBody(body, kwargs) 25 | return body 26 | 27 | def create(self, view_name, callback=None, errback=None, **kwargs): 28 | body = self._buildBody(view_name, **kwargs) 29 | 30 | return self.create_raw( 31 | view_name, body, callback=callback, errback=errback, **kwargs 32 | ) 33 | 34 | def create_raw( 35 | self, view_name, body, callback=None, errback=None, **kwargs 36 | ): 37 | return self._make_request( 38 | "PUT", 39 | "%s/%s" % (self.ROOT, view_name), 40 | body=body, 41 | callback=callback, 42 | errback=errback, 43 | ) 44 | 45 | def update(self, view_name, callback=None, errback=None, **kwargs): 46 | body = self._buildBody(view_name, **kwargs) 47 | 48 | return self._make_request( 49 | "POST", 50 | "%s/%s" % (self.ROOT, view_name), 51 | body=body, 52 | callback=callback, 53 | errback=errback, 54 | ) 55 | 56 | def delete(self, view_name, callback=None, errback=None): 57 | return self._make_request( 58 | "DELETE", 59 | "%s/%s" % (self.ROOT, view_name), 60 | callback=callback, 61 | errback=errback, 62 | ) 63 | 64 | def retrieve(self, view_name, callback=None, errback=None): 65 | return self._make_request( 66 | "GET", 67 | "%s/%s" % (self.ROOT, view_name), 68 | callback=callback, 69 | errback=errback, 70 | ) 71 | -------------------------------------------------------------------------------- /ns1/rest/zones.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | 7 | from . import resource 8 | 9 | 10 | class Zones(resource.BaseResource): 11 | ROOT = "zones" 12 | SEARCH_ROOT = "search" 13 | 14 | INT_FIELDS = ["ttl", "retry", "refresh", "expiry", "nx_ttl"] 15 | PASSTHRU_FIELDS = [ 16 | "primary", 17 | "secondary", 18 | "hostmaster", 19 | "meta", 20 | "networks", 21 | "link", 22 | "primary_master", 23 | "tags", 24 | "views", 25 | ] 26 | BOOL_FIELDS = ["dnssec"] 27 | 28 | ZONEFILE_FIELDS = [ 29 | "networks", 30 | "views", 31 | ] 32 | 33 | def _buildBody(self, zone, **kwargs): 34 | body = {} 35 | body["zone"] = zone 36 | self._buildStdBody(body, kwargs) 37 | return body 38 | 39 | def import_file( 40 | self, zone, zoneFile, callback=None, errback=None, **kwargs 41 | ): 42 | files = [("zonefile", (zoneFile, open(zoneFile, "rb"), "text/plain"))] 43 | params = self._buildImportParams(kwargs) 44 | return self._make_request( 45 | "PUT", 46 | f"import/zonefile/{zone}", 47 | files=files, 48 | params=params, 49 | callback=callback, 50 | errback=errback, 51 | ) 52 | 53 | # Extra import args are specified as query parameters not fields in a JSON object. 54 | def _buildImportParams(self, fields): 55 | params = {} 56 | # Arrays of values should be passed as multiple instances of the same 57 | # parameter but the zonefile API expects parameters containing comma 58 | # seperated values. 59 | if fields.get("networks") is not None: 60 | networks_strs = [str(network) for network in fields["networks"]] 61 | networks_param = ",".join(networks_strs) 62 | params["networks"] = networks_param 63 | if fields.get("views") is not None: 64 | views_param = ",".join(fields["views"]) 65 | params["views"] = views_param 66 | if fields.get("name") is not None: 67 | params["name"] = fields.get("name") 68 | return params 69 | 70 | def create(self, zone, callback=None, errback=None, name=None, **kwargs): 71 | body = self._buildBody(zone, **kwargs) 72 | if name is None: 73 | name = zone 74 | return self._make_request( 75 | "PUT", 76 | f"{self.ROOT}/{name}", 77 | body=body, 78 | callback=callback, 79 | errback=errback, 80 | ) 81 | 82 | def update(self, zone, callback=None, errback=None, **kwargs): 83 | body = self._buildBody(zone, **kwargs) 84 | return self._make_request( 85 | "POST", 86 | "%s/%s" % (self.ROOT, zone), 87 | body=body, 88 | callback=callback, 89 | errback=errback, 90 | ) 91 | 92 | def delete(self, zone, callback=None, errback=None): 93 | return self._make_request( 94 | "DELETE", 95 | "%s/%s" % (self.ROOT, zone), 96 | callback=callback, 97 | errback=errback, 98 | ) 99 | 100 | def list(self, callback=None, errback=None): 101 | return self._make_request( 102 | "GET", 103 | "%s" % self.ROOT, 104 | callback=callback, 105 | errback=errback, 106 | pagination_handler=zone_list_pagination, 107 | ) 108 | 109 | def retrieve(self, zone, callback=None, errback=None): 110 | return self._make_request( 111 | "GET", 112 | "%s/%s" % (self.ROOT, zone), 113 | callback=callback, 114 | errback=errback, 115 | pagination_handler=zone_retrieve_pagination, 116 | ) 117 | 118 | def search( 119 | self, 120 | query, 121 | type="all", 122 | expand=True, 123 | max=None, 124 | callback=None, 125 | errback=None, 126 | ): 127 | request = "{}?q={}&type={}&expand={}".format( 128 | self.SEARCH_ROOT, query, type, str.lower(str(expand)) 129 | ) 130 | if max is not None: 131 | request += "&max=" + str(max) 132 | return self._make_request( 133 | "GET", 134 | request, 135 | params={}, 136 | callback=callback, 137 | errback=errback, 138 | ) 139 | 140 | def list_versions(self, zone, callback=None, errback=None): 141 | request = "{}/{}/versions".format(self.ROOT, zone) 142 | return self._make_request( 143 | "GET", 144 | request, 145 | params={}, 146 | callback=callback, 147 | errback=errback, 148 | ) 149 | 150 | def create_version(self, zone, force=False, callback=None, errback=None): 151 | request = "{}/{}/versions?force={}".format( 152 | self.ROOT, zone, str.lower(str(force)) 153 | ) 154 | return self._make_request( 155 | "PUT", 156 | request, 157 | params={}, 158 | callback=callback, 159 | errback=errback, 160 | ) 161 | 162 | def activate_version(self, zone, version_id, callback=None, errback=None): 163 | request = "{}/{}/versions/{}/activate".format( 164 | self.ROOT, zone, str(version_id) 165 | ) 166 | return self._make_request( 167 | "POST", 168 | request, 169 | params={}, 170 | callback=callback, 171 | errback=errback, 172 | ) 173 | 174 | def delete_version(self, zone, version_id, callback=None, errback=None): 175 | request = "{}/{}/versions/{}".format(self.ROOT, zone, str(version_id)) 176 | return self._make_request( 177 | "DELETE", 178 | request, 179 | params={}, 180 | callback=callback, 181 | errback=errback, 182 | ) 183 | 184 | 185 | # successive pages just extend the list of zones 186 | def zone_list_pagination(curr_json, next_json): 187 | curr_json.extend(next_json) 188 | return curr_json 189 | 190 | 191 | # successive pages only differ in the "records" list 192 | def zone_retrieve_pagination(curr_json, next_json): 193 | curr_json["records"].extend(next_json["records"]) 194 | return curr_json 195 | -------------------------------------------------------------------------------- /ns1/views.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | from ns1.rest.views import Views 7 | 8 | 9 | class ViewException(Exception): 10 | pass 11 | 12 | 13 | class View(object): 14 | def __init__(self, config, view): 15 | self._rest = Views(config) 16 | self.config = config 17 | self.view = view 18 | self.data = None 19 | 20 | def __repr__(self): 21 | return "" % self.view 22 | 23 | def __getitem__(self, item): 24 | return self.data.get(item, None) 25 | 26 | def reload(self, callback=None, errback=None): 27 | return self.load(reload=True, callback=callback, errback=errback) 28 | 29 | def load(self, callback=None, errback=None, reload=False): 30 | if not reload and self.data: 31 | raise ViewException("view already loaded") 32 | 33 | def success(result, *args): 34 | self.data = result 35 | if callback: 36 | return callback(self) 37 | else: 38 | return self 39 | 40 | return self._rest.retrieve( 41 | self.view, callback=success, errback=errback 42 | ) 43 | 44 | def delete(self, callback=None, errback=None): 45 | return self._rest.delete(self.view, callback=callback, errback=errback) 46 | 47 | def update(self, callback=None, errback=None, **kwargs): 48 | if not self.data: 49 | raise ViewException("view not loaded") 50 | 51 | def success(result, *args): 52 | self.data = result 53 | if callback: 54 | return callback(self) 55 | else: 56 | return self 57 | 58 | return self._rest.update( 59 | self.view, callback=success, errback=errback, **kwargs 60 | ) 61 | 62 | def create(self, callback=None, errback=None, **kwargs): 63 | if self.data: 64 | raise ViewException("view already loaded") 65 | 66 | def success(result, *args): 67 | self.data = result 68 | if callback: 69 | return callback(self) 70 | else: 71 | return self 72 | 73 | return self._rest.create( 74 | self.view, callback=success, errback=errback, **kwargs 75 | ) 76 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns1/ns1-python/506c5ac3b90e748fec83c38bb864259435061832/requirements.txt -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = LICENSE 3 | 4 | [bdist_wheel] 5 | universal=1 6 | [aliases] 7 | test=pytest 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | from codecs import open 4 | from os import path 5 | import ns1 6 | 7 | cwd = path.abspath(path.dirname(__file__)) 8 | 9 | with open(path.join(cwd, "README.md"), encoding="utf-8") as f: 10 | long_description = f.read() 11 | 12 | setup( 13 | name="ns1-python", 14 | # flake8: noqa 15 | version=ns1.version, 16 | description="Python SDK for the NS1 DNS platform", 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | license="MIT", 20 | # contact information 21 | author="NS1 Developers", 22 | author_email="devteam@ns1.com", 23 | url="https://github.com/ns1/ns1-python", 24 | packages=find_packages(exclude=["tests", "examples"]), 25 | setup_requires=[ 26 | "pytest-runner", 27 | "wheel", 28 | ], 29 | tests_require=[ 30 | "pytest", 31 | "pytest-pep8", 32 | "pytest-cov", 33 | "mock", 34 | ], 35 | keywords="dns development rest sdk ns1 nsone", 36 | classifiers=[ 37 | "Development Status :: 4 - Beta", 38 | "Intended Audience :: Developers", 39 | "Intended Audience :: Information Technology", 40 | "License :: OSI Approved :: MIT License", 41 | "Operating System :: OS Independent", 42 | "Programming Language :: Python", 43 | "Programming Language :: Python :: 2", 44 | "Programming Language :: Python :: 2.7", 45 | "Programming Language :: Python :: 3", 46 | "Programming Language :: Python :: 3.6", 47 | "Topic :: Software Development :: Libraries :: Python Modules", 48 | "Topic :: Internet :: Name Service (DNS)", 49 | ], 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ns1/ns1-python/506c5ac3b90e748fec83c38bb864259435061832/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import pytest 4 | 5 | from ns1 import Config 6 | 7 | 8 | @pytest.fixture 9 | def config(monkeypatch, tmpdir): 10 | """ 11 | Injects ns1.Config instance. 12 | 13 | :param pytest.monkeypatch 14 | :param pytest.tmpdir 15 | :return: ns1.Config instance in which os.path.expanduser is \ 16 | patched with '/tmp' subdir that is unique per test. 17 | """ 18 | 19 | def mockreturn(path): 20 | tmp_cfg_path = str(tmpdir.join("ns1_test")) 21 | return tmp_cfg_path 22 | 23 | monkeypatch.setattr(os.path, "expanduser", mockreturn) 24 | 25 | cfg = Config() 26 | return cfg 27 | -------------------------------------------------------------------------------- /tests/unit/test_acl.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import ns1.rest.acls 4 | 5 | try: # Python 3.3 + 6 | import unittest.mock as mock 7 | except ImportError: 8 | import mock 9 | 10 | 11 | @pytest.fixture 12 | def acl_config(config): 13 | config.loadFromDict( 14 | { 15 | "endpoint": "api.nsone.net", 16 | "default_key": "test1", 17 | "keys": { 18 | "test1": { 19 | "key": "key-1", 20 | "desc": "test key number 1", 21 | } 22 | }, 23 | } 24 | ) 25 | return config 26 | 27 | 28 | @pytest.mark.parametrize("acl_name, url", [("test_acl", "acls/test_acl")]) 29 | def test_rest_acl_retrieve(acl_config, acl_name, url): 30 | z = ns1.rest.acls.Acls(acl_config) 31 | z._make_request = mock.MagicMock() 32 | z.retrieve(acl_name) 33 | z._make_request.assert_called_once_with( 34 | "GET", url, callback=None, errback=None 35 | ) 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "acl_name, url", 40 | [ 41 | ( 42 | "test_acl", 43 | "acls/test_acl", 44 | ) 45 | ], 46 | ) 47 | def test_rest_acl_create(acl_config, acl_name, url): 48 | z = ns1.rest.acls.Acls(acl_config) 49 | z._make_request = mock.MagicMock() 50 | z.create(acl_name=acl_name) 51 | z._make_request.assert_called_once_with( 52 | "PUT", 53 | url, 54 | body={ 55 | "acl_name": acl_name, 56 | }, 57 | callback=None, 58 | errback=None, 59 | ) 60 | 61 | 62 | @pytest.mark.parametrize( 63 | "acl_name, url", 64 | [("test_acl", "acls/test_acl")], 65 | ) 66 | def test_rest_acl_update(acl_config, acl_name, url): 67 | z = ns1.rest.acls.Acls(acl_config) 68 | z._make_request = mock.MagicMock() 69 | z.update(acl_name=acl_name) 70 | z._make_request.assert_called_once_with( 71 | "POST", 72 | url, 73 | callback=None, 74 | errback=None, 75 | body={"acl_name": acl_name}, 76 | ) 77 | 78 | 79 | @pytest.mark.parametrize("acl_name, url", [("test_acl", "acls/test_acl")]) 80 | def test_rest_acl_delete(acl_config, acl_name, url): 81 | z = ns1.rest.acls.Acls(acl_config) 82 | z._make_request = mock.MagicMock() 83 | z.delete(acl_name) 84 | z._make_request.assert_called_once_with( 85 | "DELETE", url, callback=None, errback=None 86 | ) 87 | -------------------------------------------------------------------------------- /tests/unit/test_alerts.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024 NSONE, Inc. 3 | # 4 | # License under The MIT License (MIT). See LICENSE in project root. 5 | # 6 | import pytest 7 | 8 | import ns1.rest.alerts 9 | 10 | try: # Python 3.3 + 11 | import unittest.mock as mock 12 | except ImportError: 13 | import mock 14 | 15 | 16 | @pytest.fixture 17 | def alerts_config(config): 18 | config.loadFromDict( 19 | { 20 | "endpoint": "api.nsone.net", 21 | "default_key": "test1", 22 | "keys": { 23 | "test1": { 24 | "key": "key-1", 25 | "desc": "test key number 1", 26 | } 27 | }, 28 | } 29 | ) 30 | return config 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "alert_id, url", 35 | [ 36 | ( 37 | "9d51efb4-a012-43b0-bcd9-6fad45227baf", 38 | "../alerting/v1/alerts/9d51efb4-a012-43b0-bcd9-6fad45227baf", 39 | ) 40 | ], 41 | ) 42 | def test_rest_alert_retrieve(alerts_config, alert_id, url): 43 | a = ns1.rest.alerts.Alerts(alerts_config) 44 | a._make_request = mock.MagicMock() 45 | a.retrieve(alert_id) 46 | a._make_request.assert_called_once_with( 47 | "GET", url, callback=None, errback=None 48 | ) 49 | 50 | 51 | def test_rest_alert_list(alerts_config): 52 | a = ns1.rest.alerts.Alerts(alerts_config) 53 | a._make_request = mock.MagicMock() 54 | a.list() 55 | a._make_request.assert_called_once_with( 56 | "GET", 57 | "../alerting/v1/alerts", 58 | callback=None, 59 | errback=None, 60 | pagination_handler=ns1.rest.alerts.alert_list_pagination, 61 | ) 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "name, type, subtype, url, alert_params", 66 | [ 67 | ( 68 | "test_alert", 69 | "zone", 70 | "transfer_failed", 71 | "../alerting/v1/alerts", 72 | { 73 | "zone_names": ["example-secondary.com"], 74 | "notifier_list_ids": ["6707da567cd4f300012cd7e4"], 75 | }, 76 | ), 77 | ( 78 | "test_alert_with_data", 79 | "zone", 80 | "transfer_failed", 81 | "../alerting/v1/alerts", 82 | { 83 | "zone_names": ["example-secondary.com"], 84 | "notifier_list_ids": ["6707da567cd4f300012cd7e4"], 85 | "data": {"min": 20, "max": 80}, 86 | }, 87 | ), 88 | ], 89 | ) 90 | def test_rest_alert_create( 91 | alerts_config, name, type, subtype, url, alert_params 92 | ): 93 | a = ns1.rest.alerts.Alerts(alerts_config) 94 | a._make_request = mock.MagicMock() 95 | a.create(name=name, type=type, subtype=subtype, **alert_params) 96 | body = alert_params 97 | body["name"] = name 98 | body["type"] = type 99 | body["subtype"] = subtype 100 | a._make_request.assert_called_once_with( 101 | "POST", 102 | url, 103 | body=body, 104 | callback=None, 105 | errback=None, 106 | ) 107 | 108 | 109 | @pytest.mark.parametrize( 110 | "alert_id, url", 111 | [ 112 | ( 113 | "9d51efb4-a012-43b0-bcd9-6fad45227baf", 114 | "../alerting/v1/alerts/9d51efb4-a012-43b0-bcd9-6fad45227baf", 115 | ) 116 | ], 117 | ) 118 | def test_rest_alert_update(alerts_config, alert_id, url): 119 | a = ns1.rest.alerts.Alerts(alerts_config) 120 | a._make_request = mock.MagicMock() 121 | a.update(alert_id, name="newName") 122 | expectedBody = {"id": alert_id, "name": "newName"} 123 | a._make_request.assert_called_once_with( 124 | "PATCH", 125 | url, 126 | callback=None, 127 | errback=None, 128 | body=expectedBody, 129 | ) 130 | 131 | 132 | @pytest.mark.parametrize( 133 | "alert_id, url", 134 | [ 135 | ( 136 | "9d51efb4-a012-43b0-bcd9-6fad45227baf", 137 | "../alerting/v1/alerts/9d51efb4-a012-43b0-bcd9-6fad45227baf", 138 | ) 139 | ], 140 | ) 141 | def test_rest_alert_delete(alerts_config, alert_id, url): 142 | a = ns1.rest.alerts.Alerts(alerts_config) 143 | a._make_request = mock.MagicMock() 144 | a.delete(alert_id) 145 | a._make_request.assert_called_once_with( 146 | "DELETE", url, callback=None, errback=None 147 | ) 148 | 149 | 150 | # Alerts have a alerts//test endpoint to verify the attached notifiers work 151 | @pytest.mark.parametrize( 152 | "alert_id, url", 153 | [ 154 | ( 155 | "9d51efb4-a012-43b0-bcd9-6fad45227baf", 156 | "../alerting/v1/alerts/9d51efb4-a012-43b0-bcd9-6fad45227baf/test", 157 | ) 158 | ], 159 | ) 160 | def test_rest_alert_do_test(alerts_config, alert_id, url): 161 | a = ns1.rest.alerts.Alerts(alerts_config) 162 | a._make_request = mock.MagicMock() 163 | a.test(alert_id) 164 | a._make_request.assert_called_once_with( 165 | "POST", url, callback=None, errback=None 166 | ) 167 | 168 | 169 | def test_rest_alerts_buildbody(alerts_config): 170 | a = ns1.rest.alerts.Alerts(alerts_config) 171 | alert_id = "9d51efb4-a012-43b0-bcd9-6fad45227baf" 172 | kwargs = { 173 | "data": {"max": 80, "min": 20}, 174 | "name": "newName", 175 | "notifier_list_ids": [ 176 | "6707da567cd4f300012cd7e4", 177 | "6707da567cd4f300012cd7e6", 178 | ], 179 | "record_ids": ["6707da567cd4f300012cd7d4", "6707da567cd4f300012cd7d9"], 180 | "zone_names": ["www.example.com", "mail.example.com"], 181 | } 182 | expectedBody = { 183 | "id": alert_id, 184 | "name": "newName", 185 | "data": {"max": 80, "min": 20}, 186 | "notifier_list_ids": [ 187 | "6707da567cd4f300012cd7e4", 188 | "6707da567cd4f300012cd7e6", 189 | ], 190 | "record_ids": ["6707da567cd4f300012cd7d4", "6707da567cd4f300012cd7d9"], 191 | "zone_names": ["www.example.com", "mail.example.com"], 192 | } 193 | assert a._buildBody(alert_id, **kwargs) == expectedBody 194 | -------------------------------------------------------------------------------- /tests/unit/test_apikey.py: -------------------------------------------------------------------------------- 1 | import ns1.rest.apikey 2 | import ns1.rest.permissions as permissions 3 | import pytest 4 | 5 | try: # Python 3.3 + 6 | import unittest.mock as mock 7 | except ImportError: 8 | import mock 9 | 10 | 11 | @pytest.fixture 12 | def apikey_config(config): 13 | config.loadFromDict( 14 | { 15 | "endpoint": "api.nsone.net", 16 | "default_key": "test1", 17 | "keys": { 18 | "test1": { 19 | "key": "key-1", 20 | "desc": "test key number 1", 21 | } 22 | }, 23 | } 24 | ) 25 | 26 | return config 27 | 28 | 29 | def test_rest_apikey_list(apikey_config): 30 | z = ns1.rest.apikey.APIKey(apikey_config) 31 | z._make_request = mock.MagicMock() 32 | z.list() 33 | z._make_request.assert_called_once_with( 34 | "GET", "account/apikeys", callback=None, errback=None 35 | ) 36 | 37 | 38 | @pytest.mark.parametrize("apikey_id, url", [("1", "account/apikeys/1")]) 39 | def test_rest_apikey_retrieve(apikey_config, apikey_id, url): 40 | z = ns1.rest.apikey.APIKey(apikey_config) 41 | z._make_request = mock.MagicMock() 42 | z.retrieve(apikey_id) 43 | z._make_request.assert_called_once_with( 44 | "GET", url, callback=None, errback=None 45 | ) 46 | 47 | 48 | @pytest.mark.parametrize("name, url", [("test-apikey", "account/apikeys")]) 49 | def test_rest_apikey_create(apikey_config, name, url): 50 | z = ns1.rest.apikey.APIKey(apikey_config) 51 | z._make_request = mock.MagicMock() 52 | z.create(name) 53 | z._make_request.assert_called_once_with( 54 | "PUT", 55 | url, 56 | callback=None, 57 | errback=None, 58 | body={"name": name, "permissions": permissions._default_perms}, 59 | ) 60 | 61 | 62 | @pytest.mark.parametrize( 63 | "apikey_id, name, ip_whitelist, permissions, url", 64 | [ 65 | ( 66 | "test-apikey_id", 67 | "test-apikey", 68 | ["1.1.1.1", "2.2.2.2"], 69 | {"data": {"push_to_datafeeds": True}}, 70 | "account/apikeys/test-apikey_id", 71 | ) 72 | ], 73 | ) 74 | def test_rest_apikey_update( 75 | apikey_config, apikey_id, name, ip_whitelist, permissions, url 76 | ): 77 | z = ns1.rest.apikey.APIKey(apikey_config) 78 | z._make_request = mock.MagicMock() 79 | z.update( 80 | apikey_id, 81 | name=name, 82 | ip_whitelist=ip_whitelist, 83 | permissions=permissions, 84 | ) 85 | z._make_request.assert_called_once_with( 86 | "POST", 87 | url, 88 | callback=None, 89 | errback=None, 90 | body={ 91 | "name": name, 92 | "ip_whitelist": ip_whitelist, 93 | "permissions": permissions, 94 | }, 95 | ) 96 | 97 | 98 | @pytest.mark.parametrize( 99 | "apikey_id, url", [("test-apikey_id", "account/apikeys/test-apikey_id")] 100 | ) 101 | def test_rest_apikey_delete(apikey_config, apikey_id, url): 102 | z = ns1.rest.apikey.APIKey(apikey_config) 103 | z._make_request = mock.MagicMock() 104 | z.delete(apikey_id) 105 | z._make_request.assert_called_once_with( 106 | "DELETE", url, callback=None, errback=None 107 | ) 108 | -------------------------------------------------------------------------------- /tests/unit/test_billing_usage.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ns1 import NS1 4 | 5 | import unittest.mock as mock 6 | 7 | 8 | @pytest.fixture 9 | def billing_usage_config(config): 10 | config.loadFromDict( 11 | { 12 | "endpoint": "api.nsone.net", 13 | "default_key": "test1", 14 | "keys": { 15 | "test1": { 16 | "key": "key-1", 17 | "desc": "test key number 1", 18 | "writeLock": True, 19 | } 20 | }, 21 | } 22 | ) 23 | 24 | return config 25 | 26 | 27 | @pytest.mark.parametrize("url", ["billing-usage/queries"]) 28 | def test_rest_get_billing_usage_for_queries(billing_usage_config, url): 29 | z = NS1(config=billing_usage_config).billing_usage() 30 | z._make_request = mock.MagicMock() 31 | z.getQueriesUsage(from_unix=123, to_unix=456) 32 | z._make_request.assert_called_once_with( 33 | "GET", 34 | url, 35 | callback=None, 36 | errback=None, 37 | params={"from": 123, "to": 456}, 38 | ) 39 | 40 | 41 | @pytest.mark.parametrize("url", ["billing-usage/decisions"]) 42 | def test_rest_get_billing_usage_for_decisions(billing_usage_config, url): 43 | z = NS1(config=billing_usage_config).billing_usage() 44 | z._make_request = mock.MagicMock() 45 | z.getDecisionsUsage(from_unix=123, to_unix=456) 46 | z._make_request.assert_called_once_with( 47 | "GET", 48 | url, 49 | callback=None, 50 | errback=None, 51 | params={"from": 123, "to": 456}, 52 | ) 53 | 54 | 55 | @pytest.mark.parametrize("url", ["billing-usage/records"]) 56 | def test_rest_get_billing_usage_for_records(billing_usage_config, url): 57 | z = NS1(config=billing_usage_config).billing_usage() 58 | z._make_request = mock.MagicMock() 59 | z.getRecordsUsage() 60 | z._make_request.assert_called_once_with( 61 | "GET", 62 | url, 63 | callback=None, 64 | errback=None, 65 | params={}, 66 | ) 67 | 68 | 69 | @pytest.mark.parametrize("url", ["billing-usage/filter-chains"]) 70 | def test_rest_get_billing_usage_for_filter_chains(billing_usage_config, url): 71 | z = NS1(config=billing_usage_config).billing_usage() 72 | z._make_request = mock.MagicMock() 73 | z.getFilterChainsUsage() 74 | z._make_request.assert_called_once_with( 75 | "GET", 76 | url, 77 | callback=None, 78 | errback=None, 79 | params={}, 80 | ) 81 | 82 | 83 | @pytest.mark.parametrize("url", ["billing-usage/monitors"]) 84 | def test_rest_get_billing_usage_for_monitors(billing_usage_config, url): 85 | z = NS1(config=billing_usage_config).billing_usage() 86 | z._make_request = mock.MagicMock() 87 | z.getMonitorsUsage() 88 | z._make_request.assert_called_once_with( 89 | "GET", 90 | url, 91 | callback=None, 92 | errback=None, 93 | params={}, 94 | ) 95 | 96 | 97 | @pytest.mark.parametrize("url", ["billing-usage/limits"]) 98 | def test_rest_get_billing_usage_limits(billing_usage_config, url): 99 | z = NS1(config=billing_usage_config).billing_usage() 100 | z._make_request = mock.MagicMock() 101 | z.getLimits(from_unix=123, to_unix=456) 102 | z._make_request.assert_called_once_with( 103 | "GET", 104 | url, 105 | callback=None, 106 | errback=None, 107 | params={"from": 123, "to": 456}, 108 | ) 109 | -------------------------------------------------------------------------------- /tests/unit/test_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from ns1.config import Config 5 | from ns1.config import ConfigException 6 | 7 | 8 | defaults = { 9 | "verbosity": 0, 10 | "endpoint": "api.nsone.net", 11 | "port": 443, 12 | "api_version": "v1", 13 | "api_version_before_resource": True, 14 | "cli": {}, 15 | "ddi": False, 16 | "follow_pagination": False, 17 | } 18 | 19 | 20 | def test_need_path(config): 21 | pytest.raises(ConfigException, config.write) 22 | 23 | 24 | def test_writeread(tmpdir, config): 25 | config.loadFromDict( 26 | { 27 | "endpoint": "api.nsone.net", 28 | "default_key": "test1", 29 | "keys": { 30 | "test1": { 31 | "key": "key-1", 32 | "desc": "test key number 1", 33 | } 34 | }, 35 | } 36 | ) 37 | 38 | # Write config to temp. 39 | tmp_cfg_path = str(tmpdir.join("test_writeread.json")) 40 | config.write(tmp_cfg_path) 41 | 42 | # Read new but identical config instance. 43 | cfg_read = Config(tmp_cfg_path) 44 | assert cfg_read is not config 45 | assert cfg_read.getEndpoint() == config.getEndpoint() 46 | assert cfg_read.getCurrentKeyID() == config.getCurrentKeyID() 47 | 48 | 49 | def test_str_repr(config): 50 | import re 51 | 52 | reg = re.compile("^config file ") 53 | rep = str(config) 54 | assert reg.match(rep) 55 | 56 | 57 | def test_dodefaults(config): 58 | assert config._data == {} 59 | config._doDefaults() 60 | assert config._data == defaults 61 | 62 | 63 | def test_create_from_apikey(config): 64 | apikey = "apikey" 65 | config.createFromAPIKey(apikey) 66 | assert config.getAPIKey() == apikey 67 | assert config.getCurrentKeyID() == "default" 68 | 69 | 70 | def test_load_from_str(config): 71 | key_cfg = { 72 | "default_key": "test1", 73 | "keys": { 74 | "test1": { 75 | "key": "key-1", 76 | "desc": "test key number 1", 77 | } 78 | }, 79 | } 80 | 81 | config.loadFromString(json.dumps(key_cfg)) 82 | assert config.getCurrentKeyID() == key_cfg["default_key"] 83 | assert config["keys"] == key_cfg["keys"] 84 | assert config.getAPIKey() == key_cfg["keys"][key_cfg["default_key"]]["key"] 85 | endpoint = f'https://{defaults["endpoint"]}' 86 | assert config.getEndpoint() == endpoint 87 | -------------------------------------------------------------------------------- /tests/unit/test_data.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import ns1.rest.data 4 | 5 | try: # Python 3.3 + 6 | import unittest.mock as mock 7 | except ImportError: 8 | import mock 9 | 10 | 11 | @pytest.fixture 12 | def source_config(config): 13 | config.loadFromDict( 14 | { 15 | "endpoint": "api.nsone.net", 16 | "default_key": "test1", 17 | "keys": { 18 | "test1": { 19 | "key": "key-1", 20 | "desc": "test key number 1", 21 | } 22 | }, 23 | } 24 | ) 25 | return config 26 | 27 | 28 | def test_rest_data_source_list(source_config): 29 | z = ns1.rest.data.Source(source_config) 30 | z._make_request = mock.MagicMock() 31 | z.list() 32 | z._make_request.assert_called_once_with( 33 | "GET", "data/sources", callback=None, errback=None 34 | ) 35 | 36 | 37 | @pytest.mark.parametrize("sourceid, url", [("1", "data/sources/1")]) 38 | def test_rest_data_source_retrieve(source_config, sourceid, url): 39 | z = ns1.rest.data.Source(source_config) 40 | z._make_request = mock.MagicMock() 41 | z.retrieve(sourceid) 42 | z._make_request.assert_called_once_with( 43 | "GET", url, callback=None, errback=None 44 | ) 45 | 46 | 47 | @pytest.mark.parametrize( 48 | "source_name, sourcetype, url", 49 | [("test_source", "test_sourcetype", "data/sources")], 50 | ) 51 | def test_rest_data_source_create(source_config, source_name, sourcetype, url): 52 | z = ns1.rest.data.Source(source_config) 53 | z._make_request = mock.MagicMock() 54 | z.create(source_name, sourcetype, config={}) 55 | z._make_request.assert_called_once_with( 56 | "PUT", 57 | url, 58 | callback=None, 59 | errback=None, 60 | body={"config": {}, "name": source_name, "sourcetype": sourcetype}, 61 | ) 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "sourceid, sourcetype, url", [("1", "ns1_v1", "data/sources/1")] 66 | ) 67 | def test_rest_data_source_update(source_config, sourceid, sourcetype, url): 68 | z = ns1.rest.data.Source(source_config) 69 | z._make_request = mock.MagicMock() 70 | z.update(sourceid, sourcetype, name="test_source_name", config={}) 71 | z._make_request.assert_called_once_with( 72 | "POST", 73 | url, 74 | callback=None, 75 | errback=None, 76 | body={ 77 | "config": {}, 78 | "name": "test_source_name", 79 | "sourcetype": sourcetype, 80 | }, 81 | ) 82 | 83 | 84 | @pytest.mark.parametrize("sourceid, url", [("1", "data/sources/1")]) 85 | def test_rest_data_source_delete(source_config, sourceid, url): 86 | z = ns1.rest.data.Source(source_config) 87 | z._make_request = mock.MagicMock() 88 | z.delete(sourceid) 89 | z._make_request.assert_called_once_with( 90 | "DELETE", url, callback=None, errback=None 91 | ) 92 | 93 | 94 | @pytest.mark.parametrize( 95 | "sourceid, data, url", [("1", {"foo": "foo", "bar": 1}, "feed/1")] 96 | ) 97 | def test_rest_data_source_publish(source_config, sourceid, data, url): 98 | z = ns1.rest.data.Source(source_config) 99 | z._make_request = mock.MagicMock() 100 | z.publish(sourceid, data=data) 101 | z._make_request.assert_called_once_with( 102 | "POST", url, callback=None, errback=None, body=data 103 | ) 104 | -------------------------------------------------------------------------------- /tests/unit/test_datasets.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import ns1.rest.datasets 4 | from ns1 import NS1 5 | 6 | try: # Python 3.3 + 7 | import unittest.mock as mock 8 | except ImportError: 9 | import mock 10 | 11 | 12 | @pytest.fixture 13 | def datasets_config(config): 14 | config.loadFromDict( 15 | { 16 | "endpoint": "api.nsone.net", 17 | "default_key": "test1", 18 | "keys": { 19 | "test1": { 20 | "key": "key-1", 21 | "desc": "test key number 1", 22 | "writeLock": True, 23 | } 24 | }, 25 | } 26 | ) 27 | 28 | return config 29 | 30 | 31 | @pytest.mark.parametrize("url", [("datasets")]) 32 | def test_rest_datasets_list(datasets_config, url): 33 | z = NS1(config=datasets_config).datasets() 34 | z._make_request = mock.MagicMock() 35 | z.list() 36 | z._make_request.assert_called_once_with( 37 | "GET", 38 | url, 39 | callback=None, 40 | errback=None, 41 | ) 42 | 43 | 44 | @pytest.mark.parametrize( 45 | "dtId, url", 46 | [ 47 | ( 48 | "96529d62-fb0c-4150-b5ad-6e5b8b2736f6", 49 | "datasets/96529d62-fb0c-4150-b5ad-6e5b8b2736f6", 50 | ) 51 | ], 52 | ) 53 | def test_rest_dataset_retrieve(datasets_config, dtId, url): 54 | z = NS1(config=datasets_config).datasets() 55 | z._make_request = mock.MagicMock() 56 | z.retrieve(dtId) 57 | z._make_request.assert_called_once_with( 58 | "GET", 59 | url, 60 | callback=None, 61 | errback=None, 62 | ) 63 | 64 | 65 | @pytest.mark.parametrize("url", [("datasets")]) 66 | def test_rest_dataset_create(datasets_config, url): 67 | z = NS1(config=datasets_config).datasets() 68 | z._make_request = mock.MagicMock() 69 | z.create( 70 | name="my dataset", 71 | datatype={ 72 | "type": "num_queries", 73 | "scope": "account", 74 | }, 75 | repeat=None, 76 | timeframe={"aggregation": "monthly", "cycles": 1}, 77 | export_type="csv", 78 | recipient_emails=None, 79 | ) 80 | 81 | z._make_request.assert_called_once_with( 82 | "PUT", 83 | url, 84 | body={ 85 | "name": "my dataset", 86 | "datatype": { 87 | "type": "num_queries", 88 | "scope": "account", 89 | }, 90 | "timeframe": {"aggregation": "monthly", "cycles": 1}, 91 | "repeat": None, 92 | "export_type": "csv", 93 | "recipient_emails": None, 94 | }, 95 | callback=None, 96 | errback=None, 97 | ) 98 | 99 | 100 | @pytest.mark.parametrize( 101 | "dtId, url", 102 | [ 103 | ( 104 | "96529d62-fb0c-4150-b5ad-6e5b8b2736f6", 105 | "datasets/96529d62-fb0c-4150-b5ad-6e5b8b2736f6", 106 | ) 107 | ], 108 | ) 109 | def test_rest_datasets_delete(datasets_config, dtId, url): 110 | z = NS1(config=datasets_config).datasets() 111 | z._make_request = mock.MagicMock() 112 | z.delete(dtId) 113 | z._make_request.assert_called_once_with( 114 | "DELETE", 115 | url, 116 | callback=None, 117 | errback=None, 118 | ) 119 | 120 | 121 | def test_rest_datasets_buildbody(datasets_config): 122 | z = ns1.rest.datasets.Datasets(datasets_config) 123 | kwargs = { 124 | "name": "my dataset", 125 | "datatype": { 126 | "type": "num_queries", 127 | "scope": "account", 128 | }, 129 | "timeframe": {"aggregation": "monthly", "cycles": 1}, 130 | "repeat": None, 131 | "recipient_emails": None, 132 | "export_type": "csv", 133 | } 134 | body = { 135 | "name": "my dataset", 136 | "datatype": { 137 | "type": "num_queries", 138 | "scope": "account", 139 | }, 140 | "timeframe": {"aggregation": "monthly", "cycles": 1}, 141 | "repeat": None, 142 | "recipient_emails": None, 143 | "export_type": "csv", 144 | } 145 | assert z._buildBody(**kwargs) == body 146 | -------------------------------------------------------------------------------- /tests/unit/test_helpers.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import pytest 3 | import threading 4 | 5 | from uuid import uuid4 6 | 7 | import ns1.helpers 8 | 9 | try: # Python 3.3 + 10 | import queue 11 | except ImportError: 12 | import Queue as queue 13 | 14 | 15 | JOIN_TIMEOUT = 2 16 | 17 | 18 | class A(ns1.helpers.SingletonMixin): 19 | def __init__(self): 20 | self.uuid = uuid4() 21 | 22 | 23 | class B(ns1.helpers.SingletonMixin): 24 | def __init__(self): 25 | self.uuid = uuid4() 26 | 27 | 28 | def test_singleton_mixin(): 29 | """ 30 | it should be the same instance per mixed-in class 31 | it should not be the same instance across subclassers 32 | """ 33 | a, a2 = A(), A() 34 | b, b2 = B(), B() 35 | 36 | assert a is a2 37 | assert b is b2 38 | 39 | assert a is not b 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "queue_class,process,repetitions", 44 | [ 45 | (multiprocessing.Queue, multiprocessing.Process, 64), 46 | (queue.Queue, threading.Thread, 64), 47 | ], 48 | ) 49 | def test_singleton_mixin_with_concurrency(queue_class, process, repetitions): 50 | """ 51 | it should hold up under multiple processes or threads 52 | """ 53 | 54 | def inner(queue): 55 | a = A() 56 | b = A() 57 | queue.put((a.uuid, b.uuid)) 58 | 59 | test_queue = queue_class() 60 | processes = [] 61 | for _ in range(repetitions): 62 | p = process(target=inner, args=(test_queue,)) 63 | p.start() 64 | processes.append(p) 65 | 66 | seen_uuids = set() 67 | while len(seen_uuids) < repetitions: 68 | a, b = test_queue.get(timeout=JOIN_TIMEOUT) 69 | assert a is b 70 | assert a not in seen_uuids 71 | seen_uuids.add(a) 72 | 73 | 74 | @pytest.mark.parametrize( 75 | "headers, want", 76 | [ 77 | ({}, None), 78 | ( 79 | { 80 | "link": "; rel=next; type='image/jpeg'," 81 | "; rel=last;type='image/jpeg'" 82 | }, 83 | "https://a.co/b.jpg", 84 | ), 85 | ( 86 | { 87 | "Link": "; rel=next; type='image/jpeg'," 88 | "; rel=last;type='image/jpeg'" 89 | }, 90 | "https://a.co/b.jpg", 91 | ), 92 | ( 93 | { 94 | "Link": "; rel=next; type='image/jpeg'," 95 | "; rel=last;type='image/jpeg'" 96 | }, 97 | "https://a.co/b.jpg", 98 | ), 99 | ( 100 | { 101 | "link": "; rel=front; type='image/jpeg'," 102 | "; rel=back;type='image/jpeg'" 103 | }, 104 | None, 105 | ), 106 | ], 107 | ) 108 | def test_next_page(headers, want): 109 | """ 110 | it should get the "next" link 111 | it should "https-ify" http links 112 | it is not case-sensitive when reading header dict 113 | """ 114 | got = ns1.helpers.get_next_page(headers) 115 | assert got == want 116 | -------------------------------------------------------------------------------- /tests/unit/test_init.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ns1 import NS1 4 | from ns1.rest.account import Plan 5 | from ns1.rest.apikey import APIKey 6 | from ns1.rest.data import Feed, Source 7 | from ns1.rest.monitoring import JobTypes, Monitors, NotifyLists, Regions 8 | from ns1.rest.records import Records 9 | from ns1.rest.stats import Stats 10 | from ns1.rest.team import Team 11 | from ns1.rest.user import User 12 | from ns1.rest.zones import Zones 13 | 14 | 15 | @pytest.fixture 16 | def ns1_config(config): 17 | config.loadFromDict( 18 | { 19 | "endpoint": "api.nsone.net", 20 | "default_key": "test1", 21 | "keys": { 22 | "test1": { 23 | "key": "key-1", 24 | "desc": "test key number 1", 25 | } 26 | }, 27 | } 28 | ) 29 | 30 | return config 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "method, want", 35 | [ 36 | ("zones", Zones), 37 | ("records", Records), 38 | ("stats", Stats), 39 | ("datasource", Source), 40 | ("datafeed", Feed), 41 | ("monitors", Monitors), 42 | ("notifylists", NotifyLists), 43 | ("monitoring_jobtypes", JobTypes), 44 | ("monitoring_regions", Regions), 45 | ("plan", Plan), 46 | ("team", Team), 47 | ("user", User), 48 | ("apikey", APIKey), 49 | ], 50 | ) 51 | def test_rest_interface(ns1_config, method, want): 52 | client = NS1(config=ns1_config) 53 | got = getattr(client, method)() 54 | assert isinstance(got, want) 55 | -------------------------------------------------------------------------------- /tests/unit/test_monitoring.py: -------------------------------------------------------------------------------- 1 | import ns1.rest.monitoring 2 | import pytest 3 | 4 | try: # Python 3.3 + 5 | import unittest.mock as mock 6 | except ImportError: 7 | import mock 8 | 9 | 10 | @pytest.fixture 11 | def monitoring_config(config): 12 | config.loadFromDict( 13 | { 14 | "endpoint": "api.nsone.net", 15 | "default_key": "test1", 16 | "keys": { 17 | "test1": { 18 | "key": "key-1", 19 | "desc": "test key number 1", 20 | } 21 | }, 22 | } 23 | ) 24 | 25 | return config 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "op, args, method, url, kwargs", 30 | [ 31 | ( 32 | "list", 33 | None, 34 | "GET", 35 | "monitoring/jobs", 36 | {"callback": None, "errback": None}, 37 | ), 38 | ( 39 | "create", 40 | [{}], 41 | "PUT", 42 | "monitoring/jobs", 43 | {"body": {}, "callback": None, "errback": None}, 44 | ), 45 | ( 46 | "retrieve", 47 | ["my-job-id"], 48 | "GET", 49 | "monitoring/jobs/my-job-id", 50 | {"callback": None, "errback": None}, 51 | ), 52 | ( 53 | "update", 54 | ["my-job-id", {}], 55 | "POST", 56 | "monitoring/jobs/my-job-id", 57 | {"body": {}, "callback": None, "errback": None}, 58 | ), 59 | ( 60 | "delete", 61 | ["my-job-id"], 62 | "DELETE", 63 | "monitoring/jobs/my-job-id", 64 | {"callback": None, "errback": None}, 65 | ), 66 | ], 67 | ) 68 | def test_rest_monitoring_monitors( 69 | monitoring_config, op, args, method, url, kwargs 70 | ): 71 | m = ns1.rest.monitoring.Monitors(monitoring_config) 72 | m._make_request = mock.MagicMock() 73 | operation = getattr(m, op) 74 | if args is not None: 75 | operation(*args) 76 | else: 77 | operation() 78 | m._make_request.assert_called_once_with(method, url, **kwargs) 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "op, args, method, url, kwargs", 83 | [ 84 | ("list", None, "GET", "lists", {"callback": None, "errback": None}), 85 | ( 86 | "create", 87 | [{}], 88 | "PUT", 89 | "lists", 90 | {"body": {}, "callback": None, "errback": None}, 91 | ), 92 | ( 93 | "retrieve", 94 | ["my-list-id"], 95 | "GET", 96 | "lists/my-list-id", 97 | {"callback": None, "errback": None}, 98 | ), 99 | ( 100 | "update", 101 | ["my-list-id", {}], 102 | "POST", 103 | "lists/my-list-id", 104 | {"body": {}, "callback": None, "errback": None}, 105 | ), 106 | ( 107 | "delete", 108 | ["my-list-id"], 109 | "DELETE", 110 | "lists/my-list-id", 111 | {"callback": None, "errback": None}, 112 | ), 113 | ], 114 | ) 115 | def test_rest_monitoring_notifylists( 116 | monitoring_config, op, args, method, url, kwargs 117 | ): 118 | m = ns1.rest.monitoring.NotifyLists(monitoring_config) 119 | m._make_request = mock.MagicMock() 120 | operation = getattr(m, op) 121 | if args is not None: 122 | operation(*args) 123 | else: 124 | operation() 125 | m._make_request.assert_called_once_with(method, url, **kwargs) 126 | 127 | 128 | def test_rest_monitoring_jobtypes(monitoring_config): 129 | m = ns1.rest.monitoring.JobTypes(monitoring_config) 130 | m._make_request = mock.MagicMock() 131 | m.list() 132 | m._make_request.assert_called_once_with( 133 | "GET", "monitoring/jobtypes", callback=None, errback=None 134 | ) 135 | 136 | 137 | def test_rest_monitoring_regions(monitoring_config): 138 | m = ns1.rest.monitoring.Regions(monitoring_config) 139 | m._make_request = mock.MagicMock() 140 | m.list() 141 | m._make_request.assert_called_once_with( 142 | "GET", "monitoring/regions", callback=None, errback=None 143 | ) 144 | -------------------------------------------------------------------------------- /tests/unit/test_resource.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | 4 | from ns1.config import Config 5 | from ns1.rest.resource import BaseResource 6 | from ns1.rest.transport.basic import BasicTransport 7 | from ns1.rest.transport.requests import have_requests, RequestsTransport 8 | 9 | try: # Python 3.3 + 10 | import unittest.mock as mock 11 | except ImportError: 12 | import mock 13 | 14 | 15 | # Note: twisted transport tests are in test_twisted.py 16 | 17 | 18 | class MockResponse: 19 | def __init__(self, status_code, json_data, headers=None, body=None): 20 | self.status_code = status_code 21 | self._json = json_data 22 | self._headers = {} if headers is None else headers 23 | self._body = json.dumps(json_data) 24 | 25 | def json(self): 26 | return self._json 27 | 28 | @property 29 | def text(self): 30 | return self._body 31 | 32 | @property 33 | def headers(self): 34 | return self._headers 35 | 36 | def read(self): 37 | return self._body 38 | 39 | 40 | def test_basic_transport(): 41 | """ 42 | it should get transport from config 43 | it should call rate_limit_func on _make_request 44 | """ 45 | config = Config() 46 | config.createFromAPIKey("AAAAAAAAAAAAAAAAA") 47 | config["transport"] = "basic" 48 | 49 | resource = BaseResource(config) 50 | 51 | assert resource._config == config 52 | assert isinstance(resource._transport, BasicTransport) 53 | 54 | resource._transport._opener.open = mock.Mock() 55 | resource._transport._opener.open.return_value = MockResponse(200, {}) 56 | resource._transport._rate_limit_func = mock.Mock() 57 | 58 | res = resource._make_request("GET", "my_path") 59 | assert res == {} 60 | 61 | resource._transport._rate_limit_func.assert_called_once_with( 62 | {"by": "customer", "limit": 10, "period": 1, "remaining": 100} 63 | ) 64 | 65 | 66 | def test_basic_transport_pagination(): 67 | config = Config() 68 | config.createFromAPIKey("AAAAAAAAAAAAAAAAA") 69 | config["transport"] = "basic" 70 | config["follow_pagination"] = True 71 | 72 | resource = BaseResource(config) 73 | 74 | resource._transport._opener.open = mock.Mock() 75 | resource._transport._opener.open.side_effect = [ 76 | MockResponse( 77 | 200, [{"1st": ""}], {"Link": "; rel=next;"} 78 | ), 79 | MockResponse( 80 | 200, [{"2nd": ""}], {"Link": "; rel=next;"} 81 | ), 82 | MockResponse(200, [{"3rd": ""}]), 83 | ] 84 | resource._transport._rate_limit_func = mock.Mock() 85 | 86 | def pagination_handler(jsonOut, next_json): 87 | jsonOut.extend(next_json) 88 | return jsonOut 89 | 90 | res = resource._make_request( 91 | "GET", "my_path", pagination_handler=pagination_handler 92 | ) 93 | assert res == [{"1st": ""}, {"2nd": ""}, {"3rd": ""}] 94 | 95 | 96 | @pytest.mark.skipif(not have_requests, reason="requests not found") 97 | def test_requests_transport(): 98 | """ 99 | it should get transport from config 100 | it should call rate_limit_func on _make_request 101 | """ 102 | config = Config() 103 | config.createFromAPIKey("AAAAAAAAAAAAAAAAA") 104 | config["transport"] = "requests" 105 | 106 | resource = BaseResource(config) 107 | 108 | assert resource._config == config 109 | assert isinstance(resource._transport, RequestsTransport) 110 | 111 | resource._transport.REQ_MAP["GET"] = mock.Mock() 112 | resource._transport.REQ_MAP["GET"].return_value = MockResponse(200, {}) 113 | resource._transport._rate_limit_func = mock.Mock() 114 | 115 | res = resource._make_request("GET", "my_path") 116 | assert res == {} 117 | 118 | resource._transport._rate_limit_func.assert_called_once_with( 119 | {"by": "customer", "limit": 10, "period": 1, "remaining": 100} 120 | ) 121 | 122 | 123 | @pytest.mark.skipif(not have_requests, reason="requests not found") 124 | def test_requests_transport_pagination(): 125 | config = Config() 126 | config.createFromAPIKey("AAAAAAAAAAAAAAAAA") 127 | config["transport"] = "requests" 128 | config["follow_pagination"] = True 129 | 130 | resource = BaseResource(config) 131 | 132 | resource._transport.REQ_MAP["GET"] = mock.Mock() 133 | resource._transport.REQ_MAP["GET"].side_effect = [ 134 | MockResponse( 135 | 200, [{"1st": ""}], {"Link": "; rel=next;"} 136 | ), 137 | MockResponse( 138 | 200, [{"2nd": ""}], {"Link": "; rel=next;"} 139 | ), 140 | MockResponse(200, [{"3rd": ""}]), 141 | ] 142 | resource._transport._rate_limit_func = mock.Mock() 143 | 144 | def pagination_handler(jsonOut, next_json): 145 | jsonOut.extend(next_json) 146 | return jsonOut 147 | 148 | res = resource._make_request( 149 | "GET", "my_path", pagination_handler=pagination_handler 150 | ) 151 | assert res == [{"1st": ""}, {"2nd": ""}, {"3rd": ""}] 152 | 153 | 154 | def test_rate_limiting_strategies(): 155 | """ 156 | it should set the right func from config 157 | """ 158 | config = Config() 159 | config.createFromAPIKey("AAAAAAAAAAAAAAAAA") 160 | resource = BaseResource(config) 161 | rate_limit_func_name = resource._transport._rate_limit_func.__name__ 162 | assert rate_limit_func_name == "default_rate_limit_func" 163 | 164 | config["rate_limit_strategy"] = "solo" 165 | resource = BaseResource(config) 166 | rate_limit_func_name = resource._transport._rate_limit_func.__name__ 167 | assert rate_limit_func_name == "solo_rate_limit_func" 168 | 169 | config["rate_limit_strategy"] = "concurrent" 170 | config["parallelism"] = 11 171 | resource = BaseResource(config) 172 | rate_limit_func_name = resource._transport._rate_limit_func.__name__ 173 | assert rate_limit_func_name == "concurrent_rate_limit_func" 174 | -------------------------------------------------------------------------------- /tests/unit/test_stats.py: -------------------------------------------------------------------------------- 1 | import ns1.rest.stats 2 | import pytest 3 | 4 | try: # Python 3.3 + 5 | import unittest.mock as mock 6 | except ImportError: 7 | import mock 8 | 9 | 10 | @pytest.fixture 11 | def stats_config(config): 12 | config.loadFromDict( 13 | { 14 | "endpoint": "api.nsone.net", 15 | "default_key": "test1", 16 | "keys": { 17 | "test1": { 18 | "key": "key-1", 19 | "desc": "test key number 1", 20 | } 21 | }, 22 | } 23 | ) 24 | 25 | return config 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "value, expected", 30 | ( 31 | ({}, "stats/qps"), 32 | ( 33 | {"zone": "test.com", "domain": "foo", "type": "A"}, 34 | "stats/qps/test.com/foo/A", 35 | ), 36 | ({"zone": "test.com"}, "stats/qps/test.com"), 37 | ), 38 | ) 39 | def test_qps(stats_config, value, expected): 40 | s = ns1.rest.stats.Stats(stats_config) 41 | s._make_request = mock.MagicMock() 42 | s.qps(**value) 43 | s._make_request.assert_called_once_with( 44 | "GET", expected, callback=None, errback=None 45 | ) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "value, expected", 50 | ( 51 | ({}, "stats/usage"), 52 | ( 53 | {"zone": "test.com", "domain": "foo", "type": "A"}, 54 | "stats/usage/test.com/foo/A", 55 | ), 56 | ({"zone": "test.com"}, "stats/usage/test.com"), 57 | ), 58 | ) 59 | def test_usage(stats_config, value, expected): 60 | s = ns1.rest.stats.Stats(stats_config) 61 | s._make_request = mock.MagicMock() 62 | s.usage(**value) 63 | s._make_request.assert_called_once_with( 64 | "GET", 65 | expected, 66 | callback=None, 67 | errback=None, 68 | pagination_handler=ns1.rest.stats.stats_usage_pagination, 69 | ) 70 | -------------------------------------------------------------------------------- /tests/unit/test_team.py: -------------------------------------------------------------------------------- 1 | import ns1.rest.permissions as permissions 2 | import ns1.rest.team 3 | import pytest 4 | 5 | try: # Python 3.3 + 6 | import unittest.mock as mock 7 | except ImportError: 8 | import mock 9 | 10 | 11 | @pytest.fixture 12 | def team_config(config): 13 | config.loadFromDict( 14 | { 15 | "endpoint": "api.nsone.net", 16 | "default_key": "test1", 17 | "keys": { 18 | "test1": { 19 | "key": "key-1", 20 | "desc": "test key number 1", 21 | } 22 | }, 23 | } 24 | ) 25 | 26 | return config 27 | 28 | 29 | def test_rest_team_list(team_config): 30 | z = ns1.rest.team.Team(team_config) 31 | z._make_request = mock.MagicMock() 32 | z.list() 33 | z._make_request.assert_called_once_with( 34 | "GET", "account/teams", callback=None, errback=None 35 | ) 36 | 37 | 38 | @pytest.mark.parametrize("team_id, url", [("1", "account/teams/1")]) 39 | def test_rest_team_retrieve(team_config, team_id, url): 40 | z = ns1.rest.team.Team(team_config) 41 | z._make_request = mock.MagicMock() 42 | z.retrieve(team_id) 43 | z._make_request.assert_called_once_with( 44 | "GET", url, callback=None, errback=None 45 | ) 46 | 47 | 48 | @pytest.mark.parametrize("name, url", [("test-team", "account/teams")]) 49 | def test_rest_team_create(team_config, name, url): 50 | z = ns1.rest.team.Team(team_config) 51 | z._make_request = mock.MagicMock() 52 | z.create(name) 53 | z._make_request.assert_called_once_with( 54 | "PUT", 55 | url, 56 | callback=None, 57 | errback=None, 58 | body={"name": name, "permissions": permissions._default_perms}, 59 | ) 60 | 61 | 62 | @pytest.mark.parametrize( 63 | "team_id, name, ip_whitelist, permissions, url", 64 | [ 65 | ( 66 | "1", 67 | "test-team", 68 | [{"name": "Test Whitelist", "values": ["1.1.1.1"]}], 69 | {"data": {"push_to_datafeeds": True}}, 70 | "account/teams/1", 71 | ) 72 | ], 73 | ) 74 | def test_rest_team_update( 75 | team_config, team_id, name, ip_whitelist, permissions, url 76 | ): 77 | z = ns1.rest.team.Team(team_config) 78 | z._make_request = mock.MagicMock() 79 | z.update( 80 | team_id, name=name, ip_whitelist=ip_whitelist, permissions=permissions 81 | ) 82 | z._make_request.assert_called_once_with( 83 | "POST", 84 | url, 85 | callback=None, 86 | errback=None, 87 | body={ 88 | "id": team_id, 89 | "name": name, 90 | "ip_whitelist": ip_whitelist, 91 | "permissions": permissions, 92 | }, 93 | ) 94 | 95 | 96 | @pytest.mark.parametrize("team_id, url", [("1", "account/teams/1")]) 97 | def test_rest_team_delete(team_config, team_id, url): 98 | z = ns1.rest.team.Team(team_config) 99 | z._make_request = mock.MagicMock() 100 | z.delete(team_id) 101 | z._make_request.assert_called_once_with( 102 | "DELETE", url, callback=None, errback=None 103 | ) 104 | -------------------------------------------------------------------------------- /tests/unit/test_tsig.py: -------------------------------------------------------------------------------- 1 | import ns1.rest.permissions as permissions 2 | import ns1.rest.tsig 3 | import pytest 4 | 5 | try: # Python 3.3 + 6 | import unittest.mock as mock 7 | except ImportError: 8 | import mock 9 | 10 | 11 | @pytest.fixture 12 | def tsig_config(config): 13 | config.loadFromDict( 14 | { 15 | "endpoint": "api.nsone.net", 16 | "default_key": "test1", 17 | "keys": { 18 | "test1": { 19 | "key": "key-1", 20 | "desc": "test key number 1", 21 | } 22 | }, 23 | } 24 | ) 25 | 26 | return config 27 | 28 | 29 | def test_rest_tsig_list(tsig_config): 30 | t = ns1.rest.tsig.Tsig(tsig_config) 31 | t._make_request = mock.MagicMock() 32 | t.list() 33 | t._make_request.assert_called_once_with( 34 | "GET", "tsig", callback=None, errback=None 35 | ) 36 | 37 | 38 | @pytest.mark.parametrize("key_name, url", [("1", "tsig/1")]) 39 | def test_rest_tsig_retrieve(tsig_config, key_name, url): 40 | t = ns1.rest.tsig.Tsig(tsig_config) 41 | t._make_request = mock.MagicMock() 42 | t.retrieve(key_name) 43 | t._make_request.assert_called_once_with( 44 | "GET", url, callback=None, errback=None 45 | ) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "key_name, algorithm, secret, url", 50 | [("test-tsig", "hmac-sha512", "Ok1qR5I", "tsig/test-tsig")], 51 | ) 52 | def test_rest_tsig_create(tsig_config, key_name, algorithm, secret, url): 53 | t = ns1.rest.tsig.Tsig(tsig_config) 54 | t._make_request = mock.MagicMock() 55 | t.create(key_name, algorithm, secret) 56 | t._make_request.assert_called_once_with( 57 | "PUT", 58 | url, 59 | callback=None, 60 | errback=None, 61 | body={ 62 | "algorithm": algorithm, 63 | "secret": secret, 64 | "permissions": permissions._default_perms, 65 | }, 66 | ) 67 | 68 | 69 | @pytest.mark.parametrize( 70 | "tsgi_name, algorithm, secret, url", 71 | [ 72 | ( 73 | "test-tsig", 74 | "hmac-sha1", 75 | "V3PSkFBROAz", 76 | "tsig/test-tsig", 77 | ) 78 | ], 79 | ) 80 | def test_rest_tsig_update(tsig_config, tsgi_name, algorithm, secret, url): 81 | t = ns1.rest.tsig.Tsig(tsig_config) 82 | t._make_request = mock.MagicMock() 83 | t.update(tsgi_name, algorithm, secret) 84 | t._make_request.assert_called_once_with( 85 | "POST", 86 | url, 87 | callback=None, 88 | errback=None, 89 | body={ 90 | "algorithm": algorithm, 91 | "secret": secret, 92 | }, 93 | ) 94 | 95 | 96 | @pytest.mark.parametrize("tsgi_name, url", [("test-tsig", "tsig/test-tsig")]) 97 | def test_rest_tsig_delete(tsig_config, tsgi_name, url): 98 | t = ns1.rest.tsig.Tsig(tsig_config) 99 | t._make_request = mock.MagicMock() 100 | t.delete(tsgi_name) 101 | t._make_request.assert_called_once_with( 102 | "DELETE", url, callback=None, errback=None 103 | ) 104 | -------------------------------------------------------------------------------- /tests/unit/test_twisted.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | 4 | from copy import deepcopy 5 | from ns1.config import Config 6 | from ns1.rest.resource import BaseResource 7 | from ns1.rest.transport.twisted import have_twisted, TwistedTransport 8 | 9 | try: # Python 3.3 + 10 | import unittest.mock as mock 11 | except ImportError: 12 | import mock 13 | 14 | 15 | class MockHeaders(object): 16 | """ 17 | lookups should return lists 18 | """ 19 | 20 | def __init__(self, lookup=None): 21 | self._lookup = {} if lookup is None else lookup 22 | 23 | def getRawHeaders(self, key, default): 24 | return self._lookup.get(key, default) 25 | 26 | 27 | class MockResponse(object): 28 | """ 29 | pretend we're a twisted.web.client.response 30 | """ 31 | 32 | method = "GET" 33 | absoluteURI = "http://" 34 | 35 | def __init__(self, code=200, phrase="200 OK", headers=None, body=None): 36 | self.body = {} if body is None else body 37 | self.code = code 38 | self.headers = MockHeaders(headers) 39 | self.phrase = phrase 40 | self.request = self 41 | 42 | def deliverBody(self, protocol): 43 | protocol.dataReceived(json.dumps(self.body).encode("utf-8")) 44 | protocol.connectionLost(self) 45 | 46 | def check(self, reason): 47 | return True 48 | 49 | 50 | @pytest.mark.skipif(not have_twisted, reason="twisted not found") 51 | def test_twisted(): 52 | """ 53 | basic test exercising rate-limiting, pagination, and response 54 | flow during a request. 55 | """ 56 | from twisted.internet import defer, reactor 57 | 58 | config = Config() 59 | config.createFromAPIKey("AAAAAAAAAAAAAAAAA") 60 | config["transport"] = "twisted" 61 | config["follow_pagination"] = True 62 | 63 | resource = BaseResource(config) 64 | 65 | assert resource._config == config 66 | assert isinstance(resource._transport, TwistedTransport) 67 | 68 | # setup mocks 69 | 70 | # rate-limiting 71 | resource._transport._rate_limit_func = mock.Mock() 72 | 73 | # first response 74 | d1 = defer.Deferred() 75 | d1.addCallback( 76 | lambda x: MockResponse( 77 | headers={"Link": ["; rel=next;"]}, 78 | body=[{"1st": ""}], 79 | ) 80 | ) 81 | reactor.callLater(0, d1.callback, None) 82 | 83 | # second response 84 | d2 = defer.Deferred() 85 | d2.addCallback(lambda x: MockResponse(body=[{"2nd": ""}])) 86 | reactor.callLater(0, d2.callback, None) 87 | 88 | resource._transport.agent.request = mock.Mock(side_effect=[d1, d2]) 89 | 90 | # pagination 91 | def _pagination_handler(jsonOut, next_json): 92 | # don't mutate args, since assertions operate on references to them 93 | out = deepcopy(jsonOut) 94 | out.extend(next_json) 95 | return out 96 | 97 | pagination_handler = mock.Mock(side_effect=_pagination_handler) 98 | 99 | # callbacks 100 | 101 | def _cb(result): 102 | reactor.stop() 103 | 104 | def _eb(err): 105 | reactor.stop() 106 | 107 | cb = mock.Mock(side_effect=_cb) 108 | eb = mock.Mock(side_effect=_eb) 109 | 110 | @defer.inlineCallbacks 111 | def req(): 112 | result = yield resource._make_request( 113 | "GET", "my_path", pagination_handler=pagination_handler 114 | ) 115 | defer.returnValue(result) 116 | 117 | # call our deferred request and add callbacks 118 | res = req() 119 | res.addCallbacks(cb, eb) 120 | 121 | # RUN THE LOOP 122 | reactor.run() 123 | 124 | # check our work 125 | 126 | # we made two requests 127 | resource._transport.agent.request.call_count == 2 128 | 129 | # we hit our rate-limit function twice, once per request 130 | call_args_list = resource._transport._rate_limit_func.call_args_list 131 | assert len(call_args_list) == 2 132 | for c in call_args_list: 133 | args, kwargs = c 134 | assert args == ( 135 | {"by": "customer", "limit": 10, "period": 1, "remaining": 100}, 136 | ) 137 | assert kwargs == {} 138 | 139 | # we hit our pagination_handler once, to put the two results together 140 | pagination_handler.assert_called_once_with([{"1st": ""}], [{"2nd": ""}]) 141 | 142 | # our final result is correct 143 | cb.assert_called_once_with([{"1st": ""}, {"2nd": ""}]) 144 | 145 | # errback was not called 146 | eb.assert_not_called() 147 | -------------------------------------------------------------------------------- /tests/unit/test_user.py: -------------------------------------------------------------------------------- 1 | import ns1.rest.permissions as permissions 2 | import ns1.rest.user 3 | import pytest 4 | 5 | try: # Python 3.3 + 6 | import unittest.mock as mock 7 | except ImportError: 8 | import mock 9 | 10 | 11 | @pytest.fixture 12 | def user_config(config): 13 | config.loadFromDict( 14 | { 15 | "endpoint": "api.nsone.net", 16 | "default_key": "test1", 17 | "keys": { 18 | "test1": { 19 | "key": "key-1", 20 | "desc": "test key number 1", 21 | } 22 | }, 23 | } 24 | ) 25 | 26 | return config 27 | 28 | 29 | def test_rest_user_list(user_config): 30 | z = ns1.rest.user.User(user_config) 31 | z._make_request = mock.MagicMock() 32 | z.list() 33 | z._make_request.assert_called_once_with( 34 | "GET", "account/users", callback=None, errback=None 35 | ) 36 | 37 | 38 | @pytest.mark.parametrize("username, url", [("1", "account/users/1")]) 39 | def test_rest_user_retrieve(user_config, username, url): 40 | z = ns1.rest.user.User(user_config) 41 | z._make_request = mock.MagicMock() 42 | z.retrieve(username) 43 | z._make_request.assert_called_once_with( 44 | "GET", url, callback=None, errback=None 45 | ) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "name, username, email, url", 50 | [("test-user", "test-username", "test-email@ns1.io", "account/users")], 51 | ) 52 | def test_rest_user_create(user_config, name, username, email, url): 53 | z = ns1.rest.user.User(user_config) 54 | z._make_request = mock.MagicMock() 55 | z.create(name, username, email) 56 | z._make_request.assert_called_once_with( 57 | "PUT", 58 | url, 59 | callback=None, 60 | errback=None, 61 | body={ 62 | "name": name, 63 | "username": username, 64 | "email": email, 65 | "permissions": permissions._default_perms, 66 | }, 67 | ) 68 | 69 | 70 | @pytest.mark.parametrize( 71 | "username, name, ip_whitelist, permissions, url", 72 | [ 73 | ( 74 | "test-username", 75 | "test-user", 76 | ["1.1.1.1", "2.2.2.2"], 77 | {"data": {"push_to_datafeeds": True}}, 78 | "account/users/test-username", 79 | ) 80 | ], 81 | ) 82 | def test_rest_user_update( 83 | user_config, username, name, ip_whitelist, permissions, url 84 | ): 85 | z = ns1.rest.user.User(user_config) 86 | z._make_request = mock.MagicMock() 87 | z.update( 88 | username, name=name, ip_whitelist=ip_whitelist, permissions=permissions 89 | ) 90 | z._make_request.assert_called_once_with( 91 | "POST", 92 | url, 93 | callback=None, 94 | errback=None, 95 | body={ 96 | "username": username, 97 | "name": name, 98 | "ip_whitelist": ip_whitelist, 99 | "permissions": permissions, 100 | }, 101 | ) 102 | 103 | 104 | @pytest.mark.parametrize( 105 | "username, url", [("test-username", "account/users/test-username")] 106 | ) 107 | def test_rest_user_delete(user_config, username, url): 108 | z = ns1.rest.user.User(user_config) 109 | z._make_request = mock.MagicMock() 110 | z.delete(username) 111 | z._make_request.assert_called_once_with( 112 | "DELETE", url, callback=None, errback=None 113 | ) 114 | -------------------------------------------------------------------------------- /tests/unit/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import ns1.rest.views 4 | 5 | try: # Python 3.3 + 6 | import unittest.mock as mock 7 | except ImportError: 8 | import mock 9 | 10 | 11 | @pytest.fixture 12 | def view_config(config): 13 | config.loadFromDict( 14 | { 15 | "endpoint": "api.nsone.net", 16 | "default_key": "test1", 17 | "keys": { 18 | "test1": { 19 | "key": "key-1", 20 | "desc": "test key number 1", 21 | } 22 | }, 23 | } 24 | ) 25 | return config 26 | 27 | 28 | @pytest.mark.parametrize("view_name, url", [("test_view", "views/test_view")]) 29 | def test_rest_view_retrieve(view_config, view_name, url): 30 | z = ns1.rest.views.Views(view_config) 31 | z._make_request = mock.MagicMock() 32 | z.retrieve(view_name) 33 | z._make_request.assert_called_once_with( 34 | "GET", url, callback=None, errback=None 35 | ) 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "view_name, url", 40 | [ 41 | ( 42 | "test_view", 43 | "views/test_view", 44 | ) 45 | ], 46 | ) 47 | def test_rest_view_create(view_config, view_name, url): 48 | z = ns1.rest.views.Views(view_config) 49 | z._make_request = mock.MagicMock() 50 | z.create(view_name=view_name) 51 | z._make_request.assert_called_once_with( 52 | "PUT", 53 | url, 54 | body={ 55 | "view_name": view_name, 56 | }, 57 | callback=None, 58 | errback=None, 59 | ) 60 | 61 | 62 | @pytest.mark.parametrize( 63 | "view_name, url", 64 | [("test_view", "views/test_view")], 65 | ) 66 | def test_rest_view_update(view_config, view_name, url): 67 | z = ns1.rest.views.Views(view_config) 68 | z._make_request = mock.MagicMock() 69 | z.update(view_name=view_name) 70 | z._make_request.assert_called_once_with( 71 | "POST", 72 | url, 73 | callback=None, 74 | errback=None, 75 | body={"view_name": view_name}, 76 | ) 77 | 78 | 79 | @pytest.mark.parametrize("view_name, url", [("test_view", "views/test_view")]) 80 | def test_rest_view_delete(view_config, view_name, url): 81 | z = ns1.rest.views.Views(view_config) 82 | z._make_request = mock.MagicMock() 83 | z.delete(view_name) 84 | z._make_request.assert_called_once_with( 85 | "DELETE", url, callback=None, errback=None 86 | ) 87 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py39,py310,py311,py312 3 | skip_missing_interpreters = True 4 | 5 | [testenv] 6 | deps= 7 | mock 8 | pytest 9 | pytest-cov 10 | 11 | commands= 12 | py.test --ignore=build -v --cov=ns1 --cov-report=term tests 13 | --------------------------------------------------------------------------------