Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 2 additions & 5 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@ name: CI

on:
push:
branches:
- main
- next
branches: [main]
pull_request:
branches:
- main
branches: [main]

permissions:
contents: write
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/generate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:

jobs:
generate:
name: Generate
name: Generate SDK
runs-on: ubuntu-latest
steps:
- name: Checkout code
Expand Down Expand Up @@ -78,4 +78,4 @@ jobs:
TARGET_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
TARGET_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
run: |
git push "https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git" HEAD:${TARGET_REF}
git push "https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git" "HEAD:${TARGET_REF}"
2 changes: 2 additions & 0 deletions codegen/pkg/builder/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,8 @@ func (b *Builder) getReferenceSchema(v *base.SchemaProxy) string {
// formatStringType converts a string schema to a valid Go type.
func formatStringType(t *base.Schema) string {
switch t.Format {
case "password":
return "Secret"
case "date-time":
return "datetime.datetime"
case "date":
Expand Down
32 changes: 32 additions & 0 deletions codegen/pkg/builder/out.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,18 @@ import (
type typesTemplateData struct {
PackageName string
Types []Writable
UsesSecret bool
}

func (b *Builder) generateResourceTypes(tag *base.Tag, schemas []*base.SchemaProxy) error {
types := b.schemasToTypes(schemas)
usesSecret := usesSecretType(types)

typesBuf := bytes.NewBuffer(nil)
if err := b.templates.ExecuteTemplate(typesBuf, "types.py.tmpl", typesTemplateData{
PackageName: strcase.ToSnake(tag.Name),
Types: types,
UsesSecret: usesSecret,
}); err != nil {
return err
}
Expand Down Expand Up @@ -98,6 +101,7 @@ type resourceTemplateData struct {
Params []Writable
Service string
Methods []*Method
UsesSecret bool
}

func (b *Builder) generateResourceFile(tagName string, paths *v3.Paths) ([]string, error) {
Expand Down Expand Up @@ -138,13 +142,16 @@ func (b *Builder) generateResourceFile(tagName string, paths *v3.Paths) ([]strin
slog.Int("methods", len(methods)),
)

usesSecret := usesSecretType(innerTypes)

serviceBuf := bytes.NewBuffer(nil)
if err := b.templates.ExecuteTemplate(serviceBuf, "resource.py.tmpl", resourceTemplateData{
PackageName: strcase.ToSnake(tag.Name),
TypeNames: typeNames,
Params: innerTypes,
Service: strcase.ToCamel(tag.Name),
Methods: methods,
UsesSecret: usesSecret,
}); err != nil {
return nil, err
}
Expand Down Expand Up @@ -201,6 +208,31 @@ func (b *Builder) generateResource(tagName string, paths *v3.Paths) error {
return nil
}

func usesSecretType(writables []Writable) bool {
for _, w := range writables {
if writableUsesSecret(w) {
return true
}
}
return false
}

func writableUsesSecret(w Writable) bool {
switch typed := w.(type) {
case *ClassDeclaration:
for _, f := range typed.Fields {
if strings.Contains(f.Type, "Secret") {
return true
}
}
case *TypeAlias:
if strings.Contains(typed.Type, "Secret") {
return true
}
}
return false
}

func (b *Builder) writeClientFile(fname string, tags []string) error {
f, err := os.OpenFile(fname, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.FileMode(0o755))
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions codegen/templates/resource.py.tmpl
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Code generated by `py-sdk-gen`. DO NOT EDIT.
from .._service import Resource, AsyncResource, HeaderTypes
from .._exceptions import APIError
{{- if .UsesSecret }}
from .._secret import Secret
{{- end }}
{{- with .TypeNames }}
from .types import {{ join ", " . }}
{{- end }}
Expand Down
3 changes: 3 additions & 0 deletions codegen/templates/types.py.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ from ._service import Service
import datetime
import typing
import pydantic
{{- if .UsesSecret }}
from .._secret import Secret
{{- end }}


{{- range $type := .Types }}
Expand Down
69 changes: 69 additions & 0 deletions sumup/_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema

_MASK = "***"


@dataclass(frozen=True)
class Secret:
"""
Secret wraps sensitive string values (e.g. passwords) so they stay masked in
repr/log output while still serializing as plain strings in API requests.
"""

_value: str

def __post_init__(self) -> None:
if not isinstance(self._value, str):
raise TypeError("Secret value must be a string")

def value(self) -> str:
"""Return the underlying secret value."""
return self._value

def __str__(self) -> str:
return _MASK

def __repr__(self) -> str:
return f"Secret({_MASK!r})"

def __bool__(self) -> bool:
return bool(self._value)

def __hash__(self) -> int:
return hash(self._value)

def __eq__(self, other: Any) -> bool:
if isinstance(other, Secret):
return self._value == other._value
if isinstance(other, str):
return self._value == other
return NotImplemented

@classmethod
def __get_pydantic_core_schema__(
cls, _source: Any, _handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
"""Allow pydantic models to accept raw strings but serialize as plain str."""

def validate(value: Any) -> "Secret":
if isinstance(value, Secret):
return value
if isinstance(value, str):
return cls(value)
raise TypeError("Secret value must be a string")

def serialize(value: "Secret") -> str:
return value._value

return core_schema.no_info_plain_validator_function(
validate,
serialization=core_schema.plain_serializer_function_ser_schema(
serialize, when_used="always"
),
)
5 changes: 3 additions & 2 deletions sumup/members/resource.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Code generated by `py-sdk-gen`. DO NOT EDIT.
from .._service import Resource, AsyncResource, HeaderTypes
from .._exceptions import APIError
from .._secret import Secret
from .types import (
Attributes,
Member,
Expand Down Expand Up @@ -48,7 +49,7 @@ class CreateMerchantMemberBody(pydantic.BaseModel):
Nickname of the member to add. Only used if `is_managed_user` is true. Used for display purposes only.
"""

password: typing.Optional[str] = None
password: typing.Optional[Secret] = None
"""
Password of the member to add. Only used if `is_managed_user` is true. In the case of service accounts, thepassword is not used and can not be defined by the caller.
Format: password
Expand All @@ -66,7 +67,7 @@ class UpdateMerchantMemberBodyUser(pydantic.BaseModel):
User's preferred name. Used for display purposes only.
"""

password: typing.Optional[str] = None
password: typing.Optional[Secret] = None
"""
Password of the member to add. Only used if `is_managed_user` is true.
Format: password
Expand Down
27 changes: 27 additions & 0 deletions tests/test_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from sumup._secret import Secret
from sumup.members.resource import CreateMerchantMemberBody


def test_secret_masks_repr_but_preserves_value() -> None:
secret = Secret("super-secret")

assert secret.value() == "super-secret"
assert str(secret) == "***"
assert repr(secret) == "Secret('***')"
assert secret == "super-secret"


def test_password_fields_use_secret_and_dump_plain_text() -> None:
body = CreateMerchantMemberBody(
email="user@example.com",
roles=["role_manager"],
is_managed_user=True,
password=Secret("super-secret"),
)

assert isinstance(body.password, Secret)
dumped = body.model_dump()
assert dumped["password"] == "super-secret"

body_repr = repr(body)
assert "super-secret" not in body_repr
Loading