RESTful API client¶

Example of how to create a RESTful API QML client.

This example shows how to create a basic QML RESTful API client with an imaginary color palette service. The application uses RESTful communication with a local server to request and send data. The REST service is provided as a QML element whose child elements wrap the individual JSON data APIs provided by the server.

Application functionality¶

The example provides the following basic functionalities:

  • List users and colors

  • Login and logout users

  • Modify and create new colors

Running a server¶

The client talks to a local REST server on http://127.0.0.1:49425. Start one of the following before launching the client:

  • A FastAPI-based REST API server bundled under server/ — the easiest option to get started.

  • A Qt-based REST API server C++ example from the QtHttpServer Module, which listens on the same port by default — for developers who want to test against the C++ backend.

Both servers expose the same REST API on the same port, so the client connects to whichever one is running. To run the bundled FastAPI server:

cd server
pip install -r requirements.txt
./start_server.sh

or on Windows:

.\start_server.bat

The FastAPI server is stateful: color additions, edits, and deletions persist for the lifetime of the server process.

The client connects on startup. If no server is reachable, it shows a dialog reporting the failed connection; start a server and relaunch the client.

The users and colors are paginated resources on the server-side. This means that the server provides the data in chunks called pages. The UI listing reflects this pagination and views the data on pages.

Logging in happens via the login function provided by the login popup. Under the hood the login sends a HTTP POST request. Upon receiving a successful response the authorization token is extracted from the response, which in turn is then used in subsequent HTTP requests which require the token.

Editing and adding new colors is done in a popup. Note that uploading the color changes to the server requires that a user has logged in.

REST implementation¶

The example illustrates one way to compose a REST service from individual resource elements. In this example the resources are the paginated user and color resources plus the login service. The resource elements are bound together by the base URL (server URL) and the shared network access manager.

The basis of the REST service is the RestService QML element whose child items compose the actual service.

Upon instantiation the RestService element loops its children elements and sets them up to use the same network access manager. This way the individual resources share the same access details such as the server URL and authorization token.

The actual communication is done with a rest access manager which implements some convenience functionality to deal specifically with HTTP REST APIs and effectively deals with sending and receiving the QNetworkRequest and QNetworkReply as needed.

Viewing the data on UI is done with standard QML views populated by JSON data received from the server via the data property of the class PaginatedResource. For C++ compatibility, it is declared to be of type QList<QJsonObject>. It can be passed a list of dicts as obtained from parsing using QJsonDocument.

RESTful API client

Download this example

from __future__ import annotations

from PySide6.QtCore import QObject
from PySide6.QtQml import QmlAnonymous


QML_IMPORT_NAME = "ColorPalette"
QML_IMPORT_MAJOR_VERSION = 1


@QmlAnonymous
class AbstractResource(QObject):

    def __init__(self, parent=None):
        super().__init__(parent)
        self.m_manager = None  # QRestAccessManager
        self.m_api = None  # QNetworkRequestFactory

    def setAccessManager(self, manager):
        self.m_manager = manager

    def setServiceApi(self, serviceApi):
        self.m_api = serviceApi
from __future__ import annotations

import sys
from functools import partial
from dataclasses import dataclass

from PySide6.QtCore import Property, Signal, Slot
from PySide6.QtNetwork import QHttpHeaders
from PySide6.QtQml import QmlElement

from abstractresource import AbstractResource


tokenField = "token"
emailField = "email"
idField = "id"


QML_IMPORT_NAME = "ColorPalette"
QML_IMPORT_MAJOR_VERSION = 1


