Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add publisher gateway client #240

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion craft_store/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@


from . import creds, endpoints, errors, models
from ._httpx_auth import CandidAuth, DeveloperTokenAuth
from .publisher import PublisherGateway
from .auth import Auth
from .base_client import BaseClient
from .developer_token_auth import DeveloperTokenAuth
from .http_client import HTTPClient
from .store_client import StoreClient
from .ubuntu_one_store_client import UbuntuOneStoreClient
Expand All @@ -33,8 +34,10 @@
"endpoints",
"errors",
"models",
"PublisherGateway",
"Auth",
"BaseClient",
"CandidAuth",
"HTTPClient",
"StoreClient",
"UbuntuOneStoreClient",
Expand Down
60 changes: 39 additions & 21 deletions craft_store/developer_token_auth.py → craft_store/_httpx_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,62 +14,80 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Client for making requests towards publisher gateway."""
"""Craft Store Authentication Store."""

import abc
import logging
from collections.abc import Generator
from logging import getLogger
from typing import Literal

import httpx

from craft_store import auth, creds, errors

logger = getLogger(__name__)
logger = logging.getLogger(__name__)


class DeveloperTokenAuth(httpx.Auth):
"""Request authentication using developer token."""
class _TokenAuth(httpx.Auth, metaclass=abc.ABCMeta):
"""Base class for httpx token-based authenticators."""

def __init__(
self,
*,
auth: auth.Auth,
auth_type: Literal["bearer", "macaroon"] = "bearer",
self, *, auth: auth.Auth, auth_type: Literal["bearer", "macaroon"] = "bearer"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does this affect exporting credentials and our existing wire protocol for it?

) -> None:
super().__init__()
self._token: str | None = None
self._auth = auth
self._auth_type = auth_type
self._token: str | None = None

def auth_flow(
self,
request: httpx.Request,
) -> Generator[httpx.Request, httpx.Response, None]:
"""Update request to include Authorization header."""
logger.debug("Adding developer token to authorize request")
if self._token is None:
self.get_token_from_keyring()
logger.debug("Getting token from keyring")
self._token = self.get_token_from_keyring()

self._update_headers(request)
yield request

def get_token_from_keyring(self) -> None:
@abc.abstractmethod
def get_token_from_keyring(self) -> str:
"""Get token stored in the credentials storage."""
logger.debug("Getting developer token from credential storage")
dev_token = creds.DeveloperToken.model_validate_json(
self._auth.get_credentials()
)
self._token = dev_token.macaroon

def _update_headers(self, request: httpx.Request) -> None:
"""Add token to the request."""
logger.debug("Adding ephemeral token to request headers")
if self._token is None:
raise errors.DeveloperTokenUnavailableError(
message="Developer token is not available"
)
raise errors.AuthTokenUnavailableError(message="Token is not available")
request.headers["Authorization"] = self._format_auth_header()

def _format_auth_header(self) -> str:
if self._auth_type == "bearer":
return f"Bearer {self._token}"
return f"Macaroon {self._token}"


class CandidAuth(_TokenAuth):
"""Candid based authentication class for httpx store clients."""

def __init__(
self, *, auth: auth.Auth, auth_type: Literal["bearer", "macaroon"] = "macaroon"
) -> None:
super().__init__(auth=auth, auth_type=auth_type)

def get_token_from_keyring(self) -> str:
"""Get token stored in the credentials storage."""
logger.debug("Getting candid from credential storage")
return creds.unmarshal_candid_credentials(self._auth.get_credentials())


class DeveloperTokenAuth(_TokenAuth):
"""Developer token based authentication class for httpx store clients."""

def get_token_from_keyring(self) -> str:
"""Get token stored in the credentials storage."""
logger.debug("Getting developer token from credential storage")
return creds.DeveloperToken.model_validate_json(
self._auth.get_credentials()
).macaroon
2 changes: 1 addition & 1 deletion craft_store/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import keyring.backends.fail
import keyring.errors
from jaraco.classes import properties
from xdg import BaseDirectory # type: ignore[import]
from xdg import BaseDirectory # type: ignore[import-untyped]

from . import errors

Expand Down
40 changes: 34 additions & 6 deletions craft_store/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Craft Store errors."""
from __future__ import annotations

import contextlib
import logging
from typing import Any

import httpx
import requests
import urllib3
import urllib3.exceptions
Expand All @@ -31,9 +33,31 @@
class CraftStoreError(Exception):
"""Base class error for craft-store."""

def __init__(self, message: str, resolution: str | None = None) -> None:
def __init__(
self,
message: str,
details: str | None = None,
resolution: str | None = None,
store_errors: StoreErrorList | None = None,
) -> None:
super().__init__(message)
if store_errors and not details:
details = str(store_errors)
lengau marked this conversation as resolved.
Show resolved Hide resolved
self.details = details
self.resolution = resolution
self.store_errors = store_errors


class InvalidRequestError(CraftStoreError, ValueError):
"""Error when the request is invalid in a known way."""

def __init__(
self,
message: str,
details: str | None = None,
resolution: str | None = None,
) -> None:
super().__init__(message, details, resolution)


