diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0dc31bd9e..e156dc502 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -190,3 +190,81 @@ jobs: GITHUB_ORG_ID: ${{ secrets.DOTNET_SDK_GITHUB_ORG_ID }} GITHUB_REPO_ID: ${{ secrets.DOTNET_SDK_GITHUB_REPO_ID }} SSH_KEY: ${{ secrets.DOTNET_SDK_SSH_KEY }} + + build-and-test-python-sdk: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Specify python version + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Setup git + run: ./scripts/setup_git.sh + env: + GIT_USER_NAME: ${{ secrets.GIT_USER_NAME }} + GIT_USER_EMAIL: ${{ secrets.GIT_USER_EMAIL }} + + - name: Clone the existing SDK + run: ./scripts/clone_sdk.sh + env: + GITHUB_ORG_ID: ${{ secrets.PYTHON_SDK_GITHUB_ORG_ID }} + GITHUB_REPO_ID: ${{ secrets.PYTHON_SDK_GITHUB_REPO_ID }} + SSH_KEY: ${{ secrets.PYTHON_SDK_SSH_KEY }} + SDK_PATH: clients/fga-python-sdk + KNOWN_HOSTS: ${{secrets.KNOWN_HOSTS}} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Run All Tests + run: |- + make test-client-python + + - name: Install SDK to prepare for Snyk + working-directory: clients/fga-python-sdk + run: |- + pip install -r requirements.txt + + - name: Install Snyk CLI + run: |- + mkdir -p ~/.local/bin + export PATH=$PATH:$HOME/.local/bin + curl https://static.snyk.io/cli/latest/snyk-linux -o ~/.local/bin/snyk + chmod +x ~/.local/bin/snyk + + - name: Run Snyk to check for vulnerabilities + working-directory: clients/fga-python-sdk + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: |- + export PATH=$PATH:$HOME/.local/bin + snyk test + + - name: Install FOSSA CLI + run: |- + mkdir -p ~/.local/bin + export PATH=$PATH:$HOME/.local/bin + curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install-latest.sh -b ~/.local/bin | bash + + - name: Run FOSSA scan and upload build data + working-directory: clients/fga-python-sdk + env: + FOSSA_API_KEY: ${{ secrets.FOSSA_API_KEY }} + run: |- + export PATH=$PATH:$HOME/.local/bin + fossa analyze + fossa test + + - name: Check for SDK changes + run: ./scripts/commit_push_changes.sh + env: + SDK_PATH: clients/fga-python-sdk + DRY_RUN: 1 + TAGGING_DISABLE: 1 + GITHUB_ORG_ID: ${{ secrets.PYTHON_SDK_GITHUB_ORG_ID }} + GITHUB_REPO_ID: ${{ secrets.PYTHON_SDK_GITHUB_REPO_ID }} + SSH_KEY: ${{ secrets.PYTHON_SDK_SSH_KEY }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 34fbde3ce..264761b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ docs/openapi/*.json .DS_Store +.dccache clients/**/* diff --git a/Makefile b/Makefile index c8e406684..cafec3af3 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,15 @@ GO_DOCKER_TAG = 1 DOTNET_DOCKER_TAG = 6.0 GOLINT_DOCKER_TAG = v1.48 BUSYBOX_DOCKER_TAG = 1.34.1 +PYTHON_DOCKER_TAG = 3.10 # Other config CONFIG_DIR = ${PWD}/config CLIENTS_OUTPUT_DIR = ${PWD}/clients DOCS_CACHE_DIR = ${PWD}/docs/openapi TMP_DIR = $(shell mktemp -d "$${TMPDIR:-/tmp}/tmp.XXXXX") dotnet_package_version = $(shell cat ./clients/fga-dotnet-sdk/VERSION.txt) +CURRENT_UID := $(shell id -u) +CURRENT_GID := $(shell id -g) dotnet_publish_api_key= @@ -39,10 +42,10 @@ test: test-all-clients build: build-all-clients .PHONY: test-all-clients -test-all-clients: test-client-js test-client-go test-client-dotnet +test-all-clients: test-client-js test-client-go test-client-dotnet test-client-python .PHONY: build-all-clients -build-all-clients: build-client-js build-client-go build-client-dotnet +build-all-clients: build-client-js build-client-go build-client-dotnet build-client-python ### JavaScript .PHONY: tag-client-js @@ -100,6 +103,25 @@ build-client-dotnet: make run-in-docker sdk_language=dotnet image=mcr.microsoft.com/dotnet/sdk:${DOTNET_DOCKER_TAG} command="/bin/sh -c 'dotnet format ./OpenFga.Sdk.sln'" || true make run-in-docker sdk_language=dotnet image=mcr.microsoft.com/dotnet/sdk:${DOTNET_DOCKER_TAG} command="/bin/sh -c 'dotnet format ./OpenFga.Sdk.sln'" +### Python +.PHONY: tag-client-python +tag-client-python: test-client-python + make utils-tag-client sdk_language=python + +.PHONY: build-client-python +build-client-python: + make build-client sdk_language=python tmpdir=${TMP_DIR} library="--library asyncio" + make run-in-docker sdk_language=python image=busybox:${BUSYBOX_DOCKER_TAG} command="/bin/sh -c 'patch -p1 /module/openfga_sdk/api/open_fga_api.py /config/clients/python/patches/open_fga_api.py.patch'" + make run-in-docker sdk_language=python image=busybox:${BUSYBOX_DOCKER_TAG} command="/bin/sh -c 'patch -p1 /module/docs/OpenFgaApi.md /config/clients/python/patches/OpenFgaApi.md.patch'" + make run-in-docker sdk_language=python image=python:${PYTHON_DOCKER_TAG} command="/bin/sh -c 'python -m pip install autopep8; autopep8 --in-place --ignore E402 --recursive openfga_sdk; autopep8 --in-place --recursive test'" + +.PHONY: test-client-python +test-client-python: build-client-python + make run-in-docker sdk_language=python image=python:${PYTHON_DOCKER_TAG} command="/bin/sh -c 'python -m pip install -r test-requirements.txt; python -m unittest test/*'" + make run-in-docker sdk_language=python image=python:${PYTHON_DOCKER_TAG} command="/bin/sh -c 'python -m pip install -r test-requirements.txt; python -m flake8 --ignore F401,E402,E501,W504 openfga_sdk'" + # Need to ignore E402 (import order) to avoid circular dependency + make run-in-docker sdk_language=python image=python:${PYTHON_DOCKER_TAG} command="/bin/sh -c 'python -m pip install -r test-requirements.txt; python -m flake8 --ignore E501 test'" + .PHONY: run-in-docker run-in-docker: docker run --rm \ @@ -136,6 +158,7 @@ build-client: build-openapi # Generate the SDK docker run --rm \ + -u ${CURRENT_UID}:${CURRENT_GID} \ -v ${PWD}/docs:/docs \ -v ${CLIENTS_OUTPUT_DIR}:/clients \ -v ${tmpdir}:/config \ @@ -143,6 +166,7 @@ build-client: build-openapi openapitools/openapi-generator-cli:${OPENAPI_GENERATOR_CLI_DOCKER_TAG} generate \ -i /docs/openapi/openfga.openapiv2.json \ --http-user-agent='openfga-sdk (${sdk_language}) {packageVersion}' \ + ${library} \ -o /clients/fga-${sdk_language}-sdk \ -c /config/config.json \ -g `cat ./config/clients/${sdk_language}/generator.txt` diff --git a/README.md b/README.md index 363d99c1d..f79162466 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,23 @@ This is the main generator responsible for generating the OpenFGA SDKs from the ## Table of Contents -- [About OpenFGA](#about) -- [Resources](#resources) -- [Currently Supported SDKs](#currently-supported-sdks) -- [Getting Started](#getting-started) - - [Requirements](#requirements) - - [Usage](#usage) - - [Adding a New SDK](#adding-a-new-sdk) - - [Uploading the SDK](#uploading-the-sdk) - - [Publishing the SDK](#publishingopen-sourcing-the-sdk) -- [Contributing](#contributing) -- [License](#license) +- [OpenFGA Client SDK Generator](#openfga-client-sdk-generator) + - [Table of Contents](#table-of-contents) + - [About](#about) + - [Resources](#resources) + - [Currently Supported SDKs](#currently-supported-sdks) + - [Getting Started](#getting-started) + - [Requirements](#requirements) + - [Usage](#usage) + - [Adding a new SDK](#adding-a-new-sdk) + - [Using the setup script](#using-the-setup-script) + - [Manually](#manually) + - [Uploading the SDK](#uploading-the-sdk) + - [Publishing/Open Sourcing the SDK](#publishingopen-sourcing-the-sdk) + - [GitHub Action Secrets](#github-action-secrets) + - [Contributing](#contributing) + - [Author](#author) + - [License](#license) ## About @@ -42,6 +48,7 @@ OpenFGA is designed to make it easy for application builders to model their perm | Javascript | [openfga/js-sdk](https://github.com/openfga/js-sdk) | [@openfga/sdk](https://www.npmjs.com/package/@auth0/fga) on npm | | Go | [openfga/go-sdk](https://github.com/openfga/go-sdk) | - | | .NET | [openfga/dotnet-sdk](https://github.com/openfga/dotnet-sdk) | [OpenFga.Sdk](https://www.nuget.org/packages/OpenFga.Sdk) on nuget | +| PYTHON | [openfga/python](https://github.com/openfga/python-sdk) | [openfga-sdk](https://pypi.org/project/openfga-sdk) on PyPI | ## Getting Started @@ -67,6 +74,7 @@ git clone git@github.com:openfga/sdk-generator.git git clone git@github.com:openfga/go-sdk.git clients/fga-go-sdk git clone git@github.com:openfga/js-sdk.git clients/fga-js-sdk git clone git@github.com:openfga/dotnet-sdk.git clients/fga-dotnet-sdk +git clone git@github.com:openfga/python-sdk.git clients/fga-python-sdk ``` 3. Build and test the client sdks @@ -163,6 +171,9 @@ Note: Semgrep will be automatically enabled - there is nothing you need to do fo | `DOTNET_SDK_GITHUB_ORG_ID` | The GitHub org for the SDK | | `DOTNET_SDK_GITHUB_REPO_ID` | The GitHub repo id for the SDK | | `DOTNET_SDK_SSH_KEY` | The SSH private deploy key for the SDK | +| `PYTHON_SDK_GITHUB_ORG_ID` | The GitHub org for the SDK | +| `PYTHON_SDK_GITHUB_REPO_ID` | The GitHub repo id for the SDK | +| `PYTHON_SDK_SSH_KEY` | The SSH private deploy key for the SDK | The following keys are also available but should be considered deprecated. Automated release is disabled due to the complexity of generating relevant commit messages when using a generator. @@ -186,7 +197,7 @@ In addition, we ask that the SDKs: * be generated from the [openapiv2 swagger document](https://github.com/openfga/api/blob/main/docs/openapiv2/apidocs.swagger.json) using the sdk-generator. -* have roughly the same consistent interface for configuration, such as [JS](https://github.com/openfga/js-sdk), [GoLang](https://github.com/openfga/go-sdk) and [.NET](https://github.com/openfga/dotnet-sdk) SDKs. +* have roughly the same consistent interface for configuration, such as [JS](https://github.com/openfga/js-sdk), [GoLang](https://github.com/openfga/go-sdk), [.NET](https://github.com/openfga/dotnet-sdk) and [Python](https://github.com/openfga/python-sdk) SDKs. * support the same features with other existing SDKs. diff --git a/config/clients/python/.openapi-generator-ignore b/config/clients/python/.openapi-generator-ignore new file mode 100644 index 000000000..fd6f1f9c4 --- /dev/null +++ b/config/clients/python/.openapi-generator-ignore @@ -0,0 +1,8 @@ +git_push.sh +test/* +!test/__init__.py +!test/test_open_fga_api.py +!test/test_credentials.py +.gitlab-ci.yml +.travis.yml +tox.ini diff --git a/config/clients/python/CHANGELOG.md.mustache b/config/clients/python/CHANGELOG.md.mustache new file mode 100644 index 000000000..a493b386f --- /dev/null +++ b/config/clients/python/CHANGELOG.md.mustache @@ -0,0 +1,13 @@ +# Changelog + +## v0.0.1 + +### [0.0.1](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/releases/tag/v0.0.1) (2022-08-31) + +Initial OpenFGA Python SDK release +- Support for [OpenFGA](https://github.com/openfga/openfga) API + - CRUD stores + - Create, read & list authorization models + - Writing and Reading Tuples + - Checking authorization + - Using Expand to understand why access was granted diff --git a/config/clients/python/config.overrides.json b/config/clients/python/config.overrides.json new file mode 100644 index 000000000..f76625eb1 --- /dev/null +++ b/config/clients/python/config.overrides.json @@ -0,0 +1,23 @@ +{ + "gitRepoId": "python-sdk", + "packageName": "openfga_sdk", + "packageVersion": "0.0.1", + "packageDescription": "Python SDK for OpenFGA", + "packageDetailedDescription": "This is an autogenerated python SDK for OpenFGA. It provides a wrapper around the [OpenFGA API definition](https://openfga.dev/api).", + "infoName": "OpenFGA", + "infoEmail": "community@openfga.dev", + "files": { + ".github/workflows/main.yaml": { + }, + ".snyk": { + }, + "credentials.mustache": { + "destinationFilename": "openfga_sdk/credentials.py", + "templateType": "SupportingFiles" + }, + "credentials_test.mustache": { + "destinationFilename": "test/test_credentials.py", + "templateType": "SupportingFiles" + } + } +} diff --git a/config/clients/python/generator.txt b/config/clients/python/generator.txt new file mode 100644 index 000000000..d5c5bbf47 --- /dev/null +++ b/config/clients/python/generator.txt @@ -0,0 +1 @@ +python-legacy diff --git a/config/clients/python/patches/OpenFgaApi.md.patch b/config/clients/python/patches/OpenFgaApi.md.patch new file mode 100644 index 000000000..29504e7e3 --- /dev/null +++ b/config/clients/python/patches/OpenFgaApi.md.patch @@ -0,0 +1,48 @@ +--- clients/fga-python-sdk/docs/OpenFgaApi.md 2022-09-13 15:41:02.000000000 -0400 ++++ OpenFgaApi.md 2022-09-13 15:39:58.000000000 -0400 +@@ -103,7 +103,7 @@ + [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + # **create_store** +-> CreateStoreResponse create_store() ++> CreateStoreResponse create_store(body) + + Create a store + +@@ -125,7 +125,6 @@ + configuration = openfga_sdk.Configuration( + scheme = "https", + api_host = "api.fga.example", +- store_id = 'YOUR_STORE_ID', + ) + + +@@ -134,7 +133,6 @@ + configuration = openfga_sdk.Configuration( + scheme = "https", + api_host = "api.fga.example", +- store_id = 'YOUR_STORE_ID', + credentials = credentials + ) + +@@ -142,10 +140,11 @@ + async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = openfga_sdk.OpenFgaApi(api_client) ++ body = openfga_sdk.CreateStoreRequest() # CreateStoreRequest | + + try: + # Create a store +- api_response = await api_instance.api_instance.create_store() ++ api_response = await api_instance.api_instance.create_store(body) + pprint(api_response) + except ApiException as e: + print("Exception when calling OpenFgaApi->create_store: %s\n" % e) +@@ -157,6 +156,7 @@ + + Name | Type | Description | Notes + ------------- | ------------- | ------------- | ------------- ++ **body** | [**CreateStoreRequest**](CreateStoreRequest.md)| | + + ### Return type + diff --git a/config/clients/python/patches/open_fga_api.py.patch b/config/clients/python/patches/open_fga_api.py.patch new file mode 100644 index 000000000..489d98046 --- /dev/null +++ b/config/clients/python/patches/open_fga_api.py.patch @@ -0,0 +1,68 @@ +--- clients/fga-python-sdk/openfga_sdk/api/open_fga_api.py 2022-09-13 14:15:46.000000000 -0400 ++++ open_fga_api.py 2022-09-13 14:14:01.000000000 -0400 +@@ -193,13 +193,15 @@ + collection_formats=collection_formats, + _request_auth=local_var_params.get('_request_auth'))) + +- async def create_store(self, **kwargs): # noqa: E501 ++ async def create_store(self, body, **kwargs): # noqa: E501 + """Create a store # noqa: E501 + + Create a unique OpenFGA store which will be used to store authorization models and relationship tuples. # noqa: E501 + +- >>> thread = await api.create_store() ++ >>> thread = await api.create_store(body) + ++ :param body: (required) ++ :type body: CreateStoreRequest + :param async_req: Whether to execute the request asynchronously. + :type async_req: bool, optional + :param _preload_content: if False, the urllib3.HTTPResponse object will +@@ -216,15 +218,17 @@ + :rtype: CreateStoreResponse + """ + kwargs['_return_http_data_only'] = True +- return await(self.create_store_with_http_info(**kwargs)) # noqa: E501 ++ return await(self.create_store_with_http_info(body, **kwargs)) # noqa: E501 + +- async def create_store_with_http_info(self, **kwargs): # noqa: E501 ++ async def create_store_with_http_info(self, body, **kwargs): # noqa: E501 + """Create a store # noqa: E501 + + Create a unique OpenFGA store which will be used to store authorization models and relationship tuples. # noqa: E501 + +- >>> thread = api.create_store_with_http_info() ++ >>> thread = api.create_store_with_http_info(body) + ++ :param body: (required) ++ :type body: CreateStoreRequest + :param async_req: Whether to execute the request asynchronously. + :type async_req: bool, optional + :param _return_http_data_only: response data without head status code +@@ -253,6 +257,8 @@ + + all_params = [ + ++ 'body' ++ + ] + all_params.extend( + [ +@@ -312,7 +318,7 @@ + } + + return await(self.api_client.call_api( +- '/stores'.replace('{store_id}', store_id), 'POST', ++ '/stores', 'POST', + path_params, + query_params, + header_params, +@@ -998,7 +1004,7 @@ + } + + return await(self.api_client.call_api( +- '/stores'.replace('{store_id}', store_id), 'GET', ++ '/stores', 'GET', + path_params, + query_params, + header_params, diff --git a/config/clients/python/template-source.json b/config/clients/python/template-source.json new file mode 100644 index 000000000..629820a68 --- /dev/null +++ b/config/clients/python/template-source.json @@ -0,0 +1,7 @@ +{ + "repo": "https://github.com/OpenAPITools/openapi-generator", + "branch": "master", + "commit": "fea42b547ed61dcfdfc479cab6f68a601ec5adde", + "url": "https://github.com/OpenAPITools/openapi-generator/tree/master/modules/openapi-generator/src/main/resources/python-legacy", + "docs": "https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/python-legacy.md" +} diff --git a/config/clients/python/template/.github/workflows/main.yaml b/config/clients/python/template/.github/workflows/main.yaml new file mode 100644 index 000000000..c2e3d0955 --- /dev/null +++ b/config/clients/python/template/.github/workflows/main.yaml @@ -0,0 +1,105 @@ +name: Build, Test and Publish + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + fossa: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: 3.x + cache: 'pip' + cache-dependency-path: | + **/setup.cfg + **/requirements*.txt + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + pip install setuptools wheel twine + - name: Build + run: python setup.py sdist bdist_wheel + - name: Run FOSSA scan and upload build data + uses: fossas/fossa-action@main + with: + api-key: ${{ secrets.FOSSA_API_KEY }} + branch: ${{ github.ref_name }} + - name: Run FOSSA tests + uses: fossas/fossa-action@main + with: + api-key: ${{ secrets.FOSSA_API_KEY }} + run-tests: true + + snyk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.x + cache: 'pip' + cache-dependency-path: | + **/setup.cfg + **/requirements*.txt + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + pip install setuptools wheel twine + - name: Build + run: python setup.py sdist bdist_wheel + - name: Run Snyk to check for vulnerabilities + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: | + npm install -g snyk + snyk auth $SNYK_TOKEN + snyk monitor + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: | + **/setup.cfg + **/requirements*.txt + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r test-requirements.txt + - name: Test + run: python -m unittest test/* + - name: Flake8 on SDK + run: python -m flake8 --ignore F401,E402,E501,W504 openfga_sdk + - name: Flake8 on unit test + run: python -m flake8 --ignore E501 test + + create-release: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + needs: [test, fossa, snyk] + + steps: + - uses: actions/checkout@v3 + + - uses: Roang-zero1/github-create-release-action@5cf058ddffa6fa04e5cda07c98570c757dc4a0e1 + with: + version_regex: ^v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+ + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/config/clients/python/template/.snyk b/config/clients/python/template/.snyk new file mode 100644 index 000000000..9e7a03c5c --- /dev/null +++ b/config/clients/python/template/.snyk @@ -0,0 +1,2 @@ +language-settings: +python: '3.10.6' \ No newline at end of file diff --git a/config/clients/python/template/README_api_endpoints.mustache b/config/clients/python/template/README_api_endpoints.mustache new file mode 100644 index 000000000..1b23e97fb --- /dev/null +++ b/config/clients/python/template/README_api_endpoints.mustache @@ -0,0 +1,4 @@ +Class | Method | HTTP request | Description +------------ | ------------- | ------------- | ------------- +{{#apiInfo}}{{#apis}}{{#operations}}{{#operation}}*{{classname}}* | [**{{operationId}}**]({{apiDocPath}}{{classname}}.md#{{operationIdLowerCase}}) | **{{httpMethod}}** {{path}} | {{#summary}}{{summary}}{{/summary}} +{{/operation}}{{/operations}}{{/apis}}{{/apiInfo}} \ No newline at end of file diff --git a/config/clients/python/template/README_calling_api.mustache b/config/clients/python/template/README_calling_api.mustache new file mode 100644 index 000000000..2d7026450 --- /dev/null +++ b/config/clients/python/template/README_calling_api.mustache @@ -0,0 +1,437 @@ +#### List Stores + +[API Documentation]({{apiDocsUrl}}/docs/api#/Stores/ListStores) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), +) + +# Get all stores +async def list_stores(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + + response = await api_instance.list_stores() + # response = ListStoreResponse(...) + # response.stores = [Store({"id": "01FQH7V8BEG3GPQW93KTRFR8JB", "name": "FGA Demo Store", "created_at": "2022-01-01T00:00:00.000Z", "updated_at": "2022-01-01T00:00:00.000Z"})] + await api_client.close() +``` + +#### Create Store + +[API Documentation]({{apiDocsUrl}}/docs/api#/Stores/CreateStore) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), +) + +# Create a store +async def create_store(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + + body = CreateStoreRequest( + name = "FGA Demo Store", + ) + response = await api_instance.create_store(body) + # response.id = "01FQH7V8BEG3GPQW93KTRFR8JB" + await api_client.close() +``` + + +#### Get Store + +[API Documentation]({{apiDocsUrl}}/docs/api#/Stores/GetStore) + +> Requires a client initialized with a storeId + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# Get a store +async def get_store(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + + response = await api_instance.get_store() + # response = Store({"id": "01FQH7V8BEG3GPQW93KTRFR8JB", "name": "FGA Demo Store", "created_at": "2022-01-01T00:00:00.000Z", "updated_at": "2022-01-01T00:00:00.000Z"}) + await api_client.close() +``` + + +#### Delete Store + +[API Documentation]({{apiDocsUrl}}/docs/api#/Stores/DeleteStore) + +> Requires a client initialized with a storeId + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# Delete a store +async def delete_store(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + + await api_instance.delete_store() + await api_client.close() +``` + +#### Write Authorization Model + +[API Documentation]({{apiDocsUrl}}#/Authorization%20Models/WriteAuthorizationModel) + +> Requires a client initialized with a storeId + +> Note: To learn how to build your authorization model, check the Docs at {{docsUrl}}. + +> Learn more about [the {{appName}} configuration language]({{docsUrl}}/configuration-language). + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# Create a new authorization model +async def write_authorization_model(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + type_definitions = WriteAuthorizationModelRequest( + type_definitions=[ + TypeDefinition( + type="document", + relations=dict( + writer=Userset( + this=dict(), + ), + viewer=Userset( + union=Usersets( + child=[ + Userset(this=dict()), + Userset(computed_userset=ObjectRelation( + object="", + relation="writer", + )), + ], + ), + ), + ) + ), + ], + ) + + response = await api_instance.write_authorization_model(type_definitions) + # response.id = "1uHxCSuTP0VKPYSnkq1pbb1jeZw" + await api_client.close() +``` + + +#### Read a Single Authorization Model + +[API Documentation]({{apiDocsUrl}}#/Authorization%20Models/ReadAuthorizationModel) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# Return a particular version of an authorization model +async def read_authorization_id(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + id = "1uHxCSuTP0VKPYSnkq1pbb1jeZw" # Assuming `1uHxCSuTP0VKPYSnkq1pbb1jeZw` is an id of an existing model + + response = await api_instance.read_authorization_model(id) + # response.authorization_model = AuthorizationModel(id='1uHxCSuTP0VKPYSnkq1pbb1jeZw', type_definitions=type_definitions[...]) + await api_client.close() +``` + +#### Read Authorization Model IDs + +[API Documentation]({{apiDocsUrl}}#/Authorization%20Models/ReadAuthorizationModels) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# Return all the authorization models for a particular store +async def read_authorization_models(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + + response = await api_instance.read_authorization_models() + # response.authorization_models = [AuthorizationModel(id='1uHxCSuTP0VKPYSnkq1pbb1jeZw', type_definitions=type_definitions[...], AuthorizationModel(id='GtQpMohWezFmIbyXxVEocOCxxgq', type_definitions=type_definitions[...])] + await api_client.close() +``` + + +#### Check + +[API Documentation]({{apiDocsUrl}}#/Relationship%20Queries/Check) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# Check whether a user is authorized to access an object +async def check(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + body = CheckRequest( + tuple_key=TupleKey( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="admin", + object="workspace:675bcac4-ad38-4fb1-a19a-94a5648c91d6", + ), + ) + + response = await api_instance.check(body) + # response.allowed = True + await api_client.close() +``` + + +#### Write Tuples + +[API Documentation]({{apiDocsUrl}}#/Relationship%20Tuples/Write) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# Add tuples from the store +async def write(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + body = WriteRequest( + writes=TupleKeys( + tuple_keys=[ + TupleKey( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="admin", + object="workspace:675bcac4-ad38-4fb1-a19a-94a5648c91d6", + ), + ], + ), + ) + + response = await api_instance.write(body) + await api_client.close() +``` + +#### Delete Tuples + +[API Documentation]({{apiDocsUrl}}#/Relationship%20Tuples/Write) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# Delete tuples from the store +async def delete(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + body = WriteRequest( + deletes=TupleKeys( + tuple_keys=[ + TupleKey( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="reader", + object="workspace:675bcac4-ad38-4fb1-a19a-94a5648c91d6", + ), + ], + ), + ) + + response = await api_instance.write(body) + await api_client.close() +``` + +#### Expand + +[API Documentation]({{apiDocsUrl}}#/Relationship%20Queries/Expand) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason about and debug a certain relationship +async def expand(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + body = ExpandRequest( + tuple_key=TupleKey( + relation="admin", + object="workspace:675bcac4-ad38-4fb1-a19a-94a5648c91d6", + ), + ) + + response = await api_instance.expand(body) + # response = ExpandResponse({"tree": UsersetTree({"root": Node({"name": "workspace:675bcac4-ad38-4fb1-a19a-94a5648c91d6#admin", "leaf": Leaf({"users": Users({"users": ["user:81684243-9356-4421-8fbf-a4f8d36aa31b", "user:f52a4f7a-054d-47ff-bb6e-3ac81269988f"]})})})})}) + await api_client.close() +``` + +#### Read Changes + +[API Documentation]({{apiDocsUrl}}#/Relationship%20Tuples/Read) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +async def read(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + + # Find if a relationship tuple stating that a certain user is an admin on a certain workspace + body = ReadRequest( + tuple_key=TupleKey( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="admin", + object="workspace:675bcac4-ad38-4fb1-a19a-94a5648c91d6", + ), + ) + + # Find all relationship tuples where a certain user has a relationship as any relation to a certain workspace + body = ReadRequest( + tuple_key=TupleKey( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + object="workspace:675bcac4-ad38-4fb1-a19a-94a5648c91d6", + ), + ) + + # Find all relationship tuples where a certain user is an admin on any workspace + body = ReadRequest( + tuple_key=TupleKey( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="admin", + object="workspace:", + ), + ) + + # Find all relationship tuples where any user has a relationship as any relation with a particular workspace + body = ReadRequest( + tuple_key=TupleKey( + object="workspace:675bcac4-ad38-4fb1-a19a-94a5648c91d6", + ), + ) + + response = await api_instance.read(body) + # response = ReadResponse({"tuples": [Tuple({"key": TupleKey({"user":"...","relation":"...","object":"..."}), "timestamp": datetime.fromisoformat("...") })]}) + await api_client.close() +``` + +#### Read Changes (Watch) + +[API Documentation]({{apiDocsUrl}}#/Relationship%20Tuples/ReadChanges) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# Return a list of all the tuple changes +async def read_changes(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + + type = "workspace" + page_size = 25 + continuation_token = "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" + + response = await api_instance.read_changes(type=type, page_size=page_size, continuation_token=continuation_token) + # response.continuation_token = ... + # response.changes = [TupleChange(tuple_key=TupleKey(object="...",relation="...",user="..."),operation=TupleOperation("TUPLE_OPERATION_WRITE"),timestamp=datetime.fromisoformat("..."))] + await api_client.close() +``` + +#### List Objects + +[API Documentation]({{apiDocsUrl}}#/Relationship%20Queries/ListObjects) + +```python +configuration = openfga_sdk.Configuration( + api_scheme = os.environ.get({{appUpperCaseName}}_API_SCHEME), + api_host = os.environ.get({{appUpperCaseName}}_API_HOST), + store_id = os.environ.get({{appUpperCaseName}}_STORE_ID), +) + +# ListObjects lists all of the object ids for objects of the provided type that the given user has a specific relation with. +async def list_objects(): + # Create an instance of the API class + api_client = openfga_sdk.ApiClient(configuration) + api_instance = open_fga_api.OpenFgaApi(api_client) + body = ListObjectsRequest( + authorization_model_id="01GAHCE4YVKPQEKZQHT2R89MQV", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="can_read", + type="document", + contextual_tuples=ContextualTupleKeys( # optional + tuple_keys=[ + TupleKey( + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation="editor", + object="folder:product", + ), + TupleKey( + user="folder:product", + relation="parent", + object="document:roadmap", + ), + ], + ), + ) + + response = await api_instance.list_objects(body) + # response.object_ids = ["roadmap"] + await api_client.close() +``` diff --git a/config/clients/python/template/README_common.mustache b/config/clients/python/template/README_common.mustache new file mode 100644 index 000000000..62822daa8 --- /dev/null +++ b/config/clients/python/template/README_common.mustache @@ -0,0 +1,9 @@ +{{>README_calling_api}} + +{{>README_api_endpoints}} + +{{>README_models}} +## Author + +{{#apiInfo}}{{#apis}}{{#-last}}{{infoEmail}} +{{/-last}}{{/apis}}{{/apiInfo}} diff --git a/config/clients/python/template/README_initializing.mustache b/config/clients/python/template/README_initializing.mustache new file mode 100644 index 000000000..cb23496f2 --- /dev/null +++ b/config/clients/python/template/README_initializing.mustache @@ -0,0 +1,89 @@ +#### No Authentication #### + +##### Without Store ID ##### + +To configure the SDK API client without store ID, we can initialize the api client by specifying the scheme and host. + +```python +import {{packageName}} +from {{packageName}}.api import open_fga_api + +configuration = {{packageName}}.Configuration( + api_scheme = 'https', + api_host = 'api.{{sampleApiDomain}}' +) + +async def api_setup(): + # Enter a context with an instance of the API client + async with {{packageName}}.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + +``` + +##### With Store ID ##### + +To configure the SDK API client store ID, we can initialize the api client by specifying the scheme, host and store_id. + +```python +import {{packageName}} +from {{packageName}}.api import open_fga_api + +configuration = openfga_sdk.Configuration( + api_scheme = 'https', + api_host = 'api.{{sampleApiDomain}}', + store_id = 'YOUR_STORE_ID' +) + +async def api_setup(): + # Enter a context with an instance of the API client + async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + +``` + +Another possibility is to use the existing configuration and add store id in its configuration + +```python +import {{packageName}} +from {{packageName}}.api import open_fga_api + +configuration = openfga_sdk.Configuration( + api_scheme = 'https', + api_host = 'api.{{sampleApiDomain}}' +) + +async def api_setup(): + configuration.store_id = 'YOUR_STORE_ID' + + # Enter a context with an instance of the API client + async with {{packageName}}.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + +``` + +#### Authentication via API Token #### + +To configure the SDK API client with authentication via API TOKEN, we can initialize the api client by specifying the scheme, host and credentials. + +```python +import {{packageName}} +from {{packageName}}.api import open_fga_api +from {{packageName}}.credentials import Credentials, CredentialConfiguration + +credentials = Credentials(method='api_token', configuration=CredentialConfiguration(api_token='TOKEN1')) +configuration = {{packageName}}.Configuration( + api_scheme = 'https', + api_host = 'api.{{sampleApiDomain}}', + credentials = credentials +) + +async def api_setup(): + # Enter a context with an instance of the API client + async with {{packageName}}.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + +``` diff --git a/config/clients/python/template/README_installation.mustache b/config/clients/python/template/README_installation.mustache new file mode 100644 index 000000000..67f58788d --- /dev/null +++ b/config/clients/python/template/README_installation.mustache @@ -0,0 +1,43 @@ +### pip install + +#### PyPI + +The {{packageName}} is available to be downloaded via PyPI, you can install directly using: + +```sh +pip3 install {{packageName}} +``` +(you may need to run `pip` with root permission: `sudo pip3 install {{packageName}}`) + +Then import the package: +```python +import {{{packageName}}} +``` + +#### GitHub + +The {{packageName}} is also hosted in GitHub, you can install directly using: + +```sh +pip3 install https://{{gitHost}}/{{{gitUserId}}}/{{{gitRepoId}}}.git +``` +(you may need to run `pip` with root permission: `sudo pip3 install https://{{gitHost}}/{{{gitUserId}}}/{{{gitRepoId}}}.git`) + +Then import the package: +```python +import {{{packageName}}} +``` + +### Setuptools + +Install via [Setuptools](https://pypi.python.org/pypi/setuptools). + +```sh +python setup.py install --user +``` +(or `sudo python setup.py install` to install the package for all users) + +Then import the package: +```python +import {{{packageName}}} +``` diff --git a/config/clients/python/template/README_license_disclaimer.mustache b/config/clients/python/template/README_license_disclaimer.mustache new file mode 100644 index 000000000..ac3a2c089 --- /dev/null +++ b/config/clients/python/template/README_license_disclaimer.mustache @@ -0,0 +1 @@ +The code in this repo was auto generated by [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator) from a template based on the [python legacy template](https://github.com/OpenAPITools/openapi-generator/tree/master/modules/openapi-generator/src/main/resources/python-legacy), licensed under the [Apache License 2.0](https://github.com/OpenAPITools/openapi-generator/blob/master/LICENSE). \ No newline at end of file diff --git a/config/clients/python/template/README_models.mustache b/config/clients/python/template/README_models.mustache new file mode 100644 index 000000000..6f1030fa7 --- /dev/null +++ b/config/clients/python/template/README_models.mustache @@ -0,0 +1,4 @@ +## Documentation For Models + +{{#models}}{{#model}} - [{{{classname}}}]({{modelDocPath}}{{{classname}}}.md) +{{/model}}{{/models}} diff --git a/config/clients/python/template/__init__.mustache b/config/clients/python/template/__init__.mustache new file mode 100644 index 000000000..e69de29bb diff --git a/config/clients/python/template/__init__api.mustache b/config/clients/python/template/__init__api.mustache new file mode 100644 index 000000000..488b8abbe --- /dev/null +++ b/config/clients/python/template/__init__api.mustache @@ -0,0 +1,5 @@ +# flake8: noqa + +# import apis into api package +{{#apiInfo}}{{#apis}}from {{apiPackage}}.{{classFilename}} import {{classname}} +{{/apis}}{{/apiInfo}} \ No newline at end of file diff --git a/config/clients/python/template/__init__model.mustache b/config/clients/python/template/__init__model.mustache new file mode 100644 index 000000000..8984a5484 --- /dev/null +++ b/config/clients/python/template/__init__model.mustache @@ -0,0 +1,8 @@ +# coding: utf-8 + +# flake8: noqa +{{>partial_header}} + +# import models into model package +{{#models}}{{#model}}from {{modelPackage}}.{{classFilename}} import {{classname}}{{/model}} +{{/models}} diff --git a/config/clients/python/template/__init__package.mustache b/config/clients/python/template/__init__package.mustache new file mode 100644 index 000000000..1996d9e35 --- /dev/null +++ b/config/clients/python/template/__init__package.mustache @@ -0,0 +1,27 @@ +# coding: utf-8 + +# flake8: noqa + +{{>partial_header}} + +__version__ = "{{packageVersion}}" + +# import apis into sdk package +{{#apiInfo}}{{#apis}}from {{apiPackage}}.{{classFilename}} import {{classname}} +{{/apis}}{{/apiInfo}} +# import ApiClient +from {{packageName}}.api_client import ApiClient +from {{packageName}}.configuration import Configuration +from {{packageName}}.exceptions import OpenApiException +from {{packageName}}.exceptions import ApiTypeError +from {{packageName}}.exceptions import ApiValueError +from {{packageName}}.exceptions import ApiKeyError +from {{packageName}}.exceptions import ApiAttributeError +from {{packageName}}.exceptions import ApiException +# import models into sdk package +{{#models}}{{#model}}from {{modelPackage}}.{{classFilename}} import {{classname}} +{{/model}}{{/models}} +{{#recursionLimit}} + +__import__('sys').setrecursionlimit({{{.}}}) +{{/recursionLimit}} diff --git a/config/clients/python/template/api.mustache b/config/clients/python/template/api.mustache new file mode 100644 index 000000000..0bc3e3997 --- /dev/null +++ b/config/clients/python/template/api.mustache @@ -0,0 +1,328 @@ +# coding: utf-8 + +{{>partial_header}} + + +import re # noqa: F401 + +# python 2 and python 3 compatibility library +import six + +from {{packageName}}.api_client import ApiClient +from {{packageName}}.exceptions import ( # noqa: F401 + ApiTypeError, + ApiValueError +) + + +{{#operations}} +class {{classname}}(object): + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None): + if api_client is None: + api_client = ApiClient() + self.api_client = api_client + + {{#asyncio}} + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() + + async def close(self): + await self.api_client.close() + {{/asyncio}} + +{{#operation}} + + {{#asyncio}}async {{/asyncio}}def {{operationId}}(self, {{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{^-first}}{{paramName}}, {{/-first}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs): # noqa: E501 + """{{{summary}}}{{^summary}}{{operationId}}{{/summary}} # noqa: E501 + +{{#notes}} + {{{.}}} # noqa: E501 +{{/notes}} + +{{#sortParamsByRequiredFlag}} + >>> thread = await api.{{operationId}}({{#allParams}}{{#required}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/required}}{{/allParams}}) +{{/sortParamsByRequiredFlag}} +{{^sortParamsByRequiredFlag}} + >>> thread = await api.{{operationId}}({{#allParams}}{{#required}}{{^-first}}{{paramName}}={{paramName}}_value{{^-last}}, {{/-last}} {{/-first}}{{/required}}{{/allParams}}) +{{/sortParamsByRequiredFlag}} + +{{#requiredParams}} +{{^-first}} + :param {{paramName}}:{{#description}} {{{.}}}{{/description}} (required) + :type {{paramName}}: {{dataType}} +{{/-first}} +{{/requiredParams}} +{{#optionalParams}} + :param {{paramName}}:{{#description}} {{{.}}}{{/description}}(optional) + :type {{paramName}}: {{dataType}}, optional +{{/optionalParams}} + :param async_req: Whether to execute the request asynchronously. + :type async_req: bool, optional + :param _preload_content: if False, the urllib3.HTTPResponse object will + be returned without reading/decoding response + data. Default is True. + :type _preload_content: bool, optional + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :return: Returns the result object. + If the method is called asynchronously, + returns the request thread. + :rtype: {{returnType}}{{^returnType}}None{{/returnType}} + """ + kwargs['_return_http_data_only'] = True + return {{#asyncio}}await({{/asyncio}}self.{{operationId}}_with_http_info({{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{^-first}}{{paramName}}, {{/-first}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs){{#asyncio}}){{/asyncio}} # noqa: E501 + + {{#asyncio}}async {{/asyncio}}def {{operationId}}_with_http_info(self, {{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{^-first}}{{paramName}}, {{/-first}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs): # noqa: E501 + """{{{summary}}}{{^summary}}{{operationId}}{{/summary}} # noqa: E501 + +{{#notes}} + {{{.}}} # noqa: E501 +{{/notes}} + +{{#sortParamsByRequiredFlag}} + >>> thread = api.{{operationId}}_with_http_info({{#allParams}}{{#required}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/required}}{{/allParams}}) +{{/sortParamsByRequiredFlag}} +{{^sortParamsByRequiredFlag}} + >>> thread = api.{{operationId}}_with_http_info({{#allParams}}{{#required}}{{^-first}}{{paramName}}={{paramName}}_value{{^-last}}, {{/-last}} {{/-first}}{{/required}}{{/allParams}}) +{{/sortParamsByRequiredFlag}} + +{{#requiredParams}} +{{^-first}} + :param {{paramName}}:{{#description}} {{{.}}}{{/description}} (required) + :type {{paramName}}: {{dataType}} +{{/-first}} +{{/requiredParams}} +{{#optionalParams}} + :param {{paramName}}:{{#description}} {{{.}}}{{/description}}(optional) + :type {{paramName}}: {{dataType}}, optional +{{/optionalParams}} + :param async_req: Whether to execute the request asynchronously. + :type async_req: bool, optional + :param _return_http_data_only: response data without head status code + and headers + :type _return_http_data_only: bool, optional + :param _preload_content: if False, the urllib3.HTTPResponse object will + be returned without reading/decoding response + data. Default is True. + :type _preload_content: bool, optional + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the authentication + in the spec for a single request. + :type _request_auth: dict, optional + :type _content_type: string, optional: force content-type for the request + :return: Returns the result object. + If the method is called asynchronously, + returns the request thread. + :rtype: {{#returnType}}tuple({{.}}, status_code(int), headers(HTTPHeaderDict)){{/returnType}}{{^returnType}}None{{/returnType}} + """ + + {{#servers.0}} + local_var_hosts = [ +{{#servers}} + '{{{url}}}'{{^-last}},{{/-last}} +{{/servers}} + ] + local_var_host = local_var_hosts[0] + if kwargs.get('_host_index'): + _host_index = int(kwargs.get('_host_index')) + if _host_index < 0 or _host_index >= len(local_var_hosts): + raise ApiValueError( + "Invalid host index. Must be 0 <= index < %s" + % len(local_var_host) + ) + local_var_host = local_var_hosts[_host_index] + {{/servers.0}} + local_var_params = locals() + + all_params = [ +{{#requiredParams}}{{^-first}} + '{{paramName}}'{{^-last}},{{/-last}} +{{/-first}}{{/requiredParams}} +{{#optionalParams}} + '{{paramName}}'{{^-last}},{{/-last}} +{{/optionalParams}} + ] + all_params.extend( + [ + 'async_req', + '_return_http_data_only', + '_preload_content', + '_request_timeout', + '_request_auth', + '_content_type', + '_headers' + ] + ) + + for key, val in six.iteritems(local_var_params['kwargs']): + if key not in all_params{{#servers.0}} and key != "_host_index"{{/servers.0}}: + raise ApiTypeError( + "Got an unexpected keyword argument '%s'" + " to method {{operationId}}" % key + ) + local_var_params[key] = val + del local_var_params['kwargs'] +{{#allParams}} +{{^isNullable}} +{{#required}} +{{^-first}} + # verify the required parameter '{{paramName}}' is set + if self.api_client.client_side_validation and local_var_params.get('{{paramName}}') is None: # noqa: E501 + raise ApiValueError("Missing the required parameter `{{paramName}}` when calling `{{operationId}}`") # noqa: E501 +{{/-first}} +{{/required}} +{{/isNullable}} +{{/allParams}} + +{{#allParams}} +{{#hasValidation}} + {{#maxLength}} + if self.api_client.client_side_validation and ('{{paramName}}' in local_var_params and # noqa: E501 + len(local_var_params['{{paramName}}']) > {{maxLength}}): # noqa: E501 + raise ApiValueError("Invalid value for parameter `{{paramName}}` when calling `{{operationId}}`, length must be less than or equal to `{{maxLength}}`") # noqa: E501 + {{/maxLength}} + {{#minLength}} + if self.api_client.client_side_validation and ('{{paramName}}' in local_var_params and # noqa: E501 + len(local_var_params['{{paramName}}']) < {{minLength}}): # noqa: E501 + raise ApiValueError("Invalid value for parameter `{{paramName}}` when calling `{{operationId}}`, length must be greater than or equal to `{{minLength}}`") # noqa: E501 + {{/minLength}} + {{#maximum}} + if self.api_client.client_side_validation and '{{paramName}}' in local_var_params and local_var_params['{{paramName}}'] >{{#exclusiveMaximum}}={{/exclusiveMaximum}} {{maximum}}: # noqa: E501 + raise ApiValueError("Invalid value for parameter `{{paramName}}` when calling `{{operationId}}`, must be a value less than {{^exclusiveMaximum}}or equal to {{/exclusiveMaximum}}`{{maximum}}`") # noqa: E501 + {{/maximum}} + {{#minimum}} + if self.api_client.client_side_validation and '{{paramName}}' in local_var_params and local_var_params['{{paramName}}'] <{{#exclusiveMinimum}}={{/exclusiveMinimum}} {{minimum}}: # noqa: E501 + raise ApiValueError("Invalid value for parameter `{{paramName}}` when calling `{{operationId}}`, must be a value greater than {{^exclusiveMinimum}}or equal to {{/exclusiveMinimum}}`{{minimum}}`") # noqa: E501 + {{/minimum}} + {{#pattern}} + if self.api_client.client_side_validation and '{{paramName}}' in local_var_params and not re.search(r'{{{vendorExtensions.x-regex}}}', local_var_params['{{paramName}}']{{#vendorExtensions.x-modifiers}}{{#-first}}, flags={{/-first}}re.{{.}}{{^-last}} | {{/-last}}{{/vendorExtensions.x-modifiers}}): # noqa: E501 + raise ApiValueError("Invalid value for parameter `{{paramName}}` when calling `{{operationId}}`, must conform to the pattern `{{{pattern}}}`") # noqa: E501 + {{/pattern}} + {{#maxItems}} + if self.api_client.client_side_validation and ('{{paramName}}' in local_var_params and # noqa: E501 + len(local_var_params['{{paramName}}']) > {{maxItems}}): # noqa: E501 + raise ApiValueError("Invalid value for parameter `{{paramName}}` when calling `{{operationId}}`, number of items must be less than or equal to `{{maxItems}}`") # noqa: E501 + {{/maxItems}} + {{#minItems}} + if self.api_client.client_side_validation and ('{{paramName}}' in local_var_params and # noqa: E501 + len(local_var_params['{{paramName}}']) < {{minItems}}): # noqa: E501 + raise ApiValueError("Invalid value for parameter `{{paramName}}` when calling `{{operationId}}`, number of items must be greater than or equal to `{{minItems}}`") # noqa: E501 + {{/minItems}} +{{/hasValidation}} +{{#-last}} +{{/-last}} +{{/allParams}} + collection_formats = {} + + path_params = {} +{{#pathParams}} +{{^-first}} + if '{{paramName}}' in local_var_params: + path_params['{{baseName}}'] = local_var_params['{{paramName}}']{{#isArray}} # noqa: E501 + collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} # noqa: E501 +{{/-first}} + +{{#-first}} + if self.api_client._get_store_id() is None: + raise ApiValueError("Store ID expected in api_client's configuration when calling `{{operationId}}`") # noqa: E501 + store_id = self.api_client._get_store_id() +{{/-first}} + +{{/pathParams}} + + query_params = [] +{{#queryParams}} + if local_var_params.get('{{paramName}}') is not None: # noqa: E501 + query_params.append(('{{baseName}}', local_var_params['{{paramName}}'])){{#isArray}} # noqa: E501 + collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} # noqa: E501 +{{/queryParams}} + + header_params = dict(local_var_params.get('_headers', {})) +{{#headerParams}} + if '{{paramName}}' in local_var_params: + header_params['{{baseName}}'] = local_var_params['{{paramName}}']{{#isArray}} # noqa: E501 + collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} # noqa: E501 +{{/headerParams}} + + form_params = [] + local_var_files = {} +{{#formParams}} + if '{{paramName}}' in local_var_params: + {{^isFile}}form_params.append(('{{baseName}}', local_var_params['{{paramName}}'])){{/isFile}}{{#isFile}}local_var_files['{{baseName}}'] = local_var_params['{{paramName}}']{{/isFile}}{{#isArray}} # noqa: E501 + collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} # noqa: E501 +{{/formParams}} + + body_params = None +{{#bodyParam}} + if '{{paramName}}' in local_var_params: + body_params = local_var_params['{{paramName}}'] +{{/bodyParam}} + {{#hasProduces}} + # HTTP header `Accept` + header_params['Accept'] = self.api_client.select_header_accept( + [{{#produces}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/produces}}]) # noqa: E501 + + {{/hasProduces}} + {{#hasConsumes}} + # HTTP header `Content-Type` + content_types_list = local_var_params.get('_content_type', self.api_client.select_header_content_type([{{#consumes}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/consumes}}],'{{httpMethod}}', body_params)) # noqa: E501 + if content_types_list: + header_params['Content-Type'] = content_types_list + + {{/hasConsumes}} + # Authentication setting + auth_settings = [{{#authMethods}}'{{name}}'{{^-last}}, {{/-last}}{{/authMethods}}] # noqa: E501 + + {{#returnType}} + {{#responses}} + {{#-first}} + response_types_map = { + {{/-first}} + {{^isWildcard}} + {{code}}: {{#dataType}}"{{.}}"{{/dataType}}{{^dataType}}None{{/dataType}}, + {{/isWildcard}} + {{#-last}} + } + {{/-last}} + {{/responses}} + {{/returnType}} + {{^returnType}} + response_types_map = {} + {{/returnType}} + + return {{#asyncio}}await({{/asyncio}}self.api_client.call_api( + '{{{path}}}'.replace('{store_id}', store_id), '{{httpMethod}}', + path_params, + query_params, + header_params, + body=body_params, + post_params=form_params, + files=local_var_files, + response_types_map=response_types_map, + auth_settings=auth_settings, + async_req=local_var_params.get('async_req'), + _return_http_data_only=local_var_params.get('_return_http_data_only'), # noqa: E501 + _preload_content=local_var_params.get('_preload_content', True), + _request_timeout=local_var_params.get('_request_timeout'), + {{#servers.0}} + _host=local_var_host, + {{/servers.0}} + collection_formats=collection_formats, + _request_auth=local_var_params.get('_request_auth')){{#asyncio}}){{/asyncio}} +{{/operation}} +{{/operations}} diff --git a/config/clients/python/template/api_client.mustache b/config/clients/python/template/api_client.mustache new file mode 100644 index 000000000..b2362769a --- /dev/null +++ b/config/clients/python/template/api_client.mustache @@ -0,0 +1,771 @@ +# coding: utf-8 +{{>partial_header}} + +{{#asyncio}} +import asyncio +{{/asyncio}} +import atexit +import datetime +from dateutil.parser import parse +import json +import math +import mimetypes +from multiprocessing.pool import ThreadPool +import os +import random +import re +import tempfile + +# python 2 and python 3 compatibility library +import six +from six.moves.urllib.parse import quote +{{#tornado}} +import tornado.gen +{{/tornado}} + +from {{packageName}}.configuration import Configuration +import {{modelPackage}} +from {{packageName}} import rest +from {{packageName}}.exceptions import ApiValueError, ApiException, ApiTypeError, RateLimitExceededError + + +def random_time(loop_count, min_wait_in_ms): + """ + Helper function to return the time (in s) to wait before retry + """ + minimum = math.ceil(2 ** loop_count * min_wait_in_ms) + maximum = math.ceil(2 ** (loop_count + 1) * min_wait_in_ms) + return random.randrange(minimum, maximum) / 1000 + + +class ApiClient(object): + """Generic API client for OpenAPI client library builds. + + OpenAPI generic API client. This client handles the client- + server communication, and is invariant across implementations. Specifics of + the methods and models for each application are generated from the OpenAPI + templates. + + NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + Do not edit the class manually. + + :param configuration: .Configuration object for this client + :param header_name: a header to pass when making calls to the API. + :param header_value: a header value to pass when making calls to + the API. + :param cookie: a cookie to include in the header when making calls + to the API + :param pool_threads: The number of threads to use for async requests + to the API. More threads means more concurrent API requests. + """ + + PRIMITIVE_TYPES = (float, bool, bytes, six.text_type) + six.integer_types + NATIVE_TYPES_MAPPING = { + 'int': int, + 'long': int if six.PY3 else long, # noqa: F821 + 'float': float, + 'str': str, + 'bool': bool, + 'date': datetime.date, + 'datetime': datetime.datetime, + 'object': object, + } + _pool = None + + def __init__(self, configuration=None, header_name=None, header_value=None, + cookie=None, pool_threads=1): + if configuration is None: + configuration = Configuration.get_default_copy() + self.configuration = configuration + self.pool_threads = pool_threads + + self.rest_client = rest.RESTClientObject(configuration) + self.default_headers = {} + if header_name is not None: + self.default_headers[header_name] = header_value + self.cookie = cookie + # Set default User-Agent. + self.user_agent = '{{userAgent}}'.replace('{sdkId}', '{{sdkId}}').replace('{packageVersion}', '{{packageVersion}}') + self.client_side_validation = configuration.client_side_validation + + {{#asyncio}} + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() + {{/asyncio}} + {{^asyncio}} + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + {{/asyncio}} + + {{#asyncio}}async {{/asyncio}}def close(self): + {{#asyncio}} + await self.rest_client.close() + {{/asyncio}} + if self._pool: + self._pool.close() + self._pool.join() + self._pool = None + if hasattr(atexit, 'unregister'): + atexit.unregister(self.close) + + @property + def pool(self): + """Create thread pool on first request + avoids instantiating unused threadpool for blocking clients. + """ + if self._pool is None: + atexit.register(self.close) + self._pool = ThreadPool(self.pool_threads) + return self._pool + + @property + def user_agent(self): + """User agent for this API client""" + return self.default_headers['User-Agent'] + + @user_agent.setter + def user_agent(self, value): + self.default_headers['User-Agent'] = value + + def set_default_header(self, header_name, header_value): + self.default_headers[header_name] = header_value + + {{#tornado}} + @tornado.gen.coroutine + {{/tornado}} + {{#asyncio}}async {{/asyncio}}def __call_api( + self, resource_path, method, path_params=None, + query_params=None, header_params=None, body=None, post_params=None, + files=None, response_types_map=None, auth_settings=None, + _return_http_data_only=None, collection_formats=None, + _preload_content=True, _request_timeout=None, _host=None, + _request_auth=None): + + self.configuration.is_valid() + config = self.configuration + + # header parameters + header_params = header_params or {} + header_params.update(self.default_headers) + if self.cookie: + header_params['Cookie'] = self.cookie + if header_params: + header_params = self.sanitize_for_serialization(header_params) + header_params = dict(self.parameters_to_tuples(header_params, + collection_formats)) + + # path parameters + if path_params: + path_params = self.sanitize_for_serialization(path_params) + path_params = self.parameters_to_tuples(path_params, + collection_formats) + for k, v in path_params: + # specified safe chars, encode everything + resource_path = resource_path.replace( + '{%s}' % k, + quote(str(v), safe=config.safe_chars_for_path_param) + ) + + # query parameters + if query_params: + query_params = self.sanitize_for_serialization(query_params) + query_params = self.parameters_to_tuples(query_params, + collection_formats) + + # post parameters + if post_params or files: + post_params = post_params if post_params else [] + post_params = self.sanitize_for_serialization(post_params) + post_params = self.parameters_to_tuples(post_params, + collection_formats) + post_params.extend(self.files_parameters(files)) + + # auth setting + {{#asyncio}}await {{/asyncio}}self.update_params_for_auth( + header_params, query_params, auth_settings, + request_auth=_request_auth) + + # body + if body: + body = self.sanitize_for_serialization(body) + + # request url + if _host is None: + url = self.configuration.api_scheme + '://' + self.configuration.api_host + resource_path + else: + # use server/host defined in path or operation instead + url = self.configuration.api_scheme + '://' + _host + resource_path + + max_retry = self.configuration.retry_params.max_retry if (self.configuration.retry_params is not None and self.configuration.retry_params.max_retry is not None) else 0 + min_wait_in_ms = self.configuration.retry_params.min_wait_in_ms if (self.configuration.retry_params is not None and self.configuration.retry_params.min_wait_in_ms is not None) else 0 + for x in range(max_retry+1): + try: + # perform request and return response + response_data = {{#asyncio}}await {{/asyncio}}{{#tornado}}yield {{/tornado}}self.request( + method, url, query_params=query_params, headers=header_params, + post_params=post_params, body=body, + _preload_content=_preload_content, + _request_timeout=_request_timeout) + except RateLimitExceededError as e: + if x < max_retry: + {{#asyncio}}await asyncio{{/asyncio}}.sleep(random_time(x, min_wait_in_ms)) + continue + e.body = e.body.decode('utf-8') if six.PY3 else e.body + response_type = response_types_map.get(e.status, None) + if response_type is not None: + e.parsed_exception = self.__deserialize(json.loads(e.body), response_type) + raise e + except ApiException as e: + e.body = e.body.decode('utf-8') if six.PY3 else e.body + response_type = response_types_map.get(e.status, None) + if response_type is not None: + e.parsed_exception = self.__deserialize(json.loads(e.body), response_type) + raise e + + self.last_response = response_data + + return_data = response_data + + if not _preload_content: + {{^tornado}} + return return_data + {{/tornado}} + {{#tornado}} + raise tornado.gen.Return(return_data) + {{/tornado}} + + response_type = response_types_map.get(response_data.status, None) + + if six.PY3 and response_type not in ["file", "bytes"]: + match = None + content_type = response_data.getheader('content-type') + if content_type is not None: + match = re.search(r"charset=([a-zA-Z\-\d]+)[\s\;]?", content_type) + encoding = match.group(1) if match else "utf-8" + if response_data.data is not None: + response_data.data = response_data.data.decode(encoding) + + # deserialize response data + + if response_type: + return_data = self.deserialize(response_data, response_type) + else: + return_data = None + +{{^tornado}} + if _return_http_data_only: + return (return_data) + else: + return (return_data, response_data.status, + response_data.getheaders()) +{{/tornado}} +{{#tornado}} + if _return_http_data_only: + raise tornado.gen.Return(return_data) + else: + raise tornado.gen.Return((return_data, response_data.status, + response_data.getheaders())) +{{/tornado}} + + def sanitize_for_serialization(self, obj): + """Builds a JSON POST object. + + If obj is None, return None. + If obj is str, int, long, float, bool, return directly. + If obj is datetime.datetime, datetime.date + convert to string in iso8601 format. + If obj is list, sanitize each element in the list. + If obj is dict, return the dict. + If obj is OpenAPI model, return the properties dict. + + :param obj: The data to serialize. + :return: The serialized form of data. + """ + if obj is None: + return None + elif isinstance(obj, self.PRIMITIVE_TYPES): + return obj + elif isinstance(obj, list): + return [self.sanitize_for_serialization(sub_obj) + for sub_obj in obj] + elif isinstance(obj, tuple): + return tuple(self.sanitize_for_serialization(sub_obj) + for sub_obj in obj) + elif isinstance(obj, (datetime.datetime, datetime.date)): + return obj.isoformat() + + if isinstance(obj, dict): + obj_dict = obj + else: + # Convert model obj to dict except + # attributes `openapi_types`, `attribute_map` + # and attributes which value is not None. + # Convert attribute name to json key in + # model definition for request. + obj_dict = {obj.attribute_map[attr]: getattr(obj, attr) + for attr, _ in six.iteritems(obj.openapi_types) + if getattr(obj, attr) is not None} + + return {key: self.sanitize_for_serialization(val) + for key, val in six.iteritems(obj_dict)} + + def deserialize(self, response, response_type): + """Deserializes response into an object. + + :param response: RESTResponse object to be deserialized. + :param response_type: class literal for + deserialized object, or string of class name. + + :return: deserialized object. + """ + # handle file downloading + # save response body into a tmp file and return the instance + if response_type == "file": + return self.__deserialize_file(response) + + # fetch data from response object + try: + data = json.loads(response.data) + except ValueError: + data = response.data + + return self.__deserialize(data, response_type) + + def __deserialize(self, data, klass): + """Deserializes dict, list, str into an object. + + :param data: dict, list or str. + :param klass: class literal, or string of class name. + + :return: object. + """ + if data is None: + return None + + if type(klass) == str: + if klass.startswith('list['): + sub_kls = re.match(r'list\[(.*)\]', klass).group(1) + return [self.__deserialize(sub_data, sub_kls) + for sub_data in data] + + if klass.startswith('dict('): + sub_kls = re.match(r'dict\(([^,]*), (.*)\)', klass).group(2) + return {k: self.__deserialize(v, sub_kls) + for k, v in six.iteritems(data)} + + # convert str to class + if klass in self.NATIVE_TYPES_MAPPING: + klass = self.NATIVE_TYPES_MAPPING[klass] + else: + klass = getattr({{modelPackage}}, klass) + + if klass in self.PRIMITIVE_TYPES: + return self.__deserialize_primitive(data, klass) + elif klass == object: + return self.__deserialize_object(data) + elif klass == datetime.date: + return self.__deserialize_date(data) + elif klass == datetime.datetime: + return self.__deserialize_datetime(data) + else: + return self.__deserialize_model(data, klass) + + {{#asyncio}}async {{/asyncio}}def call_api(self, resource_path, method, + path_params=None, query_params=None, header_params=None, + body=None, post_params=None, files=None, + response_types_map=None, auth_settings=None, + async_req=None, _return_http_data_only=None, + collection_formats=None,_preload_content=True, + _request_timeout=None, _host=None, _request_auth=None): + """Makes the HTTP request (synchronous) and returns deserialized data. + + To make an async_req request, set the async_req parameter. + + :param resource_path: Path to method endpoint. + :param method: Method to call. + :param path_params: Path parameters in the url. + :param query_params: Query parameters in the url. + :param header_params: Header parameters to be + placed in the request header. + :param body: Request body. + :param post_params dict: Request post form parameters, + for `application/x-www-form-urlencoded`, `multipart/form-data`. + :param auth_settings list: Auth Settings names for the request. + :param response: Response data type. + :param files dict: key -> filename, value -> filepath, + for `multipart/form-data`. + :param async_req bool: execute request asynchronously + :param _return_http_data_only: response data without head status code + and headers + :param collection_formats: dict of collection formats for path, query, + header, and post parameters. + :param _preload_content: if False, the urllib3.HTTPResponse object will + be returned without reading/decoding response + data. Default is True. + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the authentication + in the spec for a single request. + :type _request_token: dict, optional + :return: + If async_req parameter is True, + the request will be called asynchronously. + The method will return the request thread. + If parameter async_req is False or missing, + then the method will return the response directly. + """ + if not async_req: + return {{#asyncio}}await({{/asyncio}}self.__call_api(resource_path, method, + path_params, query_params, header_params, + body, post_params, files, + response_types_map, auth_settings, + _return_http_data_only, collection_formats, + _preload_content, _request_timeout, _host, + _request_auth){{#asyncio}}){{/asyncio}} + + return self.pool.apply_async(self.__call_api, (resource_path, + method, path_params, + query_params, + header_params, body, + post_params, files, + response_types_map, + auth_settings, + _return_http_data_only, + collection_formats, + _preload_content, + _request_timeout, + _host, _request_auth)) + + {{#asyncio}}async {{/asyncio}}def request(self, method, url, query_params=None, headers=None, + post_params=None, body=None, _preload_content=True, + _request_timeout=None): + """Makes the HTTP request using RESTClient.""" + if method == "GET": + return {{#asyncio}}await({{/asyncio}}self.rest_client.GET(url, + query_params=query_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + headers=headers){{#asyncio}}){{/asyncio}} + elif method == "HEAD": + return {{#asyncio}}await({{/asyncio}}self.rest_client.HEAD(url, + query_params=query_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + headers=headers){{#asyncio}}){{/asyncio}} + elif method == "OPTIONS": + return {{#asyncio}}await({{/asyncio}}self.rest_client.OPTIONS(url, + query_params=query_params, + headers=headers, + _preload_content=_preload_content, + _request_timeout=_request_timeout){{#asyncio}}){{/asyncio}} + elif method == "POST": + return {{#asyncio}}await({{/asyncio}}self.rest_client.POST(url, + query_params=query_params, + headers=headers, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body){{#asyncio}}){{/asyncio}} + elif method == "PUT": + return {{#asyncio}}await({{/asyncio}}self.rest_client.PUT(url, + query_params=query_params, + headers=headers, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body){{#asyncio}}){{/asyncio}} + elif method == "PATCH": + return {{#asyncio}}await({{/asyncio}}self.rest_client.PATCH(url, + query_params=query_params, + headers=headers, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body){{#asyncio}}){{/asyncio}} + elif method == "DELETE": + return {{#asyncio}}await({{/asyncio}}self.rest_client.DELETE(url, + query_params=query_params, + headers=headers, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body){{#asyncio}}){{/asyncio}} + else: + raise ApiValueError( + "http method must be `GET`, `HEAD`, `OPTIONS`," + " `POST`, `PATCH`, `PUT` or `DELETE`." + ) + + def parameters_to_tuples(self, params, collection_formats): + """Get parameters as list of tuples, formatting collections. + + :param params: Parameters as dict or list of two-tuples + :param dict collection_formats: Parameter collection formats + :return: Parameters as list of tuples, collections formatted + """ + new_params = [] + if collection_formats is None: + collection_formats = {} + for k, v in six.iteritems(params) if isinstance(params, dict) else params: # noqa: E501 + if k in collection_formats: + collection_format = collection_formats[k] + if collection_format == 'multi': + new_params.extend((k, value) for value in v) + else: + if collection_format == 'ssv': + delimiter = ' ' + elif collection_format == 'tsv': + delimiter = '\t' + elif collection_format == 'pipes': + delimiter = '|' + else: # csv is the default + delimiter = ',' + new_params.append( + (k, delimiter.join(str(value) for value in v))) + else: + new_params.append((k, v)) + return new_params + + def files_parameters(self, files=None): + """Builds form parameters. + + :param files: File parameters. + :return: Form parameters with files. + """ + params = [] + + if files: + for k, v in six.iteritems(files): + if not v: + continue + file_names = v if type(v) is list else [v] + for n in file_names: + with open(n, 'rb') as f: + filename = os.path.basename(f.name) + filedata = f.read() + mimetype = (mimetypes.guess_type(filename)[0] or + 'application/octet-stream') + params.append( + tuple([k, tuple([filename, filedata, mimetype])])) + + return params + + def select_header_accept(self, accepts): + """Returns `Accept` based on an array of accepts provided. + + :param accepts: List of headers. + :return: Accept (e.g. application/json). + """ + if not accepts: + return + + accepts = [x.lower() for x in accepts] + + if 'application/json' in accepts: + return 'application/json' + else: + return ', '.join(accepts) + + def select_header_content_type(self, content_types, method=None, body=None): + """Returns `Content-Type` based on an array of content_types provided. + + :param content_types: List of content-types. + :param method: http method (e.g. POST, PATCH). + :param body: http body to send. + :return: Content-Type (e.g. application/json). + """ + if not content_types: + return None + + content_types = [x.lower() for x in content_types] + + if (method == 'PATCH' and + 'application/json-patch+json' in content_types and + isinstance(body, list)): + return 'application/json-patch+json' + + if 'application/json' in content_types or '*/*' in content_types: + return 'application/json' + else: + return content_types[0] + + {{#asyncio}}async {{/asyncio}}def update_params_for_auth(self, headers, queries, auth_settings, + request_auth=None): + """Updates header and query params based on authentication setting. + + :param headers: Header parameters dict to be updated. + :param queries: Query parameters tuple list to be updated. + :param auth_settings: Authentication setting identifiers list. + :param request_auth: if set, the provided settings will + override the token in the configuration. + """ + if self.configuration.credentials is not None: + added_headers = {{#asyncio}}await {{/asyncio}}self.configuration.credentials.get_authentication_header(self.rest_client) + for key, value in added_headers.items(): + headers[key] = value + + if not auth_settings: + return + + if request_auth: + self._apply_auth_params(headers, queries, request_auth) + return + + for auth in auth_settings: + auth_setting = self.configuration.auth_settings().get(auth) + if auth_setting: + self._apply_auth_params(headers, queries, auth_setting) + + def _apply_auth_params(self, headers, queries, auth_setting): + """Updates the request parameters based on a single auth_setting + + :param headers: Header parameters dict to be updated. + :param queries: Query parameters tuple list to be updated. + :param auth_setting: auth settings for the endpoint + """ + if auth_setting['in'] == 'cookie': + headers['Cookie'] = auth_setting['value'] + elif auth_setting['in'] == 'header': + headers[auth_setting['key']] = auth_setting['value'] + elif auth_setting['in'] == 'query': + queries.append((auth_setting['key'], auth_setting['value'])) + else: + raise ApiValueError( + 'Authentication token must be in `query` or `header`' + ) + + def __deserialize_file(self, response): + """Deserializes body to file + + Saves response body into a file in a temporary folder, + using the filename from the `Content-Disposition` header if provided. + + :param response: RESTResponse. + :return: file path. + """ + fd, path = tempfile.mkstemp(dir=self.configuration.temp_folder_path) + os.close(fd) + os.remove(path) + + content_disposition = response.getheader("Content-Disposition") + if content_disposition: + filename = re.search(r'filename=[\'"]?([^\'"\s]+)[\'"]?', + content_disposition).group(1) + path = os.path.join(os.path.dirname(path), filename) + + with open(path, "wb") as f: + f.write(response.data) + + return path + + def __deserialize_primitive(self, data, klass): + """Deserializes string to primitive type. + + :param data: str. + :param klass: class literal. + + :return: int, long, float, str, bool. + """ + try: + return klass(data) + except UnicodeEncodeError: + return six.text_type(data) + except TypeError: + return data + + def __deserialize_object(self, value): + """Return an original value. + + :return: object. + """ + return value + + def __deserialize_date(self, string): + """Deserializes string to date. + + :param string: str. + :return: date. + """ + try: + return parse(string).date() + except ImportError: + return string + except ValueError: + raise rest.ApiException( + status=0, + reason="Failed to parse `{0}` as date object".format(string) + ) + + def __deserialize_datetime(self, string): + """Deserializes string to datetime. + + The string should be in iso8601 datetime format. + + :param string: str. + :return: datetime. + """ + try: + return parse(string) + except ImportError: + return string + except ValueError: + raise rest.ApiException( + status=0, + reason=( + "Failed to parse `{0}` as datetime object" + .format(string) + ) + ) + + def __deserialize_model(self, data, klass): + """Deserializes list or dict to model. + + :param data: dict, list. + :param klass: class literal. + :return: model object. + """ + has_discriminator = False + if (hasattr(klass, 'get_real_child_model') and klass.discriminator_value_class_map): + has_discriminator = True + + if not klass.openapi_types and has_discriminator is False: + return data + + kwargs = {} + if (data is not None and + klass.openapi_types is not None and + isinstance(data, (list, dict))): + for attr, attr_type in six.iteritems(klass.openapi_types): + if klass.attribute_map[attr] in data: + value = data[klass.attribute_map[attr]] + kwargs[attr] = self.__deserialize(value, attr_type) + + instance = klass(**kwargs) + + if has_discriminator: + klass_name = instance.get_real_child_model(data) + if klass_name: + instance = self.__deserialize(data, klass_name) + return instance + + def _get_store_id(self): + """ + Verify that the store id has been configured and not empty string. + It will return the store ID. + Otherwise, raise ApiTypeError + """ + configuration = self.configuration + if configuration.store_id is None or configuration.store_id == '': + raise ApiTypeError( + 'store_id is required but not configured' + ) + return configuration.store_id diff --git a/config/clients/python/template/api_doc.mustache b/config/clients/python/template/api_doc.mustache new file mode 100644 index 000000000..f95e09d39 --- /dev/null +++ b/config/clients/python/template/api_doc.mustache @@ -0,0 +1,58 @@ +# {{packageName}}.{{classname}}{{#description}} +{{.}}{{/description}} + +All URIs are relative to *api.{{sampleApiDomain}}* + +Method | HTTP request | Description +------------- | ------------- | ------------- +{{#operations}}{{#operation}}[**{{operationId}}**]({{classname}}.md#{{operationId}}) | **{{httpMethod}}** {{path}} | {{summary}} +{{/operation}}{{/operations}} + +{{#operations}} +{{#operation}} +# **{{{operationId}}}** +> {{#returnType}}{{{.}}} {{/returnType}}{{{operationId}}}({{#requiredParams}}{{^-first}}{{^defaultValue}}{{paramName}}{{^-last}}, {{/-last}}{{/defaultValue}}{{/-first}}{{/requiredParams}}) + +{{{summary}}}{{#notes}} + +{{{.}}}{{/notes}} + +### Example + +{{> api_doc_example }} + +### Parameters +{{^allParams}}This endpoint does not need any parameter.{{/allParams}}{{#allParams}}{{#-last}} +Name | Type | Description | Notes +------------- | ------------- | ------------- | -------------{{/-last}}{{/allParams}} +{{#requiredParams}}{{^-first}}{{^defaultValue}} **{{paramName}}** | {{^baseType}}**{{dataType}}**{{/baseType}}{{#baseType}}[**{{dataType}}**]({{baseType}}.md){{/baseType}}| {{description}} | +{{/defaultValue}}{{/-first}}{{/requiredParams}}{{#requiredParams}}{{^-first}}{{#defaultValue}} **{{paramName}}** | {{^baseType}}**{{dataType}}**{{/baseType}}{{#baseType}}[**{{dataType}}**]({{baseType}}.md){{/baseType}}| {{description}} | defaults to {{{.}}} +{{/defaultValue}}{{/-first}}{{/requiredParams}}{{#optionalParams}} **{{paramName}}** | {{^baseType}}**{{dataType}}**{{/baseType}}{{#baseType}}[**{{dataType}}**]({{baseType}}.md){{/baseType}}| {{description}} | [optional]{{#defaultValue}} if omitted the server will use the default value of {{{.}}}{{/defaultValue}} +{{/optionalParams}} + +### Return type + +{{#returnType}}{{#returnTypeIsPrimitive}}**{{{returnType}}}**{{/returnTypeIsPrimitive}}{{^returnTypeIsPrimitive}}[**{{{returnType}}}**]({{returnBaseType}}.md){{/returnTypeIsPrimitive}}{{/returnType}}{{^returnType}}void (empty response body){{/returnType}} + +### Authorization + +{{^authMethods}}No authorization required{{/authMethods}}{{#authMethods}}[{{{name}}}](../README.md#{{{name}}}){{^-last}}, {{/-last}}{{/authMethods}} + +### HTTP request headers + + - **Content-Type**: {{#consumes}}{{{mediaType}}}{{^-last}}, {{/-last}}{{/consumes}}{{^consumes}}Not defined{{/consumes}} + - **Accept**: {{#produces}}{{{mediaType}}}{{^-last}}, {{/-last}}{{/produces}}{{^produces}}Not defined{{/produces}} + +{{#responses.0}} +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +{{#responses}} +**{{code}}** | {{message}} | {{#headers}} * {{baseName}} - {{description}}
{{/headers}}{{^headers.0}} - {{/headers.0}} | +{{/responses}} +{{/responses.0}} + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +{{/operation}} +{{/operations}} diff --git a/config/clients/python/template/api_doc_example.mustache b/config/clients/python/template/api_doc_example.mustache new file mode 100644 index 000000000..3b2f03175 --- /dev/null +++ b/config/clients/python/template/api_doc_example.mustache @@ -0,0 +1,30 @@ +```python +import time +import {{{packageName}}} +from {{{packageName}}}.rest import ApiException +from pprint import pprint +{{> python_doc_auth_partial}} +# Enter a context with an instance of the API client +{{#asyncio}}async {{/asyncio}}with {{{packageName}}}.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = {{{packageName}}}.{{{classname}}}(api_client) +{{#requiredParams}} +{{^defaultValue}} +{{^-first}} + {{paramName}} = {{{example}}} # {{{dataType}}} | {{{description}}} +{{/-first}} +{{/defaultValue}} +{{/requiredParams}} +{{#optionalParams}} + {{paramName}} = {{{example}}} # {{{dataType}}} | {{{description}}}{{^required}} (optional){{/required}}{{#defaultValue}} if omitted the server will use the default value of {{{.}}}{{/defaultValue}} +{{/optionalParams}} + + try: + {{#summary}} # {{{.}}} + {{/summary}} + {{#returnType}}api_response = {{/returnType}}{{#asyncio}}await {{/asyncio}}api_instance.api_instance.{{{operationId}}}({{#allParams}}{{#required}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/required}}{{^required}}{{paramName}}={{paramName}}{{^-last}}, {{/-last}}{{/required}}{{/allParams}}){{#returnType}} + pprint(api_response){{/returnType}} + except ApiException as e: + print("Exception when calling {{classname}}->{{operationId}}: %s\n" % e) + {{#asyncio}}await {{/asyncio}}api_client.close() +``` diff --git a/config/clients/python/template/api_test.mustache b/config/clients/python/template/api_test.mustache new file mode 100644 index 000000000..19d5c1c3d --- /dev/null +++ b/config/clients/python/template/api_test.mustache @@ -0,0 +1,1179 @@ +# coding: utf-8 + +{{>partial_header}} + +import unittest +from unittest.mock import ANY +from unittest import IsolatedAsyncioTestCase +from mock import patch +from datetime import datetime + +import urllib3 + +import {{packageName}} +from {{packageName}} import rest +from {{packageName}}.api import open_fga_api +from {{packageName}}.credentials import Credentials, CredentialConfiguration +from {{packageName}}.exceptions import ApiTypeError, ApiValueError, NotFoundException, RateLimitExceededError, ServiceException, ValidationException +from {{packageName}}.models.assertion import Assertion +from {{packageName}}.models.authorization_model import AuthorizationModel +from {{packageName}}.models.check_request import CheckRequest +from {{packageName}}.models.check_response import CheckResponse +from {{packageName}}.models.create_store_request import CreateStoreRequest +from {{packageName}}.models.create_store_response import CreateStoreResponse +from {{packageName}}.models.error_code import ErrorCode +from {{packageName}}.models.expand_request import ExpandRequest +from {{packageName}}.models.expand_response import ExpandResponse +from {{packageName}}.models.get_store_response import GetStoreResponse +from {{packageName}}.models.internal_error_code import InternalErrorCode +from {{packageName}}.models.internal_error_message_response import InternalErrorMessageResponse +from {{packageName}}.models.leaf import Leaf +from {{packageName}}.models.list_objects_request import ListObjectsRequest +from {{packageName}}.models.list_objects_response import ListObjectsResponse +from {{packageName}}.models.list_stores_response import ListStoresResponse +from {{packageName}}.models.node import Node +from {{packageName}}.models.not_found_error_code import NotFoundErrorCode +from {{packageName}}.models.object_relation import ObjectRelation +from {{packageName}}.models.path_unknown_error_message_response import PathUnknownErrorMessageResponse +from {{packageName}}.models.read_assertions_response import ReadAssertionsResponse +from {{packageName}}.models.read_authorization_model_response import ReadAuthorizationModelResponse +from {{packageName}}.models.read_changes_response import ReadChangesResponse +from {{packageName}}.models.read_request import ReadRequest +from {{packageName}}.models.read_response import ReadResponse +from {{packageName}}.models.store import Store +from {{packageName}}.models.tuple import Tuple +from {{packageName}}.models.tuple_change import TupleChange +from {{packageName}}.models.tuple_key import TupleKey +from {{packageName}}.models.tuple_keys import TupleKeys +from {{packageName}}.models.tuple_operation import TupleOperation +from {{packageName}}.models.type_definition import TypeDefinition +from {{packageName}}.models.users import Users +from {{packageName}}.models.userset import Userset +from {{packageName}}.models.userset_tree import UsersetTree +from {{packageName}}.models.usersets import Usersets +from {{packageName}}.models.validation_error_message_response import ValidationErrorMessageResponse +from {{packageName}}.models.write_assertions_request import WriteAssertionsRequest +from {{packageName}}.models.write_authorization_model_request import WriteAuthorizationModelRequest +from {{packageName}}.models.write_authorization_model_response import WriteAuthorizationModelResponse +from {{packageName}}.models.write_request import WriteRequest + +store_id = 'd12345abc' + +# Helper function to construct mock response +def http_mock_response(body, status): + headers = urllib3.response.HTTPHeaderDict({ + 'content-type': 'application/json' + }) + return urllib3.HTTPResponse( + body.encode('utf-8'), + headers, + status, + preload_content=False + ) + +def mock_response(body, status): + obj = http_mock_response(body, status) + return rest.RESTResponse(obj, obj.data) + +class {{#operations}}Test{{classname}}(IsolatedAsyncioTestCase): + """{{classname}} unit test stubs""" + + def setUp(self): + self.configuration = {{packageName}}.Configuration( + api_scheme='http', + api_host="api.{{sampleApiDomain}}", + ) + + def tearDown(self): + pass + + @patch.object(rest.RESTClientObject, 'request') + async def test_check(self, mock_request): + """Test case for check + + Check whether a user is authorized to access an object # noqa: E501 + """ + + # First, mock the response + response_body = '{"allowed": true, "resolution": "1234"}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = CheckRequest( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ) + api_response = await api_instance.check( + body=body, + ) + self.assertIsInstance(api_response, CheckResponse) + self.assertTrue(api_response.allowed) + # Make sure the API was called with the right data + mock_request.assert_called_once_with( + 'POST', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/check', + headers=ANY, + query_params=[], + post_params=[], + body={"tuple_key": {"object": "document:2021-budget", + "relation": "reader", "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b"}}, + _preload_content=ANY, + _request_timeout=None + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, 'request') + async def test_create_store(self, mock_request): + """Test case for create_store + + Create a store # noqa: E501 + """ + response_body = '''{ + "id": "01YCP46JKYM8FJCQ37NMBYHE5X", + "name": "test_store", + "created_at": "2022-07-25T17:41:26.607Z", + "updated_at": "2022-07-25T17:41:26.607Z"} + ''' + mock_request.return_value = mock_response(response_body, 201) + + configuration = self.configuration + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = CreateStoreRequest( + name="test-store", + ) + api_response = await api_instance.create_store( + body=body, + ) + self.assertIsInstance(api_response, CreateStoreResponse) + self.assertEqual(api_response.id, '01YCP46JKYM8FJCQ37NMBYHE5X') + mock_request.assert_called_once_with( + 'POST', + 'http://api.{{sampleApiDomain}}/stores', + headers=ANY, + query_params=[], + post_params=[], + body={"name": "test-store"}, + _preload_content=ANY, + _request_timeout=None + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, 'request') + async def test_delete_store(self, mock_request): + """Test case for delete_store + + Delete a store # noqa: E501 + """ + response_body = '' + mock_request.return_value = mock_response(response_body, 201) + configuration = self.configuration + configuration.store_id = store_id + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + await api_instance.delete_store() + mock_request.assert_called_once_with( + 'DELETE', + 'http://api.{{sampleApiDomain}}/stores/d12345abc', + headers=ANY, + query_params=[], + body=None, + _preload_content=ANY, + _request_timeout=None + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, 'request') + async def test_expand(self, mock_request): + """Test case for expand + + Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason about and debug a certain relationship # noqa: E501 + """ + response_body = '''{ + "tree": {"root": {"name": "document:budget#reader", "leaf": {"users": {"users": ["user:81684243-9356-4421-8fbf-a4f8d36aa31b"]}}}}} + ''' + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = ExpandRequest( + tuple_key=TupleKey( + object="document:budget", + relation="reader", + ), + ) + api_response = await api_instance.expand( + body=body, + ) + self.assertIsInstance(api_response, ExpandResponse) + curUsers = Users(users=["user:81684243-9356-4421-8fbf-a4f8d36aa31b"]) + leaf = Leaf(users=curUsers) + node = Node(name="document:budget#reader", leaf=leaf) + userTree = UsersetTree(node) + expected_response = ExpandResponse(userTree) + self.assertEqual(api_response, expected_response) + mock_request.assert_called_once_with( + 'POST', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/expand', + headers=ANY, + query_params=[], + post_params=[], + body={"tuple_key": {"object": "document:budget", "relation": "reader"}}, + _preload_content=ANY, + _request_timeout=None + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, 'request') + async def test_get_store(self, mock_request): + """Test case for get_store + + Get a store # noqa: E501 + """ + response_body = '''{ + "id": "d12345abc", + "name": "test_store", + "created_at": "2022-07-25T20:45:10.485Z", + "updated_at": "2022-07-25T20:45:10.485Z" +} + ''' + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + # Get a store + api_response = await api_instance.get_store() + self.assertIsInstance(api_response, GetStoreResponse) + self.assertEqual(api_response.id, 'd12345abc') + self.assertEqual(api_response.name, 'test_store') + mock_request.assert_called_once_with( + 'GET', + 'http://api.{{sampleApiDomain}}/stores/d12345abc', + headers=ANY, + query_params=[], + _preload_content=ANY, + _request_timeout=None + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, 'request') + async def test_list_objects(self, mock_request): + """Test case for list_objects + + List objects # noqa: E501 + """ + response_body = ''' +{ + "object_ids": [ + "abcd1234" + ] +} + ''' + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = ListObjectsRequest( + authorization_model_id="01G5JAVJ41T49E9TT3SKVS7X1J", + type="document", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ) + # Get all stores + api_response = await api_instance.list_objects(body) + self.assertIsInstance(api_response, ListObjectsResponse) + self.assertEqual(api_response.object_ids, ['abcd1234']) + mock_request.assert_called_once_with( + 'POST', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/list-objects', + headers=ANY, + query_params=[], + post_params=[], + body={'authorization_model_id': '01G5JAVJ41T49E9TT3SKVS7X1J', + 'type': 'document', 'relation': 'reader', 'user': 'user:81684243-9356-4421-8fbf-a4f8d36aa31b'}, + _preload_content=ANY, + _request_timeout=None + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, 'request') + async def test_list_stores(self, mock_request): + """Test case for list_stores + + Get all stores # noqa: E501 + """ + response_body = ''' +{ + "stores": [ + { + "id": "01YCP46JKYM8FJCQ37NMBYHE5X", + "name": "store1", + "created_at": "2022-07-25T21:15:37.524Z", + "updated_at": "2022-07-25T21:15:37.524Z", + "deleted_at": "2022-07-25T21:15:37.524Z" + }, + { + "id": "01YCP46JKYM8FJCQ37NMBYHE6X", + "name": "store2", + "created_at": "2022-07-25T21:15:37.524Z", + "updated_at": "2022-07-25T21:15:37.524Z", + "deleted_at": "2022-07-25T21:15:37.524Z" + } + ], + "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" +} + ''' + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + # Get all stores + api_response = await api_instance.list_stores( + page_size=1, + continuation_token="continuation_token_example", + ) + self.assertIsInstance(api_response, ListStoresResponse) + self.assertEqual(api_response.continuation_token, + "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==") + store1 = Store( + id="01YCP46JKYM8FJCQ37NMBYHE5X", + name="store1", + created_at=datetime.fromisoformat("2022-07-25T21:15:37.524+00:00"), + updated_at=datetime.fromisoformat("2022-07-25T21:15:37.524+00:00"), + deleted_at=datetime.fromisoformat("2022-07-25T21:15:37.524+00:00"), + ) + store2 = Store( + id="01YCP46JKYM8FJCQ37NMBYHE6X", + name="store2", + created_at=datetime.fromisoformat("2022-07-25T21:15:37.524+00:00"), + updated_at=datetime.fromisoformat("2022-07-25T21:15:37.524+00:00"), + deleted_at=datetime.fromisoformat("2022-07-25T21:15:37.524+00:00"), + ) + + stores = [store1, store2] + self.assertEqual(api_response.stores, stores) + mock_request.assert_called_once_with( + 'GET', + 'http://api.{{sampleApiDomain}}/stores', + headers=ANY, + query_params=[('page_size', 1), ('continuation_token', + 'continuation_token_example')], + _preload_content=ANY, + _request_timeout=None + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, 'request') + async def test_read(self, mock_request): + """Test case for read + + Get tuples from the store that matches a query, without following userset rewrite rules # noqa: E501 + """ + response_body = ''' + { + "tuples": [ + { + "key": { + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + "relation": "reader", + "object": "document:2021-budget" + }, + "timestamp": "2021-10-06T15:32:11.128Z" + } + ] +} + ''' + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + async with openfga_sdk.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = ReadRequest( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + authorization_model_id="01G5JAVJ41T49E9TT3SKVS7X1J", + page_size=50, + continuation_token="eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==", + ) + api_response = await api_instance.read( + body=body, + ) + self.assertIsInstance(api_response, ReadResponse) + key = TupleKey(user="user:81684243-9356-4421-8fbf-a4f8d36aa31b",relation="reader",object="document:2021-budget") + timestamp = datetime.fromisoformat("2021-10-06T15:32:11.128+00:00") + expected_data = ReadResponse(tuples=[Tuple(key=key, timestamp=timestamp)]) + self.assertEqual(api_response, expected_data) + mock_request.assert_called_once_with( + 'POST', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/read', + headers=ANY, + query_params=[], + post_params=[], + body={"tuple_key":{"object":"document:2021-budget","relation":"reader","user":"user:81684243-9356-4421-8fbf-a4f8d36aa31b"},"authorization_model_id":"01G5JAVJ41T49E9TT3SKVS7X1J","page_size":50,"continuation_token":"eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ=="}, + _preload_content=ANY, + _request_timeout=None + ) + + @patch.object(rest.RESTClientObject, 'request') + async def test_read_assertions(self, mock_request): + """Test case for read_assertions + + Read assertions for an authorization model ID # noqa: E501 + """ + response_body = ''' +{ + "authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J", + "assertions": [ + { + "tuple_key": { + "object": "document:2021-budget", + "relation": "reader", + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b" + }, + "expectation": true + } + ] +} + ''' + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + async with openfga_sdk.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + api_response = await api_instance.read_assertions( + "01G5JAVJ41T49E9TT3SKVS7X1J", + ) + self.assertIsInstance(api_response, ReadAssertionsResponse) + self.assertEqual(api_response.authorization_model_id, '01G5JAVJ41T49E9TT3SKVS7X1J') + assertion=Assertion( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + expectation=True, + ) + self.assertEqual(api_response.assertions, [assertion]) + mock_request.assert_called_once_with( + 'GET', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/assertions/01G5JAVJ41T49E9TT3SKVS7X1J', + headers=ANY, + query_params=[], + _preload_content=ANY, + _request_timeout=None + ) + + @patch.object(rest.RESTClientObject, 'request') + async def test_read_authorization_model(self, mock_request): + """Test case for read_authorization_model + + Return a particular version of an authorization model # noqa: E501 + """ + response_body = ''' +{ + "authorization_model": { + "id": "01G5JAVJ41T49E9TT3SKVS7X1J", + "type_definitions": [ + { + "type": "document", + "relations": { + "reader": { + "union": { + "child": [ + { + "this": {} + }, + { + "computedUserset": { + "object": "", + "relation": "writer" + } + } + ] + } + }, + "writer": { + "this": {} + } + } + } + ] + } +} + ''' + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + # Enter a context with an instance of the API client + async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + + # Return a particular version of an authorization model + api_response = await api_instance.read_authorization_model( + "01G5JAVJ41T49E9TT3SKVS7X1J", + ) + self.assertIsInstance(api_response, ReadAuthorizationModelResponse) + type_definitions = [ + TypeDefinition( + type="document", + relations=dict( + reader=Userset( + union=Usersets( + child=[ + Userset(this=dict()), + Userset(computed_userset=ObjectRelation( + object="", + relation="writer", + )), + ], + ), + ), + writer=Userset( + this=dict(), + ), + ) + ) + ] + authorization_model = AuthorizationModel(id='01G5JAVJ41T49E9TT3SKVS7X1J', + type_definitions=type_definitions) + self.assertEqual(api_response.authorization_model, authorization_model) + mock_request.assert_called_once_with( + 'GET', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/authorization-models/01G5JAVJ41T49E9TT3SKVS7X1J', + headers=ANY, + query_params=[], + _preload_content=ANY, + _request_timeout=None + ) + + @patch.object(rest.RESTClientObject, 'request') + async def test_read_changes(self, mock_request): + """Test case for read_changes + + Return a list of all the tuple changes # noqa: E501 + """ + response_body = ''' +{ + "changes": [ + { + "tuple_key": { + "object": "document:2021-budget", + "relation": "reader", + "user": "user:81684243-9356-4421-8fbf-a4f8d36aa31b" + }, + "operation": "TUPLE_OPERATION_WRITE", + "timestamp": "2022-07-26T15:55:55.809Z" + } + ], + "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" +} + ''' + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + # Enter a context with an instance of the API client + async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + + # Return a particular version of an authorization model + api_response = await api_instance.read_changes( + page_size=1, + continuation_token="abcdefg", + type="document" + ) + self.assertIsInstance(api_response, ReadChangesResponse) + changes = TupleChange( + tuple_key=TupleKey(object="document:2021-budget",relation="reader",user="user:81684243-9356-4421-8fbf-a4f8d36aa31b"), + operation=TupleOperation.WRITE, + timestamp=datetime.fromisoformat("2022-07-26T15:55:55.809+00:00")) + read_changes = ReadChangesResponse( + continuation_token='eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==', + changes=[changes]) + self.assertEqual(api_response, read_changes) + mock_request.assert_called_once_with( + 'GET', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/changes', + headers=ANY, + query_params=[('type', 'document'), ('page_size', 1), ('continuation_token', 'abcdefg') ], + _preload_content=ANY, + _request_timeout=None + ) + + @patch.object(rest.RESTClientObject, 'request') + async def test_write(self, mock_request): + """Test case for write + + Add tuples from the store # noqa: E501 + """ + response_body = '{}' + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + # Enter a context with an instance of the API client + async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + + # example passing only required values which don't have defaults set + + body = WriteRequest( + writes=TupleKeys( + tuple_keys=[ + TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ) + ], + ), + authorization_model_id="01G5JAVJ41T49E9TT3SKVS7X1J", + ) + await api_instance.write( + body, + ) + mock_request.assert_called_once_with( + 'POST', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/write', + headers=ANY, + query_params=[], + post_params=[], + body={"writes":{"tuple_keys":[{"object":"document:2021-budget","relation":"reader","user":"user:81684243-9356-4421-8fbf-a4f8d36aa31b"}]},"authorization_model_id":"01G5JAVJ41T49E9TT3SKVS7X1J"}, + _preload_content=ANY, + _request_timeout=None + ) + + @patch.object(rest.RESTClientObject, 'request') + async def test_write_delete(self, mock_request): + """Test case for write + + Delete tuples from the store # noqa: E501 + """ + response_body = '{}' + mock_request.return_value = mock_response(response_body, 200) + configuration = self.configuration + configuration.store_id = store_id + # Enter a context with an instance of the API client + async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + + # example passing only required values which don't have defaults set + + body = WriteRequest( + deletes=TupleKeys( + tuple_keys=[ + TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ) + ], + ), + authorization_model_id="01G5JAVJ41T49E9TT3SKVS7X1J", + ) + await api_instance.write( + body, + ) + mock_request.assert_called_once_with( + 'POST', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/write', + headers=ANY, + query_params=[], + post_params=[], + body={"deletes":{"tuple_keys":[{"object":"document:2021-budget","relation":"reader","user":"user:81684243-9356-4421-8fbf-a4f8d36aa31b"}]},"authorization_model_id":"01G5JAVJ41T49E9TT3SKVS7X1J"}, + _preload_content=ANY, + _request_timeout=None + ) + + @patch.object(rest.RESTClientObject, 'request') + async def test_write_assertions(self, mock_request): + """Test case for write_assertions + + Upsert assertions for an authorization model ID # noqa: E501 + """ + response_body = '' + mock_request.return_value = mock_response(response_body, 204) + configuration = self.configuration + configuration.store_id = store_id + # Enter a context with an instance of the API client + async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + + # example passing only required values which don't have defaults set + body = WriteAssertionsRequest( + assertions=[ + Assertion( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + expectation=True, + ) + ], + ) + # Upsert assertions for an authorization model ID + await api_instance.write_assertions( + authorization_model_id="xyz0123", + body=body, + ) + mock_request.assert_called_once_with( + 'PUT', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/assertions/xyz0123', + headers=ANY, + query_params=[], + post_params=[], + body={"assertions":[{"expectation":True,"tuple_key":{"object":"document:2021-budget","relation":"reader","user":"user:81684243-9356-4421-8fbf-a4f8d36aa31b"}}]}, + _preload_content=ANY, + _request_timeout=None + ) + + @patch.object(rest.RESTClientObject, 'request') + async def test_write_authorization_model(self, mock_request): + """Test case for write_authorization_model + + Create a new authorization model # noqa: E501 + """ + response_body = '{"authorization_model_id": "01G5JAVJ41T49E9TT3SKVS7X1J"}' + mock_request.return_value = mock_response(response_body, 201) + configuration = self.configuration + configuration.store_id = store_id + async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + + # example passing only required values which don't have defaults set + body = WriteAuthorizationModelRequest( + type_definitions=[ + TypeDefinition( + type="document", + relations=dict( + writer=Userset( + this=dict(), + ), + reader=Userset( + union=Usersets( + child=[ + Userset(this=dict()), + Userset(computed_userset=ObjectRelation( + object="", + relation="writer", + )), + ], + ), + ), + ) + ), + ], + ) + # Create a new authorization model + api_response = await api_instance.write_authorization_model( + body + ) + self.assertIsInstance(api_response, WriteAuthorizationModelResponse) + expected_response = WriteAuthorizationModelResponse( + authorization_model_id='01G5JAVJ41T49E9TT3SKVS7X1J' + ) + self.assertEqual(api_response, expected_response) + mock_request.assert_called_once_with( + 'POST', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/authorization-models', + headers=ANY, + query_params=[], + post_params=[], + body={"type_definitions":[{"type":"document","relations":{"writer":{"this":{}},"reader":{"union":{"child":[{"this":{}},{"computedUserset":{"object":"","relation":"writer"}}]}}}}]}, + _preload_content=ANY, + _request_timeout=None + ) + + def test_default_scheme(self): + """ + Ensure default scheme is https + """ + configuration = {{packageName}}.Configuration( + api_host='localhost' + ) + self.assertEqual(configuration.api_scheme, 'https') + + def test_host_port(self): + """ + Ensure host has port will not raise error + """ + configuration = {{packageName}}.Configuration( + api_host='localhost:3000' + ) + self.assertEqual(configuration.api_host, 'localhost:3000') + + def test_configuration_missing_host(self): + """ + Test whether ApiTypeError is raised if configuration does not have host specified + """ + configuration = {{packageName}}.Configuration( + api_scheme='http' + ) + self.assertRaises(ApiTypeError, configuration.is_valid) + + def test_configuration_missing_scheme(self): + """ + Test whether ApiTypeError is raised if configuration does not have scheme specified + """ + configuration = {{packageName}}.Configuration( + api_host='localhost' + ) + configuration.api_scheme = None + self.assertRaises(ApiTypeError, configuration.is_valid) + + def test_configuration_bad_scheme(self): + """ + Test whether ApiValueError is raised if scheme is bad + """ + configuration = {{packageName}}.Configuration( + api_host='localhost', + api_scheme='foo' + ) + self.assertRaises(ApiValueError, configuration.is_valid) + + def test_configuration_bad_host(self): + """ + Test whether ApiValueError is raised if host is bad + """ + configuration = {{packageName}}.Configuration( + api_host='/', + api_scheme='foo' + ) + self.assertRaises(ApiValueError, configuration.is_valid) + + def test_configuration_has_path(self): + """ + Test whether ApiValueError is raised if host has path + """ + configuration = {{packageName}}.Configuration( + api_host='localhost/mypath', + api_scheme='http' + ) + self.assertRaises(ApiValueError, configuration.is_valid) + + def test_configuration_has_query(self): + """ + Test whether ApiValueError is raised if host has query + """ + configuration = {{packageName}}.Configuration( + api_host='localhost?mypath=foo', + api_scheme='http' + ) + self.assertRaises(ApiValueError, configuration.is_valid) + + async def test_bad_configuration_read_authorization_model(self): + """ + Test whether ApiTypeError is raised for API (reading authorization models) + with configuration is having incorrect API scheme + """ + configuration = {{packageName}}.Configuration( + api_scheme = 'bad', + api_host = "api.{{sampleApiDomain}}", + ) + configuration.store_id = 'xyz123' + # Enter a context with an instance of the API client + async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + + # expects ApiTypeError to be thrown because api_scheme is bad + with self.assertRaises(ApiValueError): + await api_instance.read_authorization_models( + page_size= 1, + continuation_token= "abcdefg" + ) + + async def test_configuration_missing_storeid(self): + """ + Test whether ApiTypeError is raised for API (reading authorization models) + required store ID but configuration is missing store ID + """ + configuration = {{packageName}}.Configuration( + api_scheme = 'http', + api_host = "api.{{sampleApiDomain}}", + ) + # Notice the store_id is not set + # Enter a context with an instance of the API client + async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = open_fga_api.OpenFgaApi(api_client) + + # expects ApiTypeError to be thrown because store_id is not specified + with self.assertRaises(ApiTypeError): + await api_instance.read_authorization_models( + page_size= 1, + continuation_token= "abcdefg" + ) + + @patch.object(rest.RESTClientObject, 'request') + async def test_400_error(self, mock_request): + """ + Test to ensure 400 errors are handled properly + """ + response_body = ''' +{ + "code": "validation_error", + "message": "Generic validation error" +} + ''' + mock_request.side_effect = ValidationException(http_resp=http_mock_response(response_body, 400)) + + configuration = self.configuration + configuration.store_id = store_id + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = CheckRequest( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ) + with self.assertRaises(ValidationException) as api_exception: + await api_instance.check( + body=body, + ) + self.assertIsInstance(api_exception.exception.parsed_exception, ValidationErrorMessageResponse) + self.assertEqual(api_exception.exception.parsed_exception.code, ErrorCode.VALIDATION_ERROR) + self.assertEqual(api_exception.exception.parsed_exception.message, "Generic validation error") + + + @patch.object(rest.RESTClientObject, 'request') + async def test_404_error(self, mock_request): + """ + Test to ensure 404 errors are handled properly + """ + response_body = ''' +{ + "code": "undefined_endpoint", + "message": "Endpoint not enabled" +} + ''' + mock_request.side_effect = NotFoundException(http_resp=http_mock_response(response_body, 404)) + + configuration = self.configuration + configuration.store_id = store_id + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = CheckRequest( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ) + with self.assertRaises(NotFoundException) as api_exception: + await api_instance.check( + body=body, + ) + self.assertIsInstance(api_exception.exception.parsed_exception, PathUnknownErrorMessageResponse) + self.assertEqual(api_exception.exception.parsed_exception.code, NotFoundErrorCode.UNDEFINED_ENDPOINT) + self.assertEqual(api_exception.exception.parsed_exception.message, "Endpoint not enabled") + + @patch.object(rest.RESTClientObject, 'request') + async def test_429_error_no_retry(self, mock_request): + """ + Test to ensure 429 errors are handled properly. + For this case, there is no retry configured + """ + response_body = ''' +{ + "code": "rate_limit_exceeded", + "message": "Rate Limit exceeded" +} + ''' + mock_request.side_effect = RateLimitExceededError(http_resp=http_mock_response(response_body, 429)) + + retry = {{packageName}}.configuration.RetryParams(0, 10) + configuration = self.configuration + configuration.store_id = store_id + configuration.retry_params = retry + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = CheckRequest( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ) + with self.assertRaises(RateLimitExceededError) as api_exception: + await api_instance.check( + body=body, + ) + self.assertIsInstance(api_exception.exception, RateLimitExceededError) + mock_request.assert_called() + self.assertEqual(mock_request.call_count, 1) + + @patch.object(rest.RESTClientObject, 'request') + async def test_429_error_first_error(self, mock_request): + """ + Test to ensure 429 errors are handled properly. + For this case, retry is configured and only the first time has error + """ + response_body = '{"allowed": true, "resolution": "1234"}' + error_response_body = ''' +{ + "code": "rate_limit_exceeded", + "message": "Rate Limit exceeded" +} + ''' + mock_request.side_effect = [RateLimitExceededError(http_resp=http_mock_response(error_response_body, 429)), mock_response(response_body, 200)] + + retry = {{packageName}}.configuration.RetryParams(1, 10) + configuration = self.configuration + configuration.store_id = store_id + configuration.retry_params = retry + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = CheckRequest( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ) + api_response = await api_instance.check( + body=body, + ) + self.assertIsInstance(api_response, CheckResponse) + self.assertTrue(api_response.allowed) + mock_request.assert_called() + self.assertEqual(mock_request.call_count, 2) + + + @patch.object(rest.RESTClientObject, 'request') + async def test_500_error(self, mock_request): + """ + Test to ensure 500 errors are handled properly + """ + response_body = ''' +{ + "code": "internal_error", + "message": "Internal Server Error" +} + ''' + mock_request.side_effect = ServiceException(http_resp=http_mock_response(response_body, 500)) + + configuration = self.configuration + configuration.store_id = store_id + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = CheckRequest( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ) + with self.assertRaises(ServiceException) as api_exception: + await api_instance.check( + body=body, + ) + self.assertIsInstance(api_exception.exception.parsed_exception, InternalErrorMessageResponse) + self.assertEqual(api_exception.exception.parsed_exception.code, InternalErrorCode.INTERNAL_ERROR) + self.assertEqual(api_exception.exception.parsed_exception.message, "Internal Server Error") + + @patch.object(rest.RESTClientObject, 'request') + async def test_check_api_token(self, mock_request): + """Test case for API token + + Check whether API token is send when configuration specifies credential method as api_token + """ + + # First, mock the response + response_body = '{"allowed": true}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + configuration.credentials = Credentials(method='api_token', configuration=CredentialConfiguration(api_token='TOKEN1')) + async with {{packageName}}.ApiClient(configuration) as api_client: + api_instance = open_fga_api.OpenFgaApi(api_client) + body = CheckRequest( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ) + api_response = await api_instance.check( + body=body, + ) + self.assertIsInstance(api_response, CheckResponse) + self.assertTrue(api_response.allowed) + # Make sure the API was called with the right data + expectedHeader = urllib3.response.HTTPHeaderDict({'Accept': 'application/json', 'Content-Type': 'application/json', 'User-Agent': 'openfga-sdk /{{packageVersion}}', 'Authorization': 'Bearer TOKEN1'}) + mock_request.assert_called_once_with( + 'POST', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/check', + headers=expectedHeader, + query_params=[], + post_params=[], + body={"tuple_key":{"object":"document:2021-budget","relation":"reader","user":"user:81684243-9356-4421-8fbf-a4f8d36aa31b"}}, + _preload_content=ANY, + _request_timeout=None + ) + + @patch.object(rest.RESTClientObject, 'request') + async def test_check_custom_header(self, mock_request): + """Test case for custom header + + Check whether custom header can be added + """ + + # First, mock the response + response_body = '{"allowed": true}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + async with {{packageName}}.ApiClient(configuration) as api_client: + api_client.set_default_header("Custom Header", "custom value") + api_instance = open_fga_api.OpenFgaApi(api_client) + body = CheckRequest( + tuple_key=TupleKey( + object="document:2021-budget", + relation="reader", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", + ), + ) + api_response = await api_instance.check( + body=body, + ) + self.assertIsInstance(api_response, CheckResponse) + self.assertTrue(api_response.allowed) + # Make sure the API was called with the right data + expectedHeader = urllib3.response.HTTPHeaderDict({'Accept': 'application/json', 'Content-Type': 'application/json', 'User-Agent': 'openfga-sdk /{{packageVersion}}', 'Custom Header': 'custom value'}) + mock_request.assert_called_once_with( + 'POST', + 'http://api.{{sampleApiDomain}}/stores/d12345abc/check', + headers=expectedHeader, + query_params=[], + post_params=[], + body={"tuple_key":{"object":"document:2021-budget","relation":"reader","user":"user:81684243-9356-4421-8fbf-a4f8d36aa31b"}}, + _preload_content=ANY, + _request_timeout=None + ) + +{{/operations}} + +if __name__ == '__main__': + unittest.main() diff --git a/config/clients/python/template/asyncio/rest.mustache b/config/clients/python/template/asyncio/rest.mustache new file mode 100644 index 000000000..fcb7e87c7 --- /dev/null +++ b/config/clients/python/template/asyncio/rest.mustache @@ -0,0 +1,254 @@ +# coding: utf-8 + +{{>partial_header}} + +import io +import json +import logging +import re +import ssl + +import aiohttp +# python 2 and python 3 compatibility library +from six.moves.urllib.parse import urlencode + +from {{packageName}}.exceptions import ApiException, UnauthorizedException, ForbiddenException, NotFoundException, RateLimitExceededError, ServiceException, ValidationException, ApiValueError + +logger = logging.getLogger(__name__) + + +class RESTResponse(io.IOBase): + + def __init__(self, resp, data): + self.aiohttp_response = resp + self.status = resp.status + self.reason = resp.reason + self.data = data + + def getheaders(self): + """Returns a CIMultiDictProxy of the response headers.""" + return self.aiohttp_response.headers + + def getheader(self, name, default=None): + """Returns a given response header.""" + return self.aiohttp_response.headers.get(name, default) + + +class RESTClientObject(object): + + def __init__(self, configuration, pools_size=4, maxsize=None): + + # maxsize is number of requests to host that are allowed in parallel + if maxsize is None: + maxsize = configuration.connection_pool_maxsize + + ssl_context = ssl.create_default_context(cafile=configuration.ssl_ca_cert) + if configuration.cert_file: + ssl_context.load_cert_chain( + configuration.cert_file, keyfile=configuration.key_file + ) + + if not configuration.verify_ssl: + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + connector = aiohttp.TCPConnector( + limit=maxsize, + ssl=ssl_context + ) + + self.proxy = configuration.proxy + self.proxy_headers = configuration.proxy_headers + + # https pool manager + self.pool_manager = aiohttp.ClientSession( + connector=connector, + trust_env=True + ) + + async def close(self): + await self.pool_manager.close() + + async def request(self, method, url, query_params=None, headers=None, + body=None, post_params=None, _preload_content=True, + _request_timeout=None): + """Execute request + + :param method: http request method + :param url: http request url + :param query_params: query parameters in the url + :param headers: http request headers + :param body: request json body, for `application/json` + :param post_params: request post parameters, + `application/x-www-form-urlencoded` + and `multipart/form-data` + :param _preload_content: this is a non-applicable field for + the AiohttpClient. + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + """ + method = method.upper() + assert method in ['GET', 'HEAD', 'DELETE', 'POST', 'PUT', + 'PATCH', 'OPTIONS'] + + if post_params and body: + raise ApiValueError( + "body parameter cannot be used with post_params parameter." + ) + + post_params = post_params or {} + headers = headers or {} + timeout = _request_timeout or 5 * 60 + + if 'Content-Type' not in headers: + headers['Content-Type'] = 'application/json' + + args = { + "method": method, + "url": url, + "timeout": timeout, + "headers": headers + } + + if self.proxy: + args["proxy"] = self.proxy + if self.proxy_headers: + args["proxy_headers"] = self.proxy_headers + + if query_params: + args["url"] += '?' + urlencode(query_params) + + # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE` + if method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']: + if re.search('json', headers['Content-Type'], re.IGNORECASE): + if body is not None: + body = json.dumps(body) + args["data"] = body + elif headers['Content-Type'] == 'application/x-www-form-urlencoded': # noqa: E501 + args["data"] = aiohttp.FormData(post_params) + elif headers['Content-Type'] == 'multipart/form-data': + # must del headers['Content-Type'], or the correct + # Content-Type which generated by aiohttp + del headers['Content-Type'] + data = aiohttp.FormData() + for param in post_params: + k, v = param + if isinstance(v, tuple) and len(v) == 3: + data.add_field(k, + value=v[1], + filename=v[0], + content_type=v[2]) + else: + data.add_field(k, v) + args["data"] = data + + # Pass a `bytes` parameter directly in the body to support + # other content types than Json when `body` argument is provided + # in serialized form + elif isinstance(body, bytes): + args["data"] = body + else: + # Cannot generate the request from given parameters + msg = """Cannot prepare a request message for provided + arguments. Please check that your arguments match + declared content type.""" + raise ApiException(status=0, reason=msg) + + r = await self.pool_manager.request(**args) + if _preload_content: + + data = await r.read() + r = RESTResponse(r, data) + + # log response body + logger.debug("response body: %s", r.data) + + if not 200 <= r.status <= 299: + if r.status == 401: + raise UnauthorizedException(http_resp=r) + + if r.status == 403: + raise ForbiddenException(http_resp=r) + + if r.status == 404: + raise NotFoundException(http_resp=r) + + if r.status == 429: + raise RateLimitExceededError(http_resp=r) + + if 500 <= r.status <= 599: + raise ServiceException(http_resp=r) + + raise ApiException(http_resp=r) + + return r + + async def GET(self, url, headers=None, query_params=None, + _preload_content=True, _request_timeout=None): + return (await self.request("GET", url, + headers=headers, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + query_params=query_params)) + + async def HEAD(self, url, headers=None, query_params=None, + _preload_content=True, _request_timeout=None): + return (await self.request("HEAD", url, + headers=headers, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + query_params=query_params)) + + async def OPTIONS(self, url, headers=None, query_params=None, + post_params=None, body=None, _preload_content=True, + _request_timeout=None): + return (await self.request("OPTIONS", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body)) + + async def DELETE(self, url, headers=None, query_params=None, body=None, + _preload_content=True, _request_timeout=None): + return (await self.request("DELETE", url, + headers=headers, + query_params=query_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body)) + + async def POST(self, url, headers=None, query_params=None, + post_params=None, body=None, _preload_content=True, + _request_timeout=None): + return (await self.request("POST", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body)) + + async def PUT(self, url, headers=None, query_params=None, post_params=None, + body=None, _preload_content=True, _request_timeout=None): + return (await self.request("PUT", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body)) + + async def PATCH(self, url, headers=None, query_params=None, + post_params=None, body=None, _preload_content=True, + _request_timeout=None): + return (await self.request("PATCH", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body)) diff --git a/config/clients/python/template/configuration.mustache b/config/clients/python/template/configuration.mustache new file mode 100644 index 000000000..fdb89e906 --- /dev/null +++ b/config/clients/python/template/configuration.mustache @@ -0,0 +1,600 @@ +# coding: utf-8 + +{{>partial_header}} + +import copy +import logging +{{^asyncio}} +import multiprocessing +{{/asyncio}} +import sys +import urllib3 + +import six +from six.moves import http_client as httplib +from urllib.parse import urlparse +from {{packageName}}.exceptions import ApiTypeError, ApiValueError + + +JSON_SCHEMA_VALIDATION_KEYWORDS = { + 'multipleOf', 'maximum', 'exclusiveMaximum', + 'minimum', 'exclusiveMinimum', 'maxLength', + 'minLength', 'pattern', 'maxItems', 'minItems' +} + +class RetryParams(object): + """NOTE: This class is auto generated by OpenAPI Generator + + Ref: https://openapi-generator.tech + Do not edit the class manually. + + Retry configuration in case of HTTP too many request + + :param max_retry: Maximum number of retry + :param min_wait_in_ms: Minimum wait (in ms) between retry + """ + def __init__(self, max_retry={{{defaultMaxRetry}}}, min_wait_in_ms={{{defaultMinWaitInMs}}}): + self._max_retry = max_retry + self._min_wait_in_ms = min_wait_in_ms + + @property + def max_retry(self): + """ + Return the maximum number of retry + """ + return self._max_retry + + @max_retry.setter + def max_retry(self, value): + """ + Update the maximum number of retry + """ + self._max_retry = value + + @property + def min_wait_in_ms(self): + """ + Return the minimum wait (in ms) in between retry + """ + return self._min_wait_in_ms + + @min_wait_in_ms.setter + def min_wait_in_ms(self, value): + """ + Update the minimum wait (in ms) in between retry + """ + self._min_wait_in_ms = value + + +class Configuration(object): + """NOTE: This class is auto generated by OpenAPI Generator + + Ref: https://openapi-generator.tech + Do not edit the class manually. + + :param api_scheme: Whether connection is 'https' or 'http'. Default as 'https' + :param api_host: Base url + :param store_id: ID of store for API + :param credentials: Configuration for obtaining authentication credential + :param retry_params: Retry parameters upon HTTP too many request + :param api_key: Dict to store API key(s). + Each entry in the dict specifies an API key. + The dict key is the name of the security scheme in the OAS specification. + The dict value is the API key secret. + :param api_key_prefix: Dict to store API prefix (e.g. Bearer) + The dict key is the name of the security scheme in the OAS specification. + The dict value is an API key prefix when generating the auth data. + :param username: Username for HTTP basic authentication + :param password: Password for HTTP basic authentication + :param discard_unknown_keys: Boolean value indicating whether to discard + unknown properties. A server may send a response that includes additional + properties that are not known by the client in the following scenarios: + 1. The OpenAPI document is incomplete, i.e. it does not match the server + implementation. + 2. The client was generated using an older version of the OpenAPI document + and the server has been upgraded since then. + If a schema in the OpenAPI document defines the additionalProperties attribute, + then all undeclared properties received by the server are injected into the + additional properties map. In that case, there are undeclared properties, and + nothing to discard. + :param disabled_client_side_validations (string): Comma-separated list of + JSON schema validation keywords to disable JSON schema structural validation + rules. The following keywords may be specified: multipleOf, maximum, + exclusiveMaximum, minimum, exclusiveMinimum, maxLength, minLength, pattern, + maxItems, minItems. + By default, the validation is performed for data generated locally by the client + and data received from the server, independent of any validation performed by + the server side. If the input data does not satisfy the JSON schema validation + rules specified in the OpenAPI document, an exception is raised. + If disabled_client_side_validations is set, structural validation is + disabled. This can be useful to troubleshoot data validation problem, such as + when the OpenAPI document validation rules do not match the actual API data + received by the server. + :param server_index: Index to servers configuration. + :param server_variables: Mapping with string values to replace variables in + templated server configuration. The validation of enums is performed for + variables with defined enum values before. + :param server_operation_index: Mapping from operation ID to an index to server + configuration. + :param server_operation_variables: Mapping from operation ID to a mapping with + string values to replace variables in templated server configuration. + The validation of enums is performed for variables with defined enum values before. + :param ssl_ca_cert: str - the path to a file of concatenated CA certificates + in PEM format + """ + + _default = None + + def __init__(self, api_scheme="https", api_host=None, + store_id=None, + credentials=None, + retry_params=None, + api_key=None, api_key_prefix=None, + username=None, password=None, + discard_unknown_keys=False, + disabled_client_side_validations="", + server_index=None, server_variables=None, + server_operation_index=None, server_operation_variables=None, + ssl_ca_cert=None, + ): + """Constructor + """ + self._scheme = api_scheme + self._base_path = api_host + self._store_id = store_id + self._credentials = credentials + if retry_params is not None: + self._retry_params = retry_params + else: + # use the default parameters + self._retry_params = RetryParams() + """Default Base url + """ + self.server_index = 0 + self.server_operation_index = server_operation_index or {} + """Default server index + """ + self.server_variables = server_variables or {} + self.server_operation_variables = server_operation_variables or {} + """Default server variables + """ + self.temp_folder_path = None + """Temp file folder for downloading files + """ + # Authentication Settings + self.api_key = {} + if api_key: + self.api_key = api_key + """dict to store API key(s) + """ + self.api_key_prefix = {} + if api_key_prefix: + self.api_key_prefix = api_key_prefix + """dict to store API prefix (e.g. Bearer) + """ + self.refresh_api_key_hook = None + """function hook to refresh API key if expired + """ + self.username = username + """Username for HTTP basic authentication + """ + self.password = password + """Password for HTTP basic authentication + """ + self.discard_unknown_keys = discard_unknown_keys + self.disabled_client_side_validations = disabled_client_side_validations + self.logger = {} + """Logging Settings + """ + self.logger["package_logger"] = logging.getLogger("{{packageName}}") + self.logger["urllib3_logger"] = logging.getLogger("urllib3") + self.logger_format = '%(asctime)s %(levelname)s %(message)s' + """Log format + """ + self.logger_stream_handler = None + """Log stream handler + """ + self.logger_file_handler = None + """Log file handler + """ + self.logger_file = None + """Debug file location + """ + self.debug = False + """Debug switch + """ + + self.verify_ssl = True + """SSL/TLS verification + Set this to false to skip verifying SSL certificate when calling API + from https server. + """ + self.ssl_ca_cert = ssl_ca_cert + """Set this to customize the certificate file to verify the peer. + """ + self.cert_file = None + """client certificate file + """ + self.key_file = None + """client key file + """ + self.assert_hostname = None + """Set this to True/False to enable/disable SSL hostname verification. + """ + + {{#asyncio}} + self.connection_pool_maxsize = 100 + """This value is passed to the aiohttp to limit simultaneous connections. + Default values is 100, None means no-limit. + """ + {{/asyncio}} + {{^asyncio}} + self.connection_pool_maxsize = multiprocessing.cpu_count() * 5 + """urllib3 connection pool's maximum number of connections saved + per pool. urllib3 uses 1 connection as default value, but this is + not the best value when you are making a lot of possibly parallel + requests to the same host, which is often the case here. + cpu_count * 5 is used as default value to increase performance. + """ + {{/asyncio}} + + self.proxy = None + """Proxy URL + """ + self.proxy_headers = None + """Proxy headers + """ + self.safe_chars_for_path_param = '' + """Safe chars for path_param + """ + self.retries = None + """Adding retries to override urllib3 default value 3 + """ + # Enable client side validation + self.client_side_validation = True + + self.socket_options = None + """Options to pass down to the underlying urllib3 socket + """ + + def __deepcopy__(self, memo): + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + for k, v in self.__dict__.items(): + if k not in ('logger', 'logger_file_handler'): + setattr(result, k, copy.deepcopy(v, memo)) + # shallow copy of loggers + result.logger = copy.copy(self.logger) + # use setters to configure loggers + result.logger_file = self.logger_file + result.debug = self.debug + return result + + def __setattr__(self, name, value): + object.__setattr__(self, name, value) + if name == 'disabled_client_side_validations': + s = set(filter(None, value.split(','))) + for v in s: + if v not in JSON_SCHEMA_VALIDATION_KEYWORDS: + raise ApiValueError( + "Invalid keyword: '{0}''".format(v)) + self._disabled_client_side_validations = s + + @classmethod + def set_default(cls, default): + """Set default instance of configuration. + + It stores default configuration, which can be + returned by get_default_copy method. + + :param default: object of Configuration + """ + cls._default = copy.deepcopy(default) + + @classmethod + def get_default_copy(cls): + """Return new instance of configuration. + + This method returns newly created, based on default constructor, + object of Configuration class or returns a copy of default + configuration passed by the set_default method. + + :return: The configuration object. + """ + if cls._default is not None: + return copy.deepcopy(cls._default) + return Configuration() + + @property + def logger_file(self): + """The logger file. + + If the logger_file is None, then add stream handler and remove file + handler. Otherwise, add file handler and remove stream handler. + + :param value: The logger_file path. + :type: str + """ + return self.__logger_file + + @logger_file.setter + def logger_file(self, value): + """The logger file. + + If the logger_file is None, then add stream handler and remove file + handler. Otherwise, add file handler and remove stream handler. + + :param value: The logger_file path. + :type: str + """ + self.__logger_file = value + if self.__logger_file: + # If set logging file, + # then add file handler and remove stream handler. + self.logger_file_handler = logging.FileHandler(self.__logger_file) + self.logger_file_handler.setFormatter(self.logger_formatter) + for _, logger in six.iteritems(self.logger): + logger.addHandler(self.logger_file_handler) + + @property + def debug(self): + """Debug status + + :param value: The debug status, True or False. + :type: bool + """ + return self.__debug + + @debug.setter + def debug(self, value): + """Debug status + + :param value: The debug status, True or False. + :type: bool + """ + self.__debug = value + if self.__debug: + # if debug status is True, turn on debug logging + for _, logger in six.iteritems(self.logger): + logger.setLevel(logging.DEBUG) + # turn on httplib debug + httplib.HTTPConnection.debuglevel = 1 + else: + # if debug status is False, turn off debug logging, + # setting log level to default `logging.WARNING` + for _, logger in six.iteritems(self.logger): + logger.setLevel(logging.WARNING) + # turn off httplib debug + httplib.HTTPConnection.debuglevel = 0 + + @property + def logger_format(self): + """The logger format. + + The logger_formatter will be updated when sets logger_format. + + :param value: The format string. + :type: str + """ + return self.__logger_format + + @logger_format.setter + def logger_format(self, value): + """The logger format. + + The logger_formatter will be updated when sets logger_format. + + :param value: The format string. + :type: str + """ + self.__logger_format = value + self.logger_formatter = logging.Formatter(self.__logger_format) + + def get_api_key_with_prefix(self, identifier, alias=None): + """Gets API key (with prefix if set). + + :param identifier: The identifier of apiKey. + :param alias: The alternative identifier of apiKey. + :return: The token for api key authentication. + """ + if self.refresh_api_key_hook is not None: + self.refresh_api_key_hook(self) + key = self.api_key.get(identifier, self.api_key.get(alias) if alias is not None else None) + if key: + prefix = self.api_key_prefix.get(identifier) + if prefix: + return "%s %s" % (prefix, key) + else: + return key + + def get_basic_auth_token(self): + """Gets HTTP basic authentication header (string). + + :return: The token for basic HTTP authentication. + """ + username = "" + if self.username is not None: + username = self.username + password = "" + if self.password is not None: + password = self.password + return urllib3.util.make_headers( + basic_auth=username + ':' + password + ).get('authorization') + + def auth_settings(self): + """Gets Auth Settings dict for api client. + + :return: The Auth Settings information dict. + """ + auth = {} + return auth + + def to_debug_report(self): + """Gets the essential information for debugging. + + :return: The report for debugging. + """ + return "Python SDK Debug Report:\n"\ + "OS: {env}\n"\ + "Python Version: {pyversion}\n"\ + "Version of the API: {{version}}\n"\ + "SDK Package Version: {{packageVersion}}".\ + format(env=sys.platform, pyversion=sys.version) + + def get_host_settings(self): + """Gets an array of host settings + + :return: An array of host settings + """ + return [ + {{#servers}} + { + 'url': "{{{url}}}", + 'description': "{{{description}}}{{^description}}No description provided{{/description}}", + {{#variables}} + {{#-first}} + 'variables': { + {{/-first}} + '{{{name}}}': { + 'description': "{{{description}}}{{^description}}No description provided{{/description}}", + 'default_value': "{{{defaultValue}}}", + {{#enumValues}} + {{#-first}} + 'enum_values': [ + {{/-first}} + "{{{.}}}"{{^-last}},{{/-last}} + {{#-last}} + ] + {{/-last}} + {{/enumValues}} + }{{^-last}},{{/-last}} + {{#-last}} + } + {{/-last}} + {{/variables}} + }{{^-last}},{{/-last}} + {{/servers}} + ] + + def get_host_from_settings(self, index, variables=None, servers=None): + """Gets host URL based on the index and variables + :param index: array index of the host settings + :param variables: hash of variable and the corresponding value + :param servers: an array of host settings or None + :return: URL based on host settings + """ + if index is None: + return self._base_path + + variables = {} if variables is None else variables + servers = self.get_host_settings() if servers is None else servers + + try: + server = servers[index] + except IndexError: + raise ValueError( + "Invalid index {0} when selecting the host settings. " + "Must be less than {1}".format(index, len(servers))) + + url = server['url'] + + # go through variables and replace placeholders + for variable_name, variable in server.get('variables', {}).items(): + used_value = variables.get( + variable_name, variable['default_value']) + + if 'enum_values' in variable \ + and used_value not in variable['enum_values']: + raise ValueError( + "The variable `{0}` in the host URL has invalid value " + "{1}. Must be {2}.".format( + variable_name, variables[variable_name], + variable['enum_values'])) + + url = url.replace("{" + variable_name + "}", used_value) + + return url + + def is_valid(self): + """ + Verify the configuration is valid. + Note that we are only doing basic validation to ensure input is sane. + """ + if self.api_host is None or self.api_host == '': + raise ApiTypeError('api_host is required but not configured.') + if self.api_scheme is None or self.api_scheme == '': + raise ApiTypeError('api_scheme is required but not configured.') + combined_url = self.api_scheme + '://' + self.api_host + parsed_url = None + try: + parsed_url = urlparse(combined_url) + except ValueError: + raise ApiValueError('Either api_scheme `{}` or api_host `{}` is invalid'.format(self.api_scheme, self.api_host)) + if (parsed_url.scheme != 'http' and parsed_url.scheme != 'https'): + raise ApiValueError('api_scheme `{}` must be either `http` or `https`'.format(self.api_scheme)) + if (parsed_url.netloc == ''): + raise ApiValueError('api_host `{}` is invalid'.format(self.api_host)) + if (parsed_url.path != ''): + raise ApiValueError('api_host `{}` is not expected to have path specified'.format(self.api_scheme)) + if (parsed_url.query != ''): + raise ApiValueError('api_host `{}` is not expected to have query specified'.format(self.api_scheme)) + if self._credentials is not None: + self._credentials.validate_credentials_config() + + @property + def api_scheme(self): + """Return connection is https or http.""" + return self._scheme + + @api_scheme.setter + def api_scheme(self, value): + """Update connection scheme (https or http).""" + self._scheme = value + + @property + def api_host(self): + """Return api_host.""" + return self._base_path + + @api_host.setter + def api_host(self, value): + """Update configured host""" + self._base_path = value + + @property + def store_id(self): + """Return store id.""" + return self._store_id + + @store_id.setter + def store_id(self, value): + """Update store id.""" + self._store_id = value + + @property + def credentials(self): + """ + Return configured credentials + """ + return self._credentials + + @credentials.setter + def credentials(self, value): + """Update credentials""" + self._credentials = value + + @property + def retry_params(self): + """ + Return retry parameters + """ + return self._retry_params + + @retry_params.setter + def retry_params(self, value): + """ + Update retry parameters + """ + self._retry_params = value diff --git a/config/clients/python/template/credentials.mustache b/config/clients/python/template/credentials.mustache new file mode 100644 index 000000000..d0203b421 --- /dev/null +++ b/config/clients/python/template/credentials.mustache @@ -0,0 +1,229 @@ +{{>partial_header}} + +from dataclasses import dataclass +from datetime import datetime, timedelta +import json +import typing +import urllib3 +from urllib.parse import urlparse + +from {{packageName}}.exceptions import ApiTypeError, ApiValueError, AuthenticationError + +def none_or_empty(value): + """ + Return true if value is either none or empty string + """ + return value is None or value == '' + + +class CredentialConfiguration: + """ + Configuration for SDK credential + :param client_id: Client ID which will be matched with client_secret + :param client_secret: Client secret which will be matched with client_id + :param api_token: Bearer token to be sent for authentication + :param api_audience: API audience used for OAuth2 + :param api_issuer: API issuer used for OAuth2 + """ + + def __init__( + self, + client_id: typing.Optional[str] = None, + client_secret: typing.Optional[str] = None, + api_audience: typing.Optional[str] = None, + api_issuer: typing.Optional[str] = None, + api_token: typing.Optional[str] = None, + ): + self._client_id = client_id + self._client_secret = client_secret + self._api_audience = api_audience + self._api_issuer = api_issuer + self._api_token = api_token + + + @property + def client_id(self): + """ + Return the client id configured + """ + return self._client_id + + @client_id.setter + def client_id(self, value): + """ + Update the client id + """ + self._client_id = value + + @property + def client_secret(self): + """ + Return the client secret configured + """ + return self._client_secret + + @client_secret.setter + def client_secret(self, value): + """ + Update the client secret + """ + self._client_secret = value + + @property + def api_audience(self): + """ + Return the api audience configured + """ + return self._api_audience + + @api_audience.setter + def api_audience(self, value): + """ + Update the api audience + """ + self._api_audience = value + + @property + def api_issuer(self): + """ + Return the api issuer configured + """ + return self._api_issuer + + @api_issuer.setter + def api_issuer(self, value): + """ + Update the api issuer + """ + self._api_issuer = value + + @property + def api_token(self): + """ + Return the api token configured + """ + return self._api_token + + @api_token.setter + def api_token(self, value): + """ + Update the api token + """ + self._api_token = value + + +class Credentials: + """ + Manage the credential for the API Client + :param method: Type of authentication. Possible value is 'none', 'api_token' and 'client_credentials'. Default as 'none'. + :param configuration: Credential configuration of type CredentialConfiguration. Default as None. + """ + + def __init__( + self, + method: typing.Optional[str] = 'none', + configuration: typing.Optional[CredentialConfiguration] = None, + ): + self._method = method + self._configuration = configuration + self._access_token = None + self._access_expiry_time = None + + @property + def method(self): + """ + Return the method configured + """ + return self._method + + @method.setter + def method(self, value): + """ + Update the method + """ + self._method = value + + @property + def configuration(self): + """ + Return the configuration + """ + return self._configuration + + @configuration.setter + def configuration(self, value): + """ + Update the configuration + """ + self._configuration = value + + def validate_credentials_config(self): + """ + Check whether credentials configuration is valid + """ + if self.method != 'none' and self.method != 'api_token' and self.method != 'client_credentials': + raise ApiValueError('method `{}` must be either `none`, `api_token` or `client_credentials`'.format(self.method)) + if self.method == 'api_token' and (self.configuration is None or none_or_empty(self.configuration.api_token)): + raise ApiValueError('configuration `{}` api_token must be defined and non empty when method is api_token'.format(self.configuration)) + if self.method == 'client_credentials': + if self.configuration is None or none_or_empty(self.configuration.client_id) or none_or_empty(self.configuration.client_secret) or none_or_empty(self.configuration.api_audience) or none_or_empty(self.configuration.api_issuer): + raise ApiValueError('configuration `{}` requires client_id, client_secret, api_audience and api_issuer defined for client_credentials method.') + # validate token issuer + combined_url = 'https://' + self.configuration.api_issuer + parsed_url = None + try: + parsed_url = urlparse(combined_url) + except ValueError: + raise ApiValueError('api_issuer `{}` is invalid'.format(self.configuration.api_issuer)) + if (parsed_url.netloc == ''): + raise ApiValueError('api_issuer `{}` is invalid'.format(self.configuration.api_issuer)) + + def _token_valid(self): + """ + Return whether token is valid + """ + if self._access_token is None or self._access_expiry_time is None: + return False + if self._access_expiry_time < datetime.now(): + return False + return True + + async def _obtain_token(self, client): + """ + Perform OAuth2 and obtain token + """ + token_url = 'https://{}/oauth/token'.format(self.configuration.api_issuer) + body = { + 'client_id': self.configuration.client_id, + 'client_secret': self.configuration.client_secret, + 'audience': self.configuration.api_audience, + 'grant_type': "client_credentials", + } + headers = urllib3.response.HTTPHeaderDict({'Accept': 'application/json', 'Content-Type': 'application/json', 'User-Agent': 'openfga-sdk (python) {{packageVersion}}'}) + raw_response = await client.POST(token_url, headers=headers, body=body); + if 200 <= raw_response.status <= 299: + try: + api_response = json.loads(raw_response.data) + except: # noqa: E722 + raise AuthenticationError(http_resp=raw_response) + if not api_response.get('expires_in') or not api_response.get('access_token'): + raise AuthenticationError(http_resp=raw_response) + self._access_expiry_time = datetime.now() + timedelta(seconds=int(api_response.get('expires_in'))) + self._access_token = api_response.get('access_token') + else: + raise AuthenticationError(http_resp=raw_response) + + async def get_authentication_header(self, client): + """ + If configured, return the header for authentication + """ + if self._method == 'none': + return {} + if self._method == 'api_token': + return {'Authorization': 'Bearer {}'.format(self.configuration.api_token)} + # check to see token is valid + if not self._token_valid(): + # In this case, the token is not valid, we need to get the refresh the token + await self._obtain_token(client) + return {'Authorization': 'Bearer {}'.format(self._access_token)} + diff --git a/config/clients/python/template/credentials_test.mustache b/config/clients/python/template/credentials_test.mustache new file mode 100644 index 000000000..e635aff86 --- /dev/null +++ b/config/clients/python/template/credentials_test.mustache @@ -0,0 +1,236 @@ +# coding: utf-8 + +{{>partial_header}} + +from unittest import IsolatedAsyncioTestCase + +from mock import patch +from datetime import datetime, timedelta + +import {{packageName}} +import urllib3 + +from {{packageName}} import rest +from {{packageName}}.credentials import CredentialConfiguration, Credentials +from {{packageName}}.configuration import Configuration +from {{packageName}}.exceptions import AuthenticationError + + +# Helper function to construct mock response +def mock_response(body, status): + headers = urllib3.response.HTTPHeaderDict({ + 'content-type': 'application/json' + }) + obj = urllib3.HTTPResponse( + body, + headers, + status, + preload_content=False + ) + return rest.RESTResponse(obj, obj.data) + + +class TestCredentials(IsolatedAsyncioTestCase): + """Credentials unit test""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_bad_method(self): + """ + Check whether assertion is raised if method is not allowed + """ + credential = Credentials("bad") + with self.assertRaises(openfga_sdk.ApiValueError): + credential.validate_credentials_config() + + def test_method_none(self): + """ + Test credential with method none is valid + """ + credential = Credentials("none") + credential.validate_credentials_config() + self.assertEqual(credential.method, 'none') + + def test_method_default(self): + """ + Test credential with not method is default to none + """ + credential = Credentials() + credential.validate_credentials_config() + self.assertEqual(credential.method, 'none') + + def test_configuration_api_token(self): + """ + Test credential with method api_token and appropriate configuration is valid + """ + credential = Credentials(method="api_token", configuration=CredentialConfiguration(api_token='ABCDEFG')) + credential.validate_credentials_config() + self.assertEqual(credential.method, 'api_token') + self.assertEqual(credential.configuration.api_token, 'ABCDEFG') + + def test_configuration_api_token_missing_configuration(self): + """ + Test credential with method api_token but configuration is not specified + """ + credential = Credentials(method="api_token") + with self.assertRaises(openfga_sdk.ApiValueError): + credential.validate_credentials_config() + + def test_configuration_api_token_missing_token(self): + """ + Test credential with method api_token but configuration is missing token + """ + credential = Credentials(method="api_token", configuration=CredentialConfiguration()) + with self.assertRaises(openfga_sdk.ApiValueError): + credential.validate_credentials_config() + + def test_configuration_api_token_empty_token(self): + """ + Test credential with method api_token but configuration has empty token + """ + credential = Credentials(method="api_token", configuration=CredentialConfiguration(api_token='')) + with self.assertRaises(openfga_sdk.ApiValueError): + credential.validate_credentials_config() + + def test_configuration_client_credentials(self): + """ + Test credential with method client_credentials and appropriate configuration is valid + """ + credential = Credentials(method="client_credentials", + configuration=CredentialConfiguration(client_id='myclientid', + client_secret='mysecret', api_issuer='www.testme.com', api_audience='myaudience')) + credential.validate_credentials_config() + self.assertEqual(credential.method, 'client_credentials') + + def test_configuration_client_credentials_missing_config(self): + """ + Test credential with method client_credentials and configuration is missing + """ + credential = Credentials(method="client_credentials") + with self.assertRaises(openfga_sdk.ApiValueError): + credential.validate_credentials_config() + + def test_configuration_client_credentials_missing_client_id(self): + """ + Test credential with method client_credentials and configuration is missing client id + """ + credential = Credentials(method="client_credentials", + configuration=CredentialConfiguration( + client_secret='mysecret', api_issuer='www.testme.com', api_audience='myaudience')) + with self.assertRaises(openfga_sdk.ApiValueError): + credential.validate_credentials_config() + + def test_configuration_client_credentials_missing_client_secret(self): + """ + Test credential with method client_credentials and configuration is missing client secret + """ + credential = Credentials(method="client_credentials", + configuration=CredentialConfiguration(client_id='myclientid', + api_issuer='www.testme.com', api_audience='myaudience')) + with self.assertRaises(openfga_sdk.ApiValueError): + credential.validate_credentials_config() + + def test_configuration_client_credentials_missing_api_issuer(self): + """ + Test credential with method client_credentials and configuration is missing api issuer + """ + credential = Credentials(method="client_credentials", + configuration=CredentialConfiguration(client_id='myclientid', + client_secret='mysecret', api_audience='myaudience')) + with self.assertRaises(openfga_sdk.ApiValueError): + credential.validate_credentials_config() + + def test_configuration_client_credentials_missing_api_audience(self): + """ + Test credential with method client_credentials and configuration is missing api audience + """ + credential = Credentials(method="client_credentials", + configuration=CredentialConfiguration(client_id='myclientid', + client_secret='mysecret', api_issuer='www.testme.com')) + with self.assertRaises(openfga_sdk.ApiValueError): + credential.validate_credentials_config() + + async def test_get_authentication_header(self): + """ + Test getting authentication header when method is none + """ + credential = Credentials() + auth_header = await credential.get_authentication_header(None) + self.assertEqual(auth_header, {}) + + async def test_get_authentication_api_token(self): + """ + Test getting authentication header when method is api token + """ + credential = Credentials(method="api_token", configuration=CredentialConfiguration(api_token='ABCDEFG')) + auth_header = await credential.get_authentication_header(None) + self.assertEqual(auth_header, {'Authorization': 'Bearer ABCDEFG'}) + + async def test_get_authentication_valid_client_credentials(self): + """ + Test getting authentication header when method is client credentials + """ + credential = Credentials(method="client_credentials", + configuration=CredentialConfiguration(client_id='myclientid', + client_secret='mysecret', api_issuer='www.testme.com', api_audience='myaudience')) + credential._access_token = 'XYZ123' + credential._access_expiry_time = datetime.now() + timedelta(seconds=60) + auth_header = await credential.get_authentication_header(None) + self.assertEqual(auth_header, {'Authorization': 'Bearer XYZ123'}) + + @patch.object(rest.RESTClientObject, 'request') + async def test_get_authentication_obtain_client_credentials(self, mock_request): + """ + Test getting authentication header when method is client credential and we need to obtain token + """ + response_body = ''' +{ + "expires_in": 120, + "access_token": "AABBCCDD" +} + ''' + mock_request.return_value = mock_response(response_body, 200) + + credential = Credentials(method="client_credentials", + configuration=CredentialConfiguration(client_id='myclientid', + client_secret='mysecret', api_issuer='www.testme.com', api_audience='myaudience')) + client = rest.RESTClientObject(Configuration()) + current_time = datetime.now() + auth_header = await credential.get_authentication_header(client) + self.assertEqual(auth_header, {'Authorization': 'Bearer AABBCCDD'}) + self.assertEqual(credential._access_token, 'AABBCCDD') + self.assertGreaterEqual(credential._access_expiry_time, current_time + timedelta(seconds=int(120))) + expected_header = urllib3.response.HTTPHeaderDict({'Accept': 'application/json', 'Content-Type': 'application/json', 'User-Agent': 'openfga-sdk (python) {{packageVersion}}'}) + mock_request.assert_called_once_with( + 'POST', + 'https://www.testme.com/oauth/token', + headers=expected_header, + query_params=None, post_params=None, _preload_content=True, _request_timeout=None, + body={"client_id": "myclientid", "client_secret": "mysecret", "audience": "myaudience", "grant_type": "client_credentials"} + ) + await client.close() + + @patch.object(rest.RESTClientObject, 'request') + async def test_get_authentication_obtain_client_credentials_failed(self, mock_request): + """ + Test getting authentication header when method is client credential and we fail to obtain token + """ + response_body = ''' +{ + "reason": "Unauthorized" +} + ''' + mock_request.return_value = mock_response(response_body, 403) + + credential = Credentials(method="client_credentials", + configuration=CredentialConfiguration(client_id='myclientid', + client_secret='mysecret', api_issuer='www.testme.com', api_audience='myaudience')) + client = rest.RESTClientObject(Configuration()) + with self.assertRaises(AuthenticationError): + await credential.get_authentication_header(client) + await client.close() + diff --git a/config/clients/python/template/exceptions.mustache b/config/clients/python/template/exceptions.mustache new file mode 100644 index 000000000..0bc8258b0 --- /dev/null +++ b/config/clients/python/template/exceptions.mustache @@ -0,0 +1,188 @@ +# coding: utf-8 + +{{>partial_header}} + +import six + + +class OpenApiException(Exception): + """The base exception class for all OpenAPIExceptions""" + + +class ApiTypeError(OpenApiException, TypeError): + def __init__(self, msg, path_to_item=None, valid_classes=None, + key_type=None): + """ Raises an exception for TypeErrors + + Args: + msg (str): the exception message + + Keyword Args: + path_to_item (list): a list of keys an indices to get to the + current_item + None if unset + valid_classes (tuple): the primitive classes that current item + should be an instance of + None if unset + key_type (bool): False if our value is a value in a dict + True if it is a key in a dict + False if our item is an item in a list + None if unset + """ + self.path_to_item = path_to_item + self.valid_classes = valid_classes + self.key_type = key_type + full_msg = msg + if path_to_item: + full_msg = "{0} at {1}".format(msg, render_path(path_to_item)) + super(ApiTypeError, self).__init__(full_msg) + + +class ApiValueError(OpenApiException, ValueError): + def __init__(self, msg, path_to_item=None): + """ + Args: + msg (str): the exception message + + Keyword Args: + path_to_item (list) the path to the exception in the + received_data dict. None if unset + """ + + self.path_to_item = path_to_item + full_msg = msg + if path_to_item: + full_msg = "{0} at {1}".format(msg, render_path(path_to_item)) + super(ApiValueError, self).__init__(full_msg) + + +class ApiAttributeError(OpenApiException, AttributeError): + def __init__(self, msg, path_to_item=None): + """ + Raised when an attribute reference or assignment fails. + + Args: + msg (str): the exception message + + Keyword Args: + path_to_item (None/list) the path to the exception in the + received_data dict + """ + self.path_to_item = path_to_item + full_msg = msg + if path_to_item: + full_msg = "{0} at {1}".format(msg, render_path(path_to_item)) + super(ApiAttributeError, self).__init__(full_msg) + + +class ApiKeyError(OpenApiException, KeyError): + def __init__(self, msg, path_to_item=None): + """ + Args: + msg (str): the exception message + + Keyword Args: + path_to_item (None/list) the path to the exception in the + received_data dict + """ + self.path_to_item = path_to_item + full_msg = msg + if path_to_item: + full_msg = "{0} at {1}".format(msg, render_path(path_to_item)) + super(ApiKeyError, self).__init__(full_msg) + + +class ApiException(OpenApiException): + + def __init__(self, status=None, reason=None, http_resp=None): + if http_resp: + self.status = http_resp.status + self.reason = http_resp.reason + self.body = http_resp.data + self.headers = http_resp.getheaders() + self._parsed_exception = None + else: + self.status = status + self.reason = reason + self.body = None + self.headers = None + self._parsed_exception = None + + def __str__(self): + """Custom error messages for exception""" + error_message = "({0})\n"\ + "Reason: {1}\n".format(self.status, self.reason) + if self.headers: + error_message += "HTTP response headers: {0}\n".format( + self.headers) + + if self.body: + error_message += "HTTP response body: {0}\n".format(self.body) + + return error_message + + @property + def parsed_exception(self): + """ + Return the parsed body of the exception + """ + return self._parsed_exception + + @parsed_exception.setter + def parsed_exception(self, content): + """ + Update the deserialized content + """ + self._parsed_exception = content + + +class NotFoundException(ApiException): + + def __init__(self, status=None, reason=None, http_resp=None): + super(NotFoundException, self).__init__(status, reason, http_resp) + + +class UnauthorizedException(ApiException): + + def __init__(self, status=None, reason=None, http_resp=None): + super(UnauthorizedException, self).__init__(status, reason, http_resp) + + +class ForbiddenException(ApiException): + + def __init__(self, status=None, reason=None, http_resp=None): + super(ForbiddenException, self).__init__(status, reason, http_resp) + + +class ServiceException(ApiException): + + def __init__(self, status=None, reason=None, http_resp=None): + super(ServiceException, self).__init__(status, reason, http_resp) + + +class ValidationException(ApiException): + + def __init__(self, status=None, reason=None, http_resp=None): + super(ValidationException, self).__init__(status, reason, http_resp) + + +class AuthenticationError(ApiException): + + def __init__(self, status=None, reason=None, http_resp=None): + super(AuthenticationError, self).__init__(status, reason, http_resp) + +class RateLimitExceededError(ApiException): + + def __init__(self, status=None, reason=None, http_resp=None): + super(RateLimitExceededError, self).__init__(status, reason, http_resp) + + +def render_path(path_to_item): + """Returns a string representation of a path""" + result = "" + for pth in path_to_item: + if isinstance(pth, six.integer_types): + result += "[{0}]".format(pth) + else: + result += "['{0}']".format(pth) + return result diff --git a/config/clients/python/template/gitignore.mustache b/config/clients/python/template/gitignore.mustache new file mode 100644 index 000000000..914543f68 --- /dev/null +++ b/config/clients/python/template/gitignore.mustache @@ -0,0 +1,69 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.pyc +openfga_sdk/__pycache__/ + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +venv/ +.venv/ +.python-version +.pytest_cache +test/__pycache__/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +#Ipython Notebook +.ipynb_checkpoints diff --git a/config/clients/python/template/model.mustache b/config/clients/python/template/model.mustache new file mode 100644 index 000000000..e899b0db3 --- /dev/null +++ b/config/clients/python/template/model.mustache @@ -0,0 +1,257 @@ +# coding: utf-8 + +{{>partial_header}} + +try: + from inspect import getfullargspec +except ImportError: + from inspect import getargspec as getfullargspec +import pprint +import re # noqa: F401 +import six + +from {{packageName}}.configuration import Configuration + + +{{#models}} +{{#model}} +class {{classname}}(object): + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """{{#allowableValues}} + + """ + allowed enum values + """ +{{#enumVars}} + {{name}} = {{{value}}}{{^-last}} +{{/-last}} +{{/enumVars}}{{/allowableValues}} + +{{#allowableValues}} + allowable_values = [{{#enumVars}}{{name}}{{^-last}}, {{/-last}}{{/enumVars}}] # noqa: E501 + +{{/allowableValues}} + """ + Attributes: + openapi_types (dict): The key is attribute name + and the value is attribute type. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + """ + openapi_types = { +{{#vars}} + '{{name}}': '{{{dataType}}}'{{^-last}},{{/-last}} +{{/vars}} + } + + attribute_map = { +{{#vars}} + '{{name}}': '{{baseName}}'{{^-last}},{{/-last}} +{{/vars}} + } +{{#discriminator}} + + discriminator_value_class_map = { +{{#children}} + '{{^vendorExtensions.x-discriminator-value}}{{name}}{{/vendorExtensions.x-discriminator-value}}{{#vendorExtensions.x-discriminator-value}}{{{vendorExtensions.x-discriminator-value}}}{{/vendorExtensions.x-discriminator-value}}': '{{{classname}}}'{{^-last}},{{/-last}} +{{/children}} + } +{{/discriminator}} + + def __init__(self{{#vars}}, {{name}}={{{defaultValue}}}{{^defaultValue}}None{{/defaultValue}}{{/vars}}, local_vars_configuration=None): # noqa: E501 + """{{classname}} - a model defined in OpenAPI""" # noqa: E501 + if local_vars_configuration is None: + local_vars_configuration = Configuration.get_default_copy() + self.local_vars_configuration = local_vars_configuration +{{#vars}}{{#-first}} +{{/-first}} + self._{{name}} = None +{{/vars}} + self.discriminator = {{#discriminator}}'{{{discriminatorName}}}'{{/discriminator}}{{^discriminator}}None{{/discriminator}} +{{#vars}}{{#-first}} +{{/-first}} +{{#required}} + self.{{name}} = {{name}} +{{/required}} +{{^required}} +{{#isNullable}} + self.{{name}} = {{name}} +{{/isNullable}} +{{^isNullable}} + if {{name}} is not None: + self.{{name}} = {{name}} +{{/isNullable}} +{{/required}} +{{/vars}} + +{{#vars}} + @property + def {{name}}(self): + """Gets the {{name}} of this {{classname}}. # noqa: E501 + +{{#description}} + {{{.}}} # noqa: E501 +{{/description}} + + :return: The {{name}} of this {{classname}}. # noqa: E501 + :rtype: {{dataType}} + """ + return self._{{name}} + + @{{name}}.setter + def {{name}}(self, {{name}}): + """Sets the {{name}} of this {{classname}}. + +{{#description}} + {{{.}}} # noqa: E501 +{{/description}} + + :param {{name}}: The {{name}} of this {{classname}}. # noqa: E501 + :type {{name}}: {{dataType}} + """ +{{^isNullable}} +{{#required}} + if self.local_vars_configuration.client_side_validation and {{name}} is None: # noqa: E501 + raise ValueError("Invalid value for `{{name}}`, must not be `None`") # noqa: E501 +{{/required}} +{{/isNullable}} +{{#isEnum}} +{{#isContainer}} + allowed_values = [{{#isNullable}}None,{{/isNullable}}{{#allowableValues}}{{#values}}{{#items.isString}}"{{/items.isString}}{{{this}}}{{#items.isString}}"{{/items.isString}}{{^-last}}, {{/-last}}{{/values}}{{/allowableValues}}] # noqa: E501 +{{#isArray}} + if (self.local_vars_configuration.client_side_validation and + not set({{{name}}}).issubset(set(allowed_values))): # noqa: E501 + raise ValueError( + "Invalid values for `{{{name}}}` [{0}], must be a subset of [{1}]" # noqa: E501 + .format(", ".join(map(str, set({{{name}}}) - set(allowed_values))), # noqa: E501 + ", ".join(map(str, allowed_values))) + ) +{{/isArray}} +{{#isMap}} + if (self.local_vars_configuration.client_side_validation and + not set({{{name}}}.keys()).issubset(set(allowed_values))): # noqa: E501 + raise ValueError( + "Invalid keys in `{{{name}}}` [{0}], must be a subset of [{1}]" # noqa: E501 + .format(", ".join(map(str, set({{{name}}}.keys()) - set(allowed_values))), # noqa: E501 + ", ".join(map(str, allowed_values))) + ) +{{/isMap}} +{{/isContainer}} +{{^isContainer}} + allowed_values = [{{#isNullable}}None,{{/isNullable}}{{#allowableValues}}{{#values}}{{#isString}}"{{/isString}}{{{this}}}{{#isString}}"{{/isString}}{{^-last}}, {{/-last}}{{/values}}{{/allowableValues}}] # noqa: E501 + if self.local_vars_configuration.client_side_validation and {{{name}}} not in allowed_values: # noqa: E501 + raise ValueError( + "Invalid value for `{{{name}}}` ({0}), must be one of {1}" # noqa: E501 + .format({{{name}}}, allowed_values) + ) +{{/isContainer}} +{{/isEnum}} +{{^isEnum}} +{{#hasValidation}} +{{#maxLength}} + if (self.local_vars_configuration.client_side_validation and + {{name}} is not None and len({{name}}) > {{maxLength}}): + raise ValueError("Invalid value for `{{name}}`, length must be less than or equal to `{{maxLength}}`") # noqa: E501 +{{/maxLength}} +{{#minLength}} + if (self.local_vars_configuration.client_side_validation and + {{name}} is not None and len({{name}}) < {{minLength}}): + raise ValueError("Invalid value for `{{name}}`, length must be greater than or equal to `{{minLength}}`") # noqa: E501 +{{/minLength}} +{{#maximum}} + if (self.local_vars_configuration.client_side_validation and + {{name}} is not None and {{name}} >{{#exclusiveMaximum}}={{/exclusiveMaximum}} {{maximum}}): # noqa: E501 + raise ValueError("Invalid value for `{{name}}`, must be a value less than {{^exclusiveMaximum}}or equal to {{/exclusiveMaximum}}`{{maximum}}`") # noqa: E501 +{{/maximum}} +{{#minimum}} + if (self.local_vars_configuration.client_side_validation and + {{name}} is not None and {{name}} <{{#exclusiveMinimum}}={{/exclusiveMinimum}} {{minimum}}): # noqa: E501 + raise ValueError("Invalid value for `{{name}}`, must be a value greater than {{^exclusiveMinimum}}or equal to {{/exclusiveMinimum}}`{{minimum}}`") # noqa: E501 +{{/minimum}} +{{#pattern}} + if (self.local_vars_configuration.client_side_validation and + {{name}} is not None and not re.search(r'{{{vendorExtensions.x-regex}}}', {{name}}{{#vendorExtensions.x-modifiers}}{{#-first}}, flags={{/-first}}re.{{.}}{{^-last}} | {{/-last}}{{/vendorExtensions.x-modifiers}})): # noqa: E501 + raise ValueError(r"Invalid value for `{{name}}`, must be a follow pattern or equal to `{{{pattern}}}`") # noqa: E501 +{{/pattern}} +{{#maxItems}} + if (self.local_vars_configuration.client_side_validation and + {{name}} is not None and len({{name}}) > {{maxItems}}): + raise ValueError("Invalid value for `{{name}}`, number of items must be less than or equal to `{{maxItems}}`") # noqa: E501 +{{/maxItems}} +{{#minItems}} + if (self.local_vars_configuration.client_side_validation and + {{name}} is not None and len({{name}}) < {{minItems}}): + raise ValueError("Invalid value for `{{name}}`, number of items must be greater than or equal to `{{minItems}}`") # noqa: E501 +{{/minItems}} +{{/hasValidation}} +{{/isEnum}} + + self._{{name}} = {{name}} + +{{/vars}} +{{#discriminator}} + def get_real_child_model(self, data): + """Returns the real base class specified by the discriminator""" + discriminator_key = self.attribute_map[self.discriminator] + discriminator_value = data[discriminator_key] + return self.discriminator_value_class_map.get(discriminator_value) + +{{/discriminator}} + def to_dict(self, serialize=False): + """Returns the model properties as a dict""" + result = {} + + def convert(x): + if hasattr(x, "to_dict"): + args = getfullargspec(x.to_dict).args + if len(args) == 1: + return x.to_dict() + else: + return x.to_dict(serialize) + else: + return x + + for attr, _ in six.iteritems(self.openapi_types): + value = getattr(self, attr) + attr = self.attribute_map.get(attr, attr) if serialize else attr + if isinstance(value, list): + result[attr] = list(map( + lambda x: convert(x), + value + )) + elif isinstance(value, dict): + result[attr] = dict(map( + lambda item: (item[0], convert(item[1])), + value.items() + )) + else: + result[attr] = convert(value) + + return result + + def to_str(self): + """Returns the string representation of the model""" + return pprint.pformat(self.to_dict()) + + def __repr__(self): + """For `print` and `pprint`""" + return self.to_str() + + def __eq__(self, other): + """Returns true if both objects are equal""" + if not isinstance(other, {{classname}}): + return False + + return self.to_dict() == other.to_dict() + + def __ne__(self, other): + """Returns true if both objects are not equal""" + if not isinstance(other, {{classname}}): + return True + + return self.to_dict() != other.to_dict() +{{/model}} +{{/models}} diff --git a/config/clients/python/template/model_doc.mustache b/config/clients/python/template/model_doc.mustache new file mode 100644 index 000000000..f73f21259 --- /dev/null +++ b/config/clients/python/template/model_doc.mustache @@ -0,0 +1,14 @@ +{{#models}}{{#model}}# {{classname}} + +{{#description}}{{&description}} +{{/description}} + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +{{#vars}}**{{name}}** | {{#isPrimitiveType}}**{{dataType}}**{{/isPrimitiveType}}{{^isPrimitiveType}}[**{{dataType}}**]({{complexType}}.md){{/isPrimitiveType}} | {{description}} | {{^required}}[optional] {{/required}}{{#isReadOnly}}[readonly] {{/isReadOnly}}{{#defaultValue}}[default to {{{.}}}]{{/defaultValue}} +{{/vars}} + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + +{{/model}}{{/models}} diff --git a/config/clients/python/template/model_test.mustache b/config/clients/python/template/model_test.mustache new file mode 100644 index 000000000..f5f4b4152 --- /dev/null +++ b/config/clients/python/template/model_test.mustache @@ -0,0 +1,62 @@ +# coding: utf-8 + +{{>partial_header}} + +import unittest +import datetime + +{{#models}} +{{#model}} +import {{packageName}} +from {{modelPackage}}.{{classFilename}} import {{classname}} # noqa: E501 +from {{packageName}}.rest import ApiException + +class Test{{classname}}(unittest.TestCase): + """{{classname}} unit test stubs""" + + def setUp(self): + pass + + def tearDown(self): + pass +{{^interfaces}} + + def make_instance(self, include_optional): + """Test {{classname}} + include_option is a boolean, when False only required + params are included, when True both required and + optional params are included """ + # model = {{packageName}}.models.{{classFilename}}.{{classname}}() # noqa: E501 + if include_optional : + return {{classname}}( +{{#vars}} + {{name}} = {{{example}}}{{^example}}None{{/example}}{{^-last}}, {{/-last}} +{{/vars}} + ) + else : + return {{classname}}( +{{#vars}} +{{#required}} + {{name}} = {{{example}}}{{^example}}None{{/example}}, +{{/required}} +{{/vars}} + ) +{{/interfaces}} + + def test{{classname}}(self): + """Test {{classname}}""" +{{^interfaces}} + inst_req_only = self.make_instance(include_optional=False) + inst_req_and_optional = self.make_instance(include_optional=True) +{{/interfaces}} +{{#interfaces}} +{{#-last}} + # inst_req_only = self.make_instance(include_optional=False) + # inst_req_and_optional = self.make_instance(include_optional=True) +{{/-last}} +{{/interfaces}} +{{/model}} +{{/models}} + +if __name__ == '__main__': + unittest.main() diff --git a/config/clients/python/template/partial_header.mustache b/config/clients/python/template/partial_header.mustache new file mode 100644 index 000000000..3ffa36f8c --- /dev/null +++ b/config/clients/python/template/partial_header.mustache @@ -0,0 +1,23 @@ +""" + {{#packageDescription}} + {{{packageDescription}}} + + {{/packageDescription}} + {{#version}} + API version: {{{version}}} + {{/version}} + {{#websiteUrl}} + Website: {{{websiteUrl}}} + {{/websiteUrl}} + {{#docsUrl}} + Documentation: {{{docsUrl}}} + {{/docsUrl}} + {{#supportInfo}} + Support: {{{supportInfo}}} + {{/supportInfo}} + {{#licenseId}} + License: [{{{licenseId}}}](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/blob/main/LICENSE) + {{/licenseId}} + + NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. +""" \ No newline at end of file diff --git a/config/clients/python/template/python_doc_auth_partial.mustache b/config/clients/python/template/python_doc_auth_partial.mustache new file mode 100644 index 000000000..1c14133f0 --- /dev/null +++ b/config/clients/python/template/python_doc_auth_partial.mustache @@ -0,0 +1,32 @@ +# To configure the configuration +# host is mandatory +# api_scheme is optional and default to https +{{#requiredParams}} +{{#-first}} +# store_id is mandatory +{{/-first}} +{{/requiredParams}} +# See configuration.py for a list of all supported configuration parameters. +configuration = {{{packageName}}}.Configuration( + scheme = "https", + api_host = "api.{{{sampleApiDomain}}}", +{{#requiredParams}} +{{#-first}} + store_id = 'YOUR_STORE_ID', +{{/-first}} +{{/requiredParams}} +) + + +# When authenticating via the API TOKEN method +credentials = Credentials(method='api_token', configuration=CredentialConfiguration(api_token='TOKEN1')) +configuration = {{{packageName}}}.Configuration( + scheme = "https", + api_host = "api.{{{sampleApiDomain}}}", +{{#requiredParams}} +{{#-first}} + store_id = 'YOUR_STORE_ID', +{{/-first}} +{{/requiredParams}} + credentials = credentials +) diff --git a/config/clients/python/template/requirements.mustache b/config/clients/python/template/requirements.mustache new file mode 100644 index 000000000..95b374957 --- /dev/null +++ b/config/clients/python/template/requirements.mustache @@ -0,0 +1,5 @@ +six >= 1.10 +setuptools >= 21.0.0 +python-dateutil>=2.8.2 +urllib3>=1.26.11 +aiohttp>=3.8.1 \ No newline at end of file diff --git a/config/clients/python/template/rest.mustache b/config/clients/python/template/rest.mustache new file mode 100644 index 000000000..28332e133 --- /dev/null +++ b/config/clients/python/template/rest.mustache @@ -0,0 +1,291 @@ +# coding: utf-8 + +{{>partial_header}} + +import io +import json +import logging +import re +import ssl + +# python 2 and python 3 compatibility library +import six +from six.moves.urllib.parse import urlencode +import urllib3 + +from {{packageName}}.exceptions import ApiException, UnauthorizedException, ForbiddenException, NotFoundException, ServiceException, ApiValueError + + +logger = logging.getLogger(__name__) + + +class RESTResponse(io.IOBase): + + def __init__(self, resp): + self.urllib3_response = resp + self.status = resp.status + self.reason = resp.reason + self.data = resp.data + + def getheaders(self): + """Returns a dictionary of the response headers.""" + return self.urllib3_response.getheaders() + + def getheader(self, name, default=None): + """Returns a given response header.""" + return self.urllib3_response.getheader(name, default) + + +class RESTClientObject(object): + + def __init__(self, configuration, pools_size=4, maxsize=None): + # urllib3.PoolManager will pass all kw parameters to connectionpool + # https://github.com/shazow/urllib3/blob/f9409436f83aeb79fbaf090181cd81b784f1b8ce/urllib3/poolmanager.py#L75 # noqa: E501 + # https://github.com/shazow/urllib3/blob/f9409436f83aeb79fbaf090181cd81b784f1b8ce/urllib3/connectionpool.py#L680 # noqa: E501 + # maxsize is the number of requests to host that are allowed in parallel # noqa: E501 + # Custom SSL certificates and client certificates: http://urllib3.readthedocs.io/en/latest/advanced-usage.html # noqa: E501 + + # cert_reqs + if configuration.verify_ssl: + cert_reqs = ssl.CERT_REQUIRED + else: + cert_reqs = ssl.CERT_NONE + + addition_pool_args = {} + if configuration.assert_hostname is not None: + addition_pool_args['assert_hostname'] = configuration.assert_hostname # noqa: E501 + + if configuration.retries is not None: + addition_pool_args['retries'] = configuration.retries + + if configuration.socket_options is not None: + addition_pool_args['socket_options'] = configuration.socket_options + + if maxsize is None: + if configuration.connection_pool_maxsize is not None: + maxsize = configuration.connection_pool_maxsize + else: + maxsize = 4 + + # https pool manager + if configuration.proxy: + self.pool_manager = urllib3.ProxyManager( + num_pools=pools_size, + maxsize=maxsize, + cert_reqs=cert_reqs, + ca_certs=configuration.ssl_ca_cert, + cert_file=configuration.cert_file, + key_file=configuration.key_file, + proxy_url=configuration.proxy, + proxy_headers=configuration.proxy_headers, + **addition_pool_args + ) + else: + self.pool_manager = urllib3.PoolManager( + num_pools=pools_size, + maxsize=maxsize, + cert_reqs=cert_reqs, + ca_certs=configuration.ssl_ca_cert, + cert_file=configuration.cert_file, + key_file=configuration.key_file, + **addition_pool_args + ) + + def request(self, method, url, query_params=None, headers=None, + body=None, post_params=None, _preload_content=True, + _request_timeout=None): + """Perform requests. + + :param method: http request method + :param url: http request url + :param query_params: query parameters in the url + :param headers: http request headers + :param body: request json body, for `application/json` + :param post_params: request post parameters, + `application/x-www-form-urlencoded` + and `multipart/form-data` + :param _preload_content: if False, the urllib3.HTTPResponse object will + be returned without reading/decoding response + data. Default is True. + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + """ + method = method.upper() + assert method in ['GET', 'HEAD', 'DELETE', 'POST', 'PUT', + 'PATCH', 'OPTIONS'] + + if post_params and body: + raise ApiValueError( + "body parameter cannot be used with post_params parameter." + ) + + post_params = post_params or {} + headers = headers or {} + + timeout = None + if _request_timeout: + if isinstance(_request_timeout, six.integer_types + (float, )): # noqa: E501,F821 + timeout = urllib3.Timeout(total=_request_timeout) + elif (isinstance(_request_timeout, tuple) and + len(_request_timeout) == 2): + timeout = urllib3.Timeout( + connect=_request_timeout[0], read=_request_timeout[1]) + + if 'Content-Type' not in headers: + headers['Content-Type'] = 'application/json' + + try: + # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE` + if method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']: + if query_params: + url += '?' + urlencode(query_params) + if re.search('json', headers['Content-Type'], re.IGNORECASE): + request_body = None + if body is not None: + request_body = json.dumps(body) + r = self.pool_manager.request( + method, url, + body=request_body, + preload_content=_preload_content, + timeout=timeout, + headers=headers) + elif headers['Content-Type'] == 'application/x-www-form-urlencoded': # noqa: E501 + r = self.pool_manager.request( + method, url, + fields=post_params, + encode_multipart=False, + preload_content=_preload_content, + timeout=timeout, + headers=headers) + elif headers['Content-Type'] == 'multipart/form-data': + # must del headers['Content-Type'], or the correct + # Content-Type which generated by urllib3 will be + # overwritten. + del headers['Content-Type'] + r = self.pool_manager.request( + method, url, + fields=post_params, + encode_multipart=True, + preload_content=_preload_content, + timeout=timeout, + headers=headers) + # Pass a `string` parameter directly in the body to support + # other content types than Json when `body` argument is + # provided in serialized form + elif isinstance(body, str) or isinstance(body, bytes): + request_body = body + r = self.pool_manager.request( + method, url, + body=request_body, + preload_content=_preload_content, + timeout=timeout, + headers=headers) + else: + # Cannot generate the request from given parameters + msg = """Cannot prepare a request message for provided + arguments. Please check that your arguments match + declared content type.""" + raise ApiException(status=0, reason=msg) + # For `GET`, `HEAD` + else: + r = self.pool_manager.request(method, url, + fields=query_params, + preload_content=_preload_content, + timeout=timeout, + headers=headers) + except urllib3.exceptions.SSLError as e: + msg = "{0}\n{1}".format(type(e).__name__, str(e)) + raise ApiException(status=0, reason=msg) + + if _preload_content: + r = RESTResponse(r) + + # log response body + logger.debug("response body: %s", r.data) + + if not 200 <= r.status <= 299: + if r.status == 400: + raise ValidationException(http_resp=r) + + if r.status == 401: + raise UnauthorizedException(http_resp=r) + + if r.status == 403: + raise ForbiddenException(http_resp=r) + + if r.status == 404: + raise NotFoundException(http_resp=r) + + if 500 <= r.status <= 599: + raise ServiceException(http_resp=r) + + raise ApiException(http_resp=r) + + return r + + def GET(self, url, headers=None, query_params=None, _preload_content=True, + _request_timeout=None): + return self.request("GET", url, + headers=headers, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + query_params=query_params) + + def HEAD(self, url, headers=None, query_params=None, _preload_content=True, + _request_timeout=None): + return self.request("HEAD", url, + headers=headers, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + query_params=query_params) + + def OPTIONS(self, url, headers=None, query_params=None, post_params=None, + body=None, _preload_content=True, _request_timeout=None): + return self.request("OPTIONS", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body) + + def DELETE(self, url, headers=None, query_params=None, body=None, + _preload_content=True, _request_timeout=None): + return self.request("DELETE", url, + headers=headers, + query_params=query_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body) + + def POST(self, url, headers=None, query_params=None, post_params=None, + body=None, _preload_content=True, _request_timeout=None): + return self.request("POST", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body) + + def PUT(self, url, headers=None, query_params=None, post_params=None, + body=None, _preload_content=True, _request_timeout=None): + return self.request("PUT", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body) + + def PATCH(self, url, headers=None, query_params=None, post_params=None, + body=None, _preload_content=True, _request_timeout=None): + return self.request("PATCH", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body) diff --git a/config/clients/python/template/setup.mustache b/config/clients/python/template/setup.mustache new file mode 100644 index 000000000..c5efd91cc --- /dev/null +++ b/config/clients/python/template/setup.mustache @@ -0,0 +1,53 @@ +# coding: utf-8 + +{{>partial_header}} + +from setuptools import setup, find_packages # noqa: H301 + +NAME = "{{{projectName}}}" +VERSION = "{{packageVersion}}" +{{#apiInfo}} +{{#apis}} +{{#-last}} +# To install the library, run the following +# +# python setup.py install +# +# prerequisite: setuptools +# http://pypi.python.org/pypi/setuptools + +REQUIRES = ["urllib3 >= 1.26.11", "six >= 1.10", "python-dateutil >= 2.8.2"] +{{#asyncio}} +REQUIRES.append("aiohttp >= 3.8.1") +{{/asyncio}} + +setup( + name=NAME, + version=VERSION, + description="{{appDescription}}", + author="{{author}} ({{websiteUrl}})", + author_email="{{infoEmail}}", + url="https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}", + classifiers=[ + 'Development Status :: 5 - Production', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + ], + keywords=[{{#packageTags}}"{{.}}"{{^-last}}, {{/-last}}{{/packageTags}}], + install_requires=REQUIRES, + python_requires='>=3.9', + packages=find_packages(exclude=["test", "tests"]), + include_package_data=True, + {{#licenseId}}license="{{.}}", + {{/licenseId}}long_description_content_type='text/markdown', + long_description="""\ + {{>README_project_introduction}} + """ +) +{{/-last}} +{{/apis}} +{{/apiInfo}} diff --git a/config/clients/python/template/setup_cfg.mustache b/config/clients/python/template/setup_cfg.mustache new file mode 100644 index 000000000..931f02c5d --- /dev/null +++ b/config/clients/python/template/setup_cfg.mustache @@ -0,0 +1,13 @@ +{{#useNose}} +[nosetests] +logging-clear-handlers=true +verbosity=2 +randomize=true +exe=true +with-coverage=true +cover-package={{{packageName}}} +cover-erase=true + +{{/useNose}} +[flake8] +max-line-length=99 diff --git a/config/clients/python/template/test-requirements.mustache b/config/clients/python/template/test-requirements.mustache new file mode 100644 index 000000000..2ca6d2d47 --- /dev/null +++ b/config/clients/python/template/test-requirements.mustache @@ -0,0 +1,7 @@ + +pytest-cov>=2.8.1 +mock>=4.0.3 +aiohttp>=3.8.1 +flake8>=5.0.4 +python-dateutil>=2.8.2 +urllib3>=1.26.11 \ No newline at end of file diff --git a/config/clients/python/template/tornado/rest.mustache b/config/clients/python/template/tornado/rest.mustache new file mode 100644 index 000000000..2679760ea --- /dev/null +++ b/config/clients/python/template/tornado/rest.mustache @@ -0,0 +1,224 @@ +# coding: utf-8 + +{{>partial_header}} + +import io +import json +import logging +import re + +# python 2 and python 3 compatibility library +from six.moves.urllib.parse import urlencode +import tornado +import tornado.gen +from tornado import httpclient +from urllib3.filepost import encode_multipart_formdata + +from {{packageName}}.exceptions import ApiException, ApiValueError + +logger = logging.getLogger(__name__) + + +class RESTResponse(io.IOBase): + + def __init__(self, resp): + self.tornado_response = resp + self.status = resp.code + self.reason = resp.reason + + if resp.body: + self.data = resp.body + else: + self.data = None + + def getheaders(self): + """Returns a CIMultiDictProxy of the response headers.""" + return self.tornado_response.headers + + def getheader(self, name, default=None): + """Returns a given response header.""" + return self.tornado_response.headers.get(name, default) + + +class RESTClientObject(object): + + def __init__(self, configuration, pools_size=4, maxsize=4): + # maxsize is number of requests to host that are allowed in parallel + + self.ca_certs = configuration.ssl_ca_cert + self.client_key = configuration.key_file + self.client_cert = configuration.cert_file + + self.proxy_port = self.proxy_host = None + + # https pool manager + if configuration.proxy: + self.proxy_port = 80 + self.proxy_host = configuration.proxy + + self.pool_manager = httpclient.AsyncHTTPClient() + + @tornado.gen.coroutine + def request(self, method, url, query_params=None, headers=None, body=None, + post_params=None, _preload_content=True, + _request_timeout=None): + """Execute Request + + :param method: http request method + :param url: http request url + :param query_params: query parameters in the url + :param headers: http request headers + :param body: request json body, for `application/json` + :param post_params: request post parameters, + `application/x-www-form-urlencoded` + and `multipart/form-data` + :param _preload_content: this is a non-applicable field for + the AiohttpClient. + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + """ + method = method.upper() + assert method in ['GET', 'HEAD', 'DELETE', 'POST', 'PUT', + 'PATCH', 'OPTIONS'] + + if post_params and body: + raise ApiValueError( + "body parameter cannot be used with post_params parameter." + ) + + request = httpclient.HTTPRequest(url) + request.allow_nonstandard_methods = True + request.ca_certs = self.ca_certs + request.client_key = self.client_key + request.client_cert = self.client_cert + request.proxy_host = self.proxy_host + request.proxy_port = self.proxy_port + request.method = method + if headers: + request.headers = headers + if 'Content-Type' not in headers: + request.headers['Content-Type'] = 'application/json' + request.request_timeout = _request_timeout or 5 * 60 + + post_params = post_params or {} + + if query_params: + request.url += '?' + urlencode(query_params) + + # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE` + if method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']: + if re.search('json', headers['Content-Type'], re.IGNORECASE): + if body: + body = json.dumps(body) + request.body = body + elif headers['Content-Type'] == 'application/x-www-form-urlencoded': # noqa: E501 + request.body = urlencode(post_params) + elif headers['Content-Type'] == 'multipart/form-data': + multipart = encode_multipart_formdata(post_params) + request.body, headers['Content-Type'] = multipart + # Pass a `bytes` parameter directly in the body to support + # other content types than Json when `body` argument is provided + # in serialized form + elif isinstance(body, bytes): + request.body = body + else: + # Cannot generate the request from given parameters + msg = """Cannot prepare a request message for provided + arguments. Please check that your arguments match + declared content type.""" + raise ApiException(status=0, reason=msg) + + r = yield self.pool_manager.fetch(request, raise_error=False) + + if _preload_content: + + r = RESTResponse(r) + + # log response body + logger.debug("response body: %s", r.data) + + if not 200 <= r.status <= 299: + raise ApiException(http_resp=r) + + raise tornado.gen.Return(r) + + @tornado.gen.coroutine + def GET(self, url, headers=None, query_params=None, _preload_content=True, + _request_timeout=None): + result = yield self.request("GET", url, + headers=headers, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + query_params=query_params) + raise tornado.gen.Return(result) + + @tornado.gen.coroutine + def HEAD(self, url, headers=None, query_params=None, _preload_content=True, + _request_timeout=None): + result = yield self.request("HEAD", url, + headers=headers, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + query_params=query_params) + raise tornado.gen.Return(result) + + @tornado.gen.coroutine + def OPTIONS(self, url, headers=None, query_params=None, post_params=None, + body=None, _preload_content=True, _request_timeout=None): + result = yield self.request("OPTIONS", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body) + raise tornado.gen.Return(result) + + @tornado.gen.coroutine + def DELETE(self, url, headers=None, query_params=None, body=None, + _preload_content=True, _request_timeout=None): + result = yield self.request("DELETE", url, + headers=headers, + query_params=query_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body) + raise tornado.gen.Return(result) + + @tornado.gen.coroutine + def POST(self, url, headers=None, query_params=None, post_params=None, + body=None, _preload_content=True, _request_timeout=None): + result = yield self.request("POST", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body) + raise tornado.gen.Return(result) + + @tornado.gen.coroutine + def PUT(self, url, headers=None, query_params=None, post_params=None, + body=None, _preload_content=True, _request_timeout=None): + result = yield self.request("PUT", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body) + raise tornado.gen.Return(result) + + @tornado.gen.coroutine + def PATCH(self, url, headers=None, query_params=None, post_params=None, + body=None, _preload_content=True, _request_timeout=None): + result = yield self.request("PATCH", url, + headers=headers, + query_params=query_params, + post_params=post_params, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + body=body) + raise tornado.gen.Return(result) diff --git a/config/clients/python/template/tox.mustache b/config/clients/python/template/tox.mustache new file mode 100644 index 000000000..fe989faf9 --- /dev/null +++ b/config/clients/python/template/tox.mustache @@ -0,0 +1,14 @@ +[tox] +{{^asyncio}} +envlist = py27, py3 +{{/asyncio}} +{{#asyncio}} +envlist = py3 +{{/asyncio}} + +[testenv] +deps=-r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +commands= + {{^useNose}}pytest --cov={{{packageName}}}{{/useNose}}{{#useNose}}nosetests{{/useNose}}