@QmlElement
class BasicLogin(AbstractResource):
    @dataclass
    class User:
        email: str
        token: bytes
        id: int

    userChanged = Signal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self.m_user = None
        self.m_loginPath = ""
        self.m_logoutPath = ""
        self.m_user = None

    @Property(str, notify=userChanged)
    def user(self):
        return self.m_user.email if self.m_user else ""

    @Property(bool, notify=userChanged)
    def loggedIn(self):
        return bool(self.m_user)

    @Property(str)
    def loginPath(self):
        return self.m_loginPath

    @loginPath.setter
    def loginPath(self, p):
        self.m_loginPath = p

    @Property(str)
    def logoutPath(self):
        return self.m_logoutPath

    @logoutPath.setter
    def logoutPath(self, p):
        self.m_logoutPath = p

    @Slot("QVariantMap")
    def login(self, data):
        request = self.m_api.createRequest(self.m_loginPath)
        self.m_manager.post(request, data, self, partial(self.loginReply, data))

    def loginReply(self, data, reply):
        self.m_user = None
        if not reply.isSuccess():
            print("login: ", reply.errorString(), file=sys.stderr)
        (json, error) = reply.readJson()
        if json and json.isObject():
            json_object = json.object()
            if token := json_object.get(tokenField):
                email = data[emailField]
                token = json_object[tokenField]
                id = data[idField]
                self.m_user = BasicLogin.User(email, token, id)

        headers = QHttpHeaders()
        headers.append("token", self.m_user.token if self.m_user else "")
        self.m_api.setCommonHeaders(headers)
        self.userChanged.emit()

    @Slot()
    def logout(self):
        request = self.m_api.createRequest(self.m_logoutPath)
        self.m_manager.post(request, b"", self, self.logoutReply)

    def logoutReply(self, reply):
        if reply.isSuccess():
            self.m_user = None
            self.m_api.clearCommonHeaders()  # clears 'token' header
            self.userChanged.emit()
        else:
            print("logout: ", reply.errorString(), file=sys.stderr)
from __future__ import annotations

"""PySide6 port of the Qt RESTful API client demo from Qt v6.x"""

import os
import sys
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from pathlib import Path

from PySide6.QtCore import QUrl
from PySide6.QtGui import QIcon, QGuiApplication
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle

from basiclogin import BasicLogin  # noqa: F401
from paginatedresource import PaginatedResource  # noqa: F401
from restservice import RestService  # noqa: F401
import rc_colorpaletteclient  # noqa: F401

if __name__ == "__main__":
    parser = ArgumentParser(formatter_class=RawDescriptionHelpFormatter)
    parser.add_argument("--url", type=str, help="The URL to open")
    args = parser.parse_args()
    url = args.url if args.url else "http://127.0.0.1:49425/api"

    app = QGuiApplication(sys.argv)
    QIcon.setThemeName("colorpaletteclient")

    QQuickStyle.setStyle("Fusion")

    engine = QQmlApplicationEngine()
    engine.setInitialProperties({"serverUrl": QUrl.fromUserInput(url)})

    app_dir = Path(__file__).parent
    app_dir_url = QUrl.fromLocalFile(os.fspath(app_dir))
    engine.addImportPath(os.fspath(app_dir))
    engine.loadFromModule("ColorPalette", "Main")
    if not engine.rootObjects():
        sys.exit(-1)

    exit_code = app.exec()
    del engine
    sys.exit(exit_code)
from __future__ import annotations

import sys
from PySide6.QtCore import (QUrlQuery, Property, Signal, Slot)
from PySide6.QtNetwork import QNetworkReply
from PySide6.QtQml import QmlElement

from abstractresource import AbstractResource


QML_IMPORT_NAME = "ColorPalette"
QML_IMPORT_MAJOR_VERSION = 1


totalPagesField = "total_pages"
currentPageField = "page"