class NetworkError(CraftStoreError):
Expand Down Expand Up @@ -75,7 +99,7 @@ def __repr__(self) -> str:
if code:
code_list.append(code)

return "<StoreErrorList: {' '.join(code_list)}>"
return f"<StoreErrorList: {' '.join(code_list)}>"

def __contains__(self, error_code: str) -> bool:
return any(error.get("code") == error_code for error in self._error_list)
Expand Down Expand Up @@ -111,7 +135,7 @@ def _get_raw_error_list(self) -> list[dict[str, str]]:

return error_list

def __init__(self, response: requests.Response) -> None:
def __init__(self, response: requests.Response | httpx.Response) -> None:
self.response = response

try:
Expand All @@ -126,9 +150,13 @@ def __init__(self, response: requests.Response) -> None:
with contextlib.suppress(KeyError):
message = "Store operation failed:\n" + str(self.error_list)
if message is None:
if isinstance(response, httpx.Response):
reason = response.reason_phrase
else:
reason = response.reason
message = (
"Issue encountered while processing your request: "
f"[{response.status_code}] {response.reason}."
f"[{response.status_code}] {reason}."
)

super().__init__(message)
Expand Down Expand Up @@ -193,5 +221,5 @@ def __init__(self, url: str) -> None:
super().__init__(f"Empty token value returned from {url!r}.")


class DeveloperTokenUnavailableError(CraftStoreError):
"""Raised when developer token is not set."""
class AuthTokenUnavailableError(CraftStoreError):
"""Raised when an authorization token is not available."""
35 changes: 35 additions & 0 deletions craft_store/publisher/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2024 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Package containing the Publisher Gateway client and relevant metadata."""

from ._request import (
CreateTrackRequest,
)

from ._response import (
PackageMetadata,
PublisherMetadata,
TrackMetadata,
)
from ._publishergw import PublisherGateway

__all__ = [
"CreateTrackRequest",
"PackageMetadata",
"PublisherMetadata",
"TrackMetadata",
"PublisherGateway",
]
115 changes: 115 additions & 0 deletions craft_store/publisher/_publishergw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2024 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Client for the publisher gateway."""
from __future__ import annotations

from json import JSONDecodeError
from typing import cast

import httpx

from craft_store import errors
from craft_store._httpx_auth import CandidAuth
from craft_store.auth import Auth

from . import _request, _response


class PublisherGateway:
"""Client for the publisher gateway.

This class is a client wrapper for the Canonical Publisher Gateway.
The latest version of the server API can be seen at: https://api.charmhub.io/docs/

Each instance is only valid for one particular namespace.
"""

def __init__(self, base_url: str, namespace: str, auth: Auth) -> None:
self._namespace = namespace
self._client = httpx.Client(
base_url=base_url,
auth=CandidAuth(auth=auth, auth_type="macaroon"),
)

@staticmethod
def _check_error(response: httpx.Response) -> None:
if response.is_success:
return
try:
error_response = response.json()
except JSONDecodeError as exc:
raise errors.CraftStoreError(
f"Invalid response from server ({response.status_code})",
details=response.text,
) from exc
error_list = error_response.get("error-list", [])
if response.status_code >= 500:
brief = f"Store had an error ({response.status_code})"
else:
brief = f"Error {response.status_code} returned from store"
if len(error_list) == 1:
brief = f"{brief}: {error_list[0].get('message')}"
else:
fancy_error_list = errors.StoreErrorList(error_list)
brief = f"{brief}.\n{fancy_error_list}"
raise errors.CraftStoreError(
brief, store_errors=errors.StoreErrorList(error_list)
)

def get_package_metadata(self, name: str) -> _response.PackageMetadata:
"""Get general metadata for a package.

:param name: The name of the package to query.
:returns: A dictionary matching the result from the publisher gateway.

API docs: https://api.charmhub.io/docs/default.html#package_metadata
"""
response = self._client.get(
url=f"/v1/{self._namespace}/{name}",
)
self._check_error(response)
return cast(_response.PackageMetadata, response.json()["metadata"])

def create_tracks(self, name: str, *tracks: _request.CreateTrackRequest) -> int:
"""Create one or more tracks in the store.

:param name: The store name (i.e. the specific charm, snap or other package)
to which this track will be attached.
:param tracks: Each track is a dictionary mapping query values.
:returns: The number of tracks created by the store.
:returns: InvalidRequestError if the name field of any passed track is invalid.

API docs: https://api.charmhub.io/docs/default.html#create_tracks
"""
bad_track_names = {
track["name"]
for track in tracks
if not _request.TRACK_NAME_REGEX.match(track["name"])
or len(track["name"]) > 28
}
if bad_track_names:
bad_tracks = ", ".join(sorted(bad_track_names))
raise errors.InvalidRequestError(
f"The following track names are invalid: {bad_tracks}",
resolution="Ensure all tracks have valid names.",
)

response = self._client.post(
f"/v1/{self._namespace}/{name}/tracks", json=tracks
)
self._check_error(response)

return int(response.json()["num-tracks-created"])
Loading
Loading