@QmlElement
class PaginatedResource(AbstractResource):
    """This class manages a simple paginated Crud resource,
       where the resource is a paginated list of JSON items."""

    dataUpdated = Signal()
    pageUpdated = Signal()
    pagesUpdated = Signal()
    errorOccurred = Signal(str)

    def __init__(self, parent=None):
        super().__init__(parent)
        # The total number of pages as reported by the server responses
        self.m_pages = 0
        # The default page we request if the user hasn't set otherwise
        self.m_currentPage = 1
        self.m_path = ""
        self._data = []

    @Property(str)
    def path(self):
        return self.m_path

    @path.setter
    def path(self, p):
        self.m_path = p

    @Property(int, notify=pagesUpdated)
    def pages(self):
        return self.m_pages

    @Property(int, notify=pageUpdated)
    def page(self):
        return self.m_currentPage

    @page.setter
    def page(self, page):
        if self.m_currentPage == page or page < 1:
            return
        self.m_currentPage = page
        self.pageUpdated.emit()
        self.refreshCurrentPage()

    @Slot()
    def refreshCurrentPage(self):
        query = QUrlQuery()
        query.addQueryItem("page", str(self.m_currentPage))
        request = self.m_api.createRequest(self.m_path, query)
        self.m_manager.get(request, self, self.refreshCurrentPageReply)

    def refreshCurrentPageReply(self, reply):
        error = ""
        if reply.isSuccess():
            (json, jsonError) = reply.readJson()
            if json:
                self.refreshRequestFinished(json)
            else:
                error = jsonError.errorString()
        else:
            error = (reply.errorString() if reply.error() != QNetworkReply.NetworkError.NoError
                     else f"HTTP status: {reply.httpStatus()}")
        if error:
            url = reply.networkReply().url().toString()
            print(f'PaginatedResource: request "{url}" failed: "{error}"', file=sys.stderr)
            self.errorOccurred.emit(error)
            self.refreshRequestFailed()

    def refreshRequestFinished(self, json):
        json_object = json.object()
        data = json_object.get("data")
        totalPages = json_object.get(totalPagesField)
        currentPage = json_object.get(currentPageField)
        self._data = data if data else []
        self.m_pages = int(totalPages) if totalPages else 1
        self.m_currentPage = int(currentPage) if currentPage else 1
        self.pageUpdated.emit()
        self.pagesUpdated.emit()
        self.dataUpdated.emit()

    def refreshRequestFailed(self):
        if self.m_currentPage != 1:
            # A failed refresh. If we weren't on page 1, try that.
            # Last resource on currentPage might have been deleted, causing a failure
            self.setPage(1)
        else:
            # Refresh failed and we we're already on page 1 => clear data
            self.m_pages = 0
            self.pagesUpdated.emit()
            self._data = []
            self.dataUpdated.emit()

    @Slot("QVariantMap", int)
    def update(self, data, id):
        request = self.m_api.createRequest(f"{self.m_path}/{id}")
        self.m_manager.put(request, data, self, self.updateReply)

    def updateReply(self, reply):
        if reply.isSuccess():
            self.refreshCurrentPage()

    @Slot("QVariantMap")
    def add(self, data):
        request = self.m_api.createRequest(self.m_path)
        self.m_manager.post(request, data, self, self.updateReply)

    @Slot(int)
    def remove(self, id):
        request = self.m_api.createRequest(f"{self.m_path}/{id}")
        self.m_manager.deleteResource(request, self, self.updateReply)

    @Property("QList<QJsonObject>", notify=dataUpdated, final=True)
    def data(self):
        return self._data
from __future__ import annotations

from PySide6.QtCore import Property, Signal, ClassInfo
from PySide6.QtNetwork import (QNetworkAccessManager, QRestAccessManager,
                               QNetworkRequestFactory, QSslSocket)
from PySide6.QtQml import QmlElement, QPyQmlParserStatus, ListProperty
from abstractresource import AbstractResource

QML_IMPORT_NAME = "ColorPalette"
QML_IMPORT_MAJOR_VERSION = 1


@QmlElement
@ClassInfo(DefaultProperty="resources")
class RestService(QPyQmlParserStatus):

    urlChanged = Signal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self.m_resources = []
        self.m_qnam = QNetworkAccessManager()
        self.m_qnam.setAutoDeleteReplies(True)
        self.m_manager = QRestAccessManager(self.m_qnam)
        self.m_serviceApi = QNetworkRequestFactory()

    @Property(str, notify=urlChanged)
    def url(self):
        return self.m_serviceApi.baseUrl()

    @url.setter
    def url(self, url):
        if self.m_serviceApi.baseUrl() != url:
            self.m_serviceApi.setBaseUrl(url)
            self.urlChanged.emit()

    @Property(bool, constant=True)
    def sslSupported(self):
        return QSslSocket.supportsSsl()

    def classBegin(self):
        pass

    def componentComplete(self):
        for resource in self.m_resources:
            resource.setAccessManager(self.m_manager)
            resource.setServiceApi(self.m_serviceApi)

    def appendResource(self, r):
        self.m_resources.append(r)

    resources = ListProperty(AbstractResource, appendResource)
<RCC>
    <qresource prefix="/qt/qml/ColorPalette">
        <file>icons/close.svg</file>
        <file>icons/close_dark.svg</file>
        <file>icons/delete.svg</file>
        <file>icons/delete_dark.svg</file>
        <file>icons/dots.svg</file>
        <file>icons/edit.svg</file>
        <file>icons/edit_dark.svg</file>
        <file>icons/login.svg</file>
        <file>icons/login_dark.svg</file>
        <file>icons/logout.svg</file>
        <file>icons/logout_dark.svg</file>
        <file>icons/ok.svg</file>
        <file>icons/ok_dark.svg</file>
        <file>icons/plus.svg</file>
        <file>icons/plus_dark.svg</file>
        <file>icons/qt.png</file>
        <file>icons/update.svg</file>
        <file>icons/update_dark.svg</file>
        <file>icons/user.svg</file>
        <file>icons/userMask.svg</file>
        <file>icons/user_dark.svg</file>
    </qresource>
</RCC>
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Popup {
    id: colorDeleter
    padding: 10
    modal: true
    focus: true
    anchors.centerIn: parent
    closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
    signal deleteClicked(int cid)

    property int colorId: -1

    property string colorName: ""

    function maybeDelete(data) {
        colorName = data.name
        colorId = data.id
        open()
    }


    ColumnLayout {
        anchors.fill: parent
        spacing: 10

        Label {
            text: qsTr("Delete Color?")
            font.bold: true
        }

        Label {
            text: qsTr("Are you sure, you want to delete color") + " \"" + colorDeleter.colorName + "\"?"
        }

        RowLayout {
            Layout.fillWidth: true
            spacing: 10

            Button {
                Layout.fillWidth: true
                text: qsTr("Cancel")
                onClicked: colorDeleter.close()
            }

            Button {
                Layout.fillWidth: true
                text: qsTr("Delete")

                onClicked: {
                    colorDeleter.deleteClicked(colorDeleter.colorId)
                    colorDeleter.close()
                }
            }
       }
    }
}
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Dialogs

Popup {
    id: colorEditor
    // Popup for adding or updating a color
    padding: 10
    modal: true
    focus: true
    anchors.centerIn: parent
    closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
    signal colorAdded(string name, string color, string pantone_value)
    signal colorUpdated(string name, string color, string pantone_value, int cid)

    property bool newColor: true
    property int colorId: -1
    property color currentColor: "white"

    function createNewColor() {
        newColor = true
        colorNameField.text = "cute green"
        currentColor = Qt.color("#41cd52")
        colorPantoneField.text = "PMS 802C"
        colorDialog.selectedColor = currentColor
        open()
    }

    function updateColor(data) {
        newColor = false
        colorNameField.text = data.name
        currentColor = Qt.color(data.color)
        colorPantoneField.text = data.pantone_value
        colorId = data.id
        colorDialog.selectedColor = currentColor
        open()
    }

    ColorDialog {
        id: colorDialog
        title: qsTr("Choose a color")
        onAccepted: {
            colorEditor.currentColor = Qt.color(colorDialog.selectedColor)
            colorDialog.close()
        }
        onRejected: {
            colorDialog.close()
        }
    }

    ColumnLayout {
        anchors.fill: parent
        spacing: 10

        GridLayout {
            columns: 2
            rowSpacing: 10
            columnSpacing: 10

            Label {
                text: qsTr("Color Name")
            }
            TextField {
                id: colorNameField
                padding: 10
            }

            Label {
                text: qsTr("Pantone Value")
            }
            TextField {
                id: colorPantoneField
                padding: 10
            }

            Label {
                text: qsTr("Rgb Value")
            }

            TextField {
                id: colorRGBField
                text: colorEditor.currentColor.toString()
                readOnly: true
                padding: 10
            }
        }

        RowLayout {
            Layout.fillWidth: true
            spacing: 10

            Rectangle {
                Layout.preferredWidth: 30
                Layout.preferredHeight: 30
                Layout.minimumWidth: 30
                Layout.minimumHeight: 30
                radius: 4
                border.width: 1
                border.color: palette.mid
                color: colorEditor.currentColor
            }

            Button {
                Layout.fillWidth: true
                text: qsTr("Change Color")
                onClicked: colorDialog.open()
            }
        }

        RowLayout {
            Layout.fillWidth: true
            spacing: 10

            Button {
                text: qsTr("Cancel")
                onClicked: colorEditor.close()
                Layout.fillWidth: true
            }

            Button {
                Layout.fillWidth: true
                text: colorEditor.newColor ? qsTr("Add") : qsTr("Update")

                onClicked: {
                    if (colorEditor.newColor) {
                        colorEditor.colorAdded(colorNameField.text,
                                               colorRGBField.text,
                                               colorPantoneField.text)
                    } else {
                        colorEditor.colorUpdated(colorNameField.text,
                                                 colorRGBField.text,
                                                 colorPantoneField.text,
                                                 colorEditor.colorId)
                    }
                    colorEditor.close()
                }
            }
        }
    }
}
pragma ComponentBehavior: Bound

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
import QtQuick.Shapes

Rectangle {
    id: root
    required property BasicLogin loginService
    required property PaginatedResource colors
    required property PaginatedResource colorViewUsers

    color: root.palette.window

    function iconPath(baseImagePath) {
        return Application.styleHints.colorScheme === Qt.ColorScheme.Dark
            ? `qrc:/qt/qml/ColorPalette/icons/${baseImagePath}_dark.svg`
            : `qrc:/qt/qml/ColorPalette/icons/${baseImagePath}.svg`
    }

    ColorDialogEditor {
        id: colorPopup
        onColorAdded: (colorNameField, colorRGBField, colorPantoneField) => {
            root.colors.add({"name" : colorNameField,
                        "color" : colorRGBField,
                        "pantone_value" : colorPantoneField})
        }

        onColorUpdated: (colorNameField, colorRGBField, colorPantoneField, cid) => {
            root.colors.update({"name" : colorNameField,
                        "color" : colorRGBField,
                        "pantone_value" : colorPantoneField},
                        cid)
        }
    }

    ColorDialogDelete {
        id: colorDeletePopup
        onDeleteClicked: (cid) => {
            root.colors.remove(cid)
        }
    }

    ColumnLayout {
        // The main application layout
        anchors.fill :parent
        spacing: 0
        ToolBar {
            Layout.fillWidth: true
            Layout.minimumHeight: 35

            UserMenu {
                id: userMenu

                userMenuUsers: root.colorViewUsers
                userLoginService: root.loginService
            }

            RowLayout {
                anchors.fill: parent
                anchors.leftMargin: 5
                anchors.rightMargin: 5

                Button {
                    Layout.preferredWidth: 25
                    Layout.preferredHeight: 25
                    Layout.minimumWidth: 25
                    Layout.minimumHeight: 25
                    Layout.alignment: Qt.AlignVCenter

                    display: AbstractButton.IconOnly
                    icon.source: root.iconPath("plus")

                    ToolTip.visible: hovered
                    ToolTip.delay: 500
                    ToolTip.text: qsTr("Add a new color")

                    enabled: root.loginService.loggedIn
                    onClicked: colorPopup.createNewColor()
                }

                Button {
                    Layout.preferredWidth: 25
                    Layout.preferredHeight: 25
                    Layout.minimumWidth: 25
                    Layout.minimumHeight: 25
                    Layout.alignment: Qt.AlignVCenter

                    display: AbstractButton.IconOnly
                    icon.source: root.iconPath("update")

                    ToolTip.visible: hovered
                    ToolTip.delay: 500
                    ToolTip.text: qsTr("Refresh colors and users")

                    onClicked: {
                        root.colors.refreshCurrentPage()
                        root.colorViewUsers.refreshCurrentPage()
                    }
                }

                Item { Layout.fillWidth: true }

                Image {
                    Layout.preferredWidth: 25
                    Layout.preferredHeight: 25
                    Layout.minimumWidth: 25
                    Layout.minimumHeight: 25
                    Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft

                    source: "qrc:/qt/qml/ColorPalette/icons/qt.png"
                    fillMode: Image.PreserveAspectFit
                }

                Label {
                    Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft

                    text: qsTr("Color Palette")
                    font.bold: true
                }

                Item { Layout.fillWidth: true }

                AbstractButton {
                    id: loginButton
                    Layout.preferredWidth: 25
                    Layout.preferredHeight: 25
                    Layout.minimumWidth: 25
                    Layout.minimumHeight: 25

                    ToolTip.visible: hovered
                    ToolTip.delay: 500
                    ToolTip.text: root.loginService.loggedIn
                                  ? qsTr("Logged in as %1").arg(root.loginService.user)
                                  : qsTr("Log in")

                    Item {
                        id: userImageCliped
                        anchors.left: parent.left
                        anchors.verticalCenter: parent.verticalCenter
                        width: 25
                        height: 25

                        Image {
                            id: userImage
                            anchors.fill: parent
                            source: getCurrentUserImage()
                            visible: false

                            function getCurrentUserImage() {
                                if (!root.loginService.loggedIn)
                                    return root.iconPath("user");
                                let users = root.colorViewUsers
                                for (let i = 0; i < users.data.length; i++) {
                                    if (users.data[i].email === root.loginService.user)
                                        return users.data[i].avatar;
                                }
                            }
                        }

                        Image {
                            id: userMask
                            source: "qrc:/qt/qml/ColorPalette/icons/userMask.svg"
                            anchors.fill: userImage
                            anchors.margins: 4
                            visible: false
                        }

                        MultiEffect {
                            source: userImage
                            anchors.fill: userImage
                            maskSource: userMask
                            maskEnabled: true
                        }
                    }

                    onClicked: {
                        userMenu.open()
                        var pos = mapToGlobal(Qt.point(x, y))
                        pos = userMenu.parent.mapFromGlobal(pos)
                        userMenu.x = x - userMenu.width + 50
                        userMenu.y = y + 15
                    }

                    Shape {
                       id: bubble
                       x: -text.width - 25
                       y: -3
                       anchors.margins: 3

                       preferredRendererType: Shape.CurveRenderer

                        visible: !root.loginService.loggedIn

                       ShapePath {
                           strokeWidth: 0
                           fillColor: root.palette.highlight
                           strokeColor: Qt.darker(root.palette.highlight, 1.3)
                           startX: 5; startY: 0
                           PathLine { x: 5 + text.width + 6; y: 0 }
                           PathArc { x: 10 + text.width + 6; y: 5; radiusX: 5; radiusY: 5}
                           // arrow
                           PathLine { x: 10 + text.width + 6; y: 8 + text.height / 2 - 6 }
                           PathLine { x: 10 + text.width + 6 + 6; y: 8 + text.height / 2 }
                           PathLine { x: 10 + text.width + 6; y: 8 + text.height / 2 + 6}
                           PathLine { x: 10 + text.width + 6; y: 5 + text.height + 6 }
                           // end arrow
                           PathArc { x: 5 + text.width + 6; y: 10 + text.height + 6 ; radiusX: 5; radiusY: 5}
                           PathLine { x: 5; y: 10 + text.height + 6 }
                           PathArc { x: 0; y: 5 + text.height + 6 ; radiusX: 5; radiusY: 5}
                           PathLine { x: 0; y: 5 }
                           PathArc { x: 5; y: 0 ; radiusX: 5; radiusY: 5}
                       }
                       Text {
                           x: 8
                           y: 8
                           id: text
                           color: root.palette.highlightedText
                           text: qsTr("Log in to edit")
                           font.bold: true
                           horizontalAlignment: Qt.AlignHCenter
                           verticalAlignment: Qt.AlignVCenter
                       }
                   }
                }

            }
        }



        //! [View and model]
        ListView {
            id: colorListView

            model: root.colors.data
        //! [View and model]
            footerPositioning: ListView.OverlayFooter
            spacing: 15
            clip: true

            Layout.fillHeight: true
            Layout.fillWidth: true

            header:  Rectangle {
                height: 32
                width: parent.width
                color: palette.window

                RowLayout {
                    anchors.fill: parent

                    component HeaderText : Label {
                        Layout.alignment: Qt.AlignVCenter
                        horizontalAlignment: Qt.AlignHCenter
                    }
                    HeaderText {
                        id: headerName
                        text: qsTr("Color Name")
                        Layout.fillWidth: true
                        Layout.horizontalStretchFactor: 30
                    }
                    HeaderText {
                        id: headerRgb
                        text: qsTr("Rgb Value")
                        Layout.fillWidth: true
                        Layout.horizontalStretchFactor: 25
                    }
                    HeaderText {
                        id: headerPantone
                        text: qsTr("Pantone Value")
                        Layout.fillWidth: true
                        Layout.horizontalStretchFactor: 25
                    }
                    HeaderText {
                        id: headerAction
                        text: qsTr("Action")
                        Layout.fillWidth: true
                        Layout.horizontalStretchFactor: 20
                    }
                }
            }

            delegate: Item {
                id: colorInfo

                required property var modelData

                width: colorListView.width
                height: (colorListView.height - 55) / 6 - colorListView.spacing
                // Header: 35, Footer 20, 55 together
                RowLayout {
                    anchors.fill: parent
                    anchors.leftMargin: 5
                    anchors.rightMargin: 5

                    Rectangle {
                        id: colorSample
                        Layout.alignment: Qt.AlignVCenter
                        Layout.minimumWidth: 36
                        Layout.minimumHeight: 36
                        implicitWidth: 36
                        implicitHeight: 36
                        radius: 6
                        color: colorInfo.modelData.color
                    }

                    Label {
                        Layout.preferredWidth: colorInfo.width * 0.3 - colorSample.width
                        horizontalAlignment: Qt.AlignLeft
                        leftPadding: 5
                        text: colorInfo.modelData.name
                    }

                    Label {
                        Layout.preferredWidth: colorInfo.width * 0.25
                        horizontalAlignment: Qt.AlignHCenter
                        text: colorInfo.modelData.color
                    }

                    Label {
                        Layout.preferredWidth: colorInfo.width * 0.25
                        horizontalAlignment: Qt.AlignHCenter
                        text: colorInfo.modelData.pantone_value
                    }

                    Item {
                        Layout.maximumHeight: 28
                        Layout.minimumWidth: buttonBox.implicitWidth
                        Layout.minimumHeight: buttonBox.implicitHeight
                        implicitHeight: buttonBox.implicitHeight
                        implicitWidth: buttonBox.implicitWidth

                        RowLayout {
                            id: buttonBox
                            anchors.fill: parent
                            ToolButton {
                                icon.source: root.iconPath("delete")
                                ToolTip.visible: hovered
                                ToolTip.delay: 500
                                ToolTip.text: qsTr("Delete color")
                                enabled: root.loginService.loggedIn
                                onClicked: colorDeletePopup.maybeDelete(colorInfo.modelData)
                            }
                            ToolButton {
                                icon.source: root.iconPath("edit")
                                ToolTip.visible: hovered
                                ToolTip.delay: 500
                                ToolTip.text: qsTr("Edit color")
                                enabled: root.loginService.loggedIn
                                onClicked: colorPopup.updateColor(colorInfo.modelData)
                            }
                        }
                    }
                }
            }

            footer: ToolBar {
                // Paginate buttons if more than one page
                visible: root.colors.pages > 1
                implicitWidth: parent.width

                RowLayout {
                    anchors.fill: parent

                    Item { Layout.fillWidth: true /* spacer */ }

                    Repeater {
                        model: root.colors.pages

                        ToolButton {
                            text: page
                            font.bold: root.colors.page === page

                            required property int index
                            readonly property int page: (index + 1)

                            ToolTip.visible: hovered
                            ToolTip.delay: 500
                            ToolTip.text: qsTr("Go to page %1").arg(page)

                            onClicked: root.colors.page = page
                        }
                    }
                }
            }
        }
    }
}
pragma ComponentBehavior: Bound

import QtQuick
import QtQuick.Dialogs

Window {
    id: window
    width: 500
    height: 400
    minimumWidth: 500
    minimumHeight: 400
    visible: true
    title: qsTr("Color Palette Client")

    enum DataView {
        UserView = 0,
        ColorView = 1
    }

    // The Qt colorpalette REST API server listens here, so the client
    // just connects on startup.
    required property var serverUrl

    ColorView {
        id: colorview
        anchors.fill: parent
        loginService: colorLogin
        colors: colorPalette
        colorViewUsers: users
    }

    MessageDialog {
        id: connectionErrorDialog
        title: qsTr("Connection failed")
        text: qsTr("Could not reach the server at %1.").arg(window.serverUrl)
        informativeText: qsTr("Start the Qt colorpalette REST API server "
            + "on port 49425, then retry.")
        buttons: MessageDialog.Cancel | MessageDialog.Retry
        onButtonClicked: function(button) {
            if (button === MessageDialog.Retry) {
                colorPalette.refreshCurrentPage()
                users.refreshCurrentPage()
            } else if (button === MessageDialog.Cancel) {
                Qt.quit()
            }
        }
    }

    Connections {
        target: colorPalette
        function onErrorOccurred(message) { connectionErrorDialog.open() }
    }

    //! [RestService QML element]
    RestService {
        id: paletteService
        url: window.serverUrl

        PaginatedResource {
            id: users
            path: "users"
        }

        PaginatedResource {
            id: colorPalette
            path: "unknown"
        }

        BasicLogin {
            id: colorLogin
            loginPath: "login"
            logoutPath: "logout"
        }
    }
    //! [RestService QML element]

    Component.onCompleted: {
        colorPalette.refreshCurrentPage()
        users.refreshCurrentPage()
    }
}
pragma ComponentBehavior: Bound

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects

Popup {
    id: userMenu

    required property BasicLogin userLoginService
    required property PaginatedResource userMenuUsers

    width: 280
    height: 270

    background: Item {}

    function iconPath(baseImagePath) {
        return Application.styleHints.colorScheme === Qt.ColorScheme.Dark
            ? `qrc:/qt/qml/ColorPalette/icons/${baseImagePath}_dark.svg`
            : `qrc:/qt/qml/ColorPalette/icons/${baseImagePath}.svg`
    }

    Rectangle {
        radius: 8
        border.width: 0
        color: palette.window

        anchors.fill: parent

        ListView {
            id: userListView
            anchors.fill: parent
            anchors.leftMargin: 10
            anchors.rightMargin: 5
            anchors.topMargin: 5
            anchors.bottomMargin: 2

            model: userMenu.userMenuUsers.data
            spacing: 7
            footerPositioning: ListView.PullBackFooter
            clip: true

            Layout.fillHeight: true
            Layout.fillWidth: true

            delegate: Item {
                id: userInfo

                height: 30
                width: userListView.width

                required property var modelData
                readonly property bool logged: (modelData.email === userMenu.userLoginService.user)

                Item {
                    id: userImageCliped
                    anchors.left: parent.left
                    anchors.verticalCenter: parent.verticalCenter
                    width: 30
                    height: 30

                    Image {
                        id: userImage
                        anchors.fill: parent
                        source: userInfo.modelData.avatar
                        visible: false
                    }

                    Image {
                        id: userMask
                        source: "qrc:/qt/qml/ColorPalette/icons/userMask.svg"
                        anchors.fill: userImage
                        anchors.margins: 4
                        visible: false
                    }

                    MultiEffect {
                        source: userImage
                        anchors.fill: userImage
                        maskSource: userMask
                        maskEnabled: true
                    }
                }

                Label {
                    id: userMailLabel
                    anchors.left: userImageCliped.right
                    anchors.verticalCenter: parent.verticalCenter
                    anchors.margins: 5
                    text: userInfo.modelData.email
                    font.bold: userInfo.logged
                }

                ToolButton {
                    anchors.right: parent.right
                    anchors.verticalCenter: parent.verticalCenter
                    anchors.margins: 5

                    icon.source: userMenu.iconPath(userInfo.logged
                                 ? "logout" : "login")
                    enabled: userInfo.logged || !userMenu.userLoginService.loggedIn

                    ToolTip.visible: hovered
                    ToolTip.delay: 500
                    ToolTip.text: userInfo.logged
                                  ? qsTr("Log out")
                                  : qsTr("Log in as %1").arg(userInfo.modelData.email)

                    onClicked: {
                        if (userInfo.logged) {
                            userMenu.userLoginService.logout()
                        } else {
                            //! [Login]
                            userMenu.userLoginService.login({"email" : userInfo.modelData.email,
                                                "password" : "apassword",
                                                "id" : userInfo.modelData.id})
                            //! [Login]
                            userMenu.close()
                        }
                    }
                }

            }
            footer: ToolBar {
                // Paginate buttons if more than one page
                visible: userMenu.userMenuUsers.pages > 1
                implicitWidth: parent.width

                RowLayout {
                    anchors.fill: parent

                    Item { Layout.fillWidth: true /* spacer */ }

                    Repeater {
                        model: userMenu.userMenuUsers.pages

                        ToolButton {
                            text: page
                            font.bold: userMenu.userMenuUsers.page === page

                            required property int index
                            readonly property int page: (index + 1)

                            ToolTip.visible: hovered
                            ToolTip.delay: 500
                            ToolTip.text: qsTr("Go to page %1").arg(page)

                            onClicked: userMenu.userMenuUsers.page = page
                        }
                    }
                }
            }
        }
    }

    Rectangle {
        radius: 8
        border.color: palette.mid
        border.width: 2
        color: "transparent"

        anchors.fill: parent
    }
}
module ColorPalette
Main 1.0 Main.qml
ColorDialogDelete 1.0 ColorDialogDelete.qml
ColorDialogEditor 1.0 ColorDialogEditor.qml
ColorView 1.0 ColorView.qml
UserMenu 1.0 UserMenu.qml
<RCC>
    <qresource prefix="/qt/qml/ColorPalette">
        <file>icons/close.svg</file>
        <file>icons/close_dark.svg</file>
        <file>icons/delete.svg</file>
        <file>icons/delete_dark.svg</file>
        <file>icons/dots.svg</file>
        <file>icons/edit.svg</file>
        <file>icons/edit_dark.svg</file>
        <file>icons/login.svg</file>
        <file>icons/login_dark.svg</file>
        <file>icons/logout.svg</file>
        <file>icons/logout_dark.svg</file>
        <file>icons/ok.svg</file>
        <file>icons/ok_dark.svg</file>
        <file>icons/plus.svg</file>
        <file>icons/plus_dark.svg</file>
        <file>icons/qt.png</file>
        <file>icons/update.svg</file>
        <file>icons/update_dark.svg</file>
        <file>icons/user.svg</file>
        <file>icons/userMask.svg</file>
        <file>icons/user_dark.svg</file>
    </qresource>
</RCC>