openapi: 3.0.3 info: title: Test REST API version: 1.0.0 description: | Testowy serwis REST API (CakePHP 5): klienci (`customers`), ich auta (`cars`) i użytkownicy (`users`). ## Uwierzytelnianie 1. Wywołaj `POST /api/auth/login` z e-mailem i hasłem. 2. Skopiuj `data.token` i kliknij **Authorize** (u góry strony) – wklej sam token (bez `Bearer`). 3. Token wysyłany jest w nagłówku `Authorization: Bearer ` i jest ważny 8 godzin. Konta testowe: | Rola | E-mail | Hasło | |-------|---------------------|------------| | admin | admin@example.com | Admin123! | | user | user@example.com | User1234! | ## Uprawnienia * **admin** – pełny dostęp, w tym CRUD użytkowników (`/api/users`). * **user** – logowanie, własny profil (`/api/auth/me`), CRUD klientów i aut. ## Format odpowiedzi * Sukces: `{"success": true, "data": ...}` (listy dodatkowo mają `meta` z paginacją). * Błąd: `{"success": false, "error": {"code": 422, "message": "...", "fields": {...}}}`. * `DELETE` zwraca `204 No Content`. ## Listy Wszystkie listy obsługują `page`, `limit` (domyślnie 20, max 100), `sort` i `direction` (`asc`/`desc`). Łączna liczba rekordów jest również w nagłówku `X-Total-Count`. servers: - url: / description: Ten serwer security: - bearerAuth: [] tags: - name: Auth description: Logowanie i profil zalogowanego użytkownika - name: Customers description: Klienci (wszyscy zalogowani) - name: Cars description: Auta klientów (wszyscy zalogowani) - name: Users description: Użytkownicy systemu (tylko admin) paths: /api/auth/login: post: tags: [Auth] summary: Logowanie – zwraca token JWT security: [] requestBody: required: true content: application/json: schema: type: object required: [email, password] properties: email: { type: string, format: email, example: admin@example.com } password: { type: string, format: password, example: Admin123! } responses: '200': description: Zalogowano content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: token: { type: string, example: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... } token_type: { type: string, example: Bearer } expires_in: { type: integer, description: Czas ważności w sekundach, example: 28800 } user: { $ref: '#/components/schemas/User' } '401': { $ref: '#/components/responses/Unauthorized' } /api/auth/me: get: tags: [Auth] summary: Profil zalogowanego użytkownika responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/UserResponse' } '401': { $ref: '#/components/responses/Unauthorized' } patch: tags: [Auth] summary: Edycja własnego profilu (imię, nazwisko, e-mail, hasło) description: Pola `role` i `active` są ignorowane – może je zmienić tylko admin przez `/api/users/{id}`. requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ProfileInput' } responses: '200': description: Zaktualizowano content: application/json: schema: { $ref: '#/components/schemas/UserResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/ValidationError' } put: tags: [Auth] summary: Edycja własnego profilu (alias PATCH) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ProfileInput' } responses: '200': description: Zaktualizowano content: application/json: schema: { $ref: '#/components/schemas/UserResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/ValidationError' } /api/customers: get: tags: [Customers] summary: Lista klientów parameters: - $ref: '#/components/parameters/page' - $ref: '#/components/parameters/limit' - name: sort in: query schema: type: string enum: [id, type, first_name, last_name, email, company_name, city, country, created, modified] - $ref: '#/components/parameters/direction' - name: q in: query description: Szukaj w imieniu, nazwisku, e-mailu, telefonie, nazwie firmy, mieście i NIP schema: { type: string } - name: type in: query schema: { type: string, enum: [individual, company] } - name: city in: query schema: { type: string } - name: country in: query schema: { type: string, example: PL } - name: with_cars in: query description: Dołącz listę aut każdego klienta schema: { type: boolean } responses: '200': description: OK content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: array items: { $ref: '#/components/schemas/Customer' } meta: { $ref: '#/components/schemas/PaginationMeta' } '401': { $ref: '#/components/responses/Unauthorized' } post: tags: [Customers] summary: Dodaj klienta requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CustomerCreate' } responses: '201': description: Utworzono content: application/json: schema: { $ref: '#/components/schemas/CustomerResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/ValidationError' } /api/customers/{id}: parameters: - $ref: '#/components/parameters/id' get: tags: [Customers] summary: Szczegóły klienta (z autami) responses: '200': description: OK content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: '#/components/schemas/CustomerWithCars' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } patch: tags: [Customers] summary: Częściowa edycja klienta requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CustomerInput' } responses: '200': description: Zaktualizowano content: application/json: schema: { $ref: '#/components/schemas/CustomerResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } put: tags: [Customers] summary: Edycja klienta requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CustomerInput' } responses: '200': description: Zaktualizowano content: application/json: schema: { $ref: '#/components/schemas/CustomerResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } delete: tags: [Customers] summary: Usuń klienta (razem z jego autami) responses: '204': { description: Usunięto } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /api/customers/{customer_id}/cars: parameters: - name: customer_id in: path required: true schema: { type: integer, example: 1 } get: tags: [Cars] summary: Auta danego klienta parameters: - $ref: '#/components/parameters/page' - $ref: '#/components/parameters/limit' - $ref: '#/components/parameters/carSort' - $ref: '#/components/parameters/direction' responses: '200': { $ref: '#/components/responses/CarList' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } post: tags: [Cars] summary: Dodaj auto do klienta description: '`customer_id` jest brany ze ścieżki.' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CarCreateNested' } responses: '201': description: Utworzono content: application/json: schema: { $ref: '#/components/schemas/CarResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } /api/cars: get: tags: [Cars] summary: Lista aut parameters: - $ref: '#/components/parameters/page' - $ref: '#/components/parameters/limit' - $ref: '#/components/parameters/carSort' - $ref: '#/components/parameters/direction' - name: q in: query description: Szukaj w marce, modelu, VIN i numerze rejestracyjnym schema: { type: string } - name: customer_id in: query schema: { type: integer } - name: make in: query schema: { type: string, example: Toyota } - name: fuel_type in: query schema: { $ref: '#/components/schemas/FuelType' } - name: transmission in: query schema: { type: string, enum: [manual, automatic] } - name: year_from in: query schema: { type: integer, example: 2015 } - name: year_to in: query schema: { type: integer, example: 2022 } - name: with_customer in: query description: Dołącz dane właściciela schema: { type: boolean } responses: '200': { $ref: '#/components/responses/CarList' } '401': { $ref: '#/components/responses/Unauthorized' } post: tags: [Cars] summary: Dodaj auto requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CarCreate' } responses: '201': description: Utworzono content: application/json: schema: { $ref: '#/components/schemas/CarResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '422': { $ref: '#/components/responses/ValidationError' } /api/cars/{id}: parameters: - $ref: '#/components/parameters/id' get: tags: [Cars] summary: Szczegóły auta (z właścicielem) responses: '200': description: OK content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: '#/components/schemas/CarWithCustomer' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } patch: tags: [Cars] summary: Częściowa edycja auta description: Zmiana `customer_id` przenosi auto do innego klienta. requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CarInput' } responses: '200': description: Zaktualizowano content: application/json: schema: { $ref: '#/components/schemas/CarResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } put: tags: [Cars] summary: Edycja auta requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CarInput' } responses: '200': description: Zaktualizowano content: application/json: schema: { $ref: '#/components/schemas/CarResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } delete: tags: [Cars] summary: Usuń auto responses: '204': { description: Usunięto } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /api/users: get: tags: [Users] summary: Lista użytkowników (admin) parameters: - $ref: '#/components/parameters/page' - $ref: '#/components/parameters/limit' - name: sort in: query schema: type: string enum: [id, email, first_name, last_name, role, active, last_login, created, modified] - $ref: '#/components/parameters/direction' - name: q in: query description: Szukaj w e-mailu, imieniu i nazwisku schema: { type: string } - name: role in: query schema: { $ref: '#/components/schemas/Role' } - name: active in: query schema: { type: boolean } responses: '200': description: OK content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: array items: { $ref: '#/components/schemas/User' } meta: { $ref: '#/components/schemas/PaginationMeta' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } post: tags: [Users] summary: Dodaj użytkownika (admin) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/UserCreate' } responses: '201': description: Utworzono content: application/json: schema: { $ref: '#/components/schemas/UserResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } /api/users/{id}: parameters: - $ref: '#/components/parameters/id' get: tags: [Users] summary: Szczegóły użytkownika (admin) responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/UserResponse' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } patch: tags: [Users] summary: Częściowa edycja użytkownika (admin) description: Admin nie może zmienić własnej roli ani dezaktywować własnego konta. requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/UserInput' } responses: '200': description: Zaktualizowano content: application/json: schema: { $ref: '#/components/schemas/UserResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } put: tags: [Users] summary: Edycja użytkownika (admin) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/UserInput' } responses: '200': description: Zaktualizowano content: application/json: schema: { $ref: '#/components/schemas/UserResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } delete: tags: [Users] summary: Usuń użytkownika (admin) description: Nie można usunąć własnego konta. responses: '204': { description: Usunięto } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT parameters: id: name: id in: path required: true schema: { type: integer, example: 1 } page: name: page in: query schema: { type: integer, minimum: 1, default: 1 } limit: name: limit in: query schema: { type: integer, minimum: 1, maximum: 100, default: 20 } direction: name: direction in: query schema: { type: string, enum: [asc, desc], default: asc } carSort: name: sort in: query schema: type: string enum: [id, customer_id, make, model, production_year, registration_number, fuel_type, mileage, power_hp, created, modified] responses: Unauthorized: description: Brak lub nieprawidłowy token / błędne dane logowania content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { success: false, error: { code: 401, message: Authentication is required to continue } } Forbidden: description: Brak uprawnień (wymagana rola admin) content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { success: false, error: { code: 403, message: This action requires the admin role. } } NotFound: description: Nie znaleziono rekordu content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { success: false, error: { code: 404, message: Record not found in table `customers`. } } BadRequest: description: Niedozwolona operacja content: application/json: schema: { $ref: '#/components/schemas/Error' } ValidationError: description: Błędy walidacji (klucz = pole, wartość = reguła => komunikat) content: application/json: schema: { $ref: '#/components/schemas/Error' } example: success: false error: code: 422 message: Validation failed. fields: email: { _isUnique: This email is already in use. } phone: { _required: This field is required } CarList: description: OK content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: array items: { $ref: '#/components/schemas/Car' } meta: { $ref: '#/components/schemas/PaginationMeta' } schemas: Error: type: object properties: success: { type: boolean, example: false } error: type: object properties: code: { type: integer } message: { type: string } fields: type: object additionalProperties: type: object additionalProperties: { type: string } PaginationMeta: type: object properties: page: { type: integer, example: 1 } limit: { type: integer, example: 20 } total: { type: integer, example: 100 } pages: { type: integer, example: 5 } has_next_page: { type: boolean, example: true } has_prev_page: { type: boolean, example: false } sort: { type: string, nullable: true, example: id } direction: { type: string, nullable: true, example: asc } Role: type: string enum: [admin, user] FuelType: type: string enum: [petrol, diesel, lpg, hybrid, electric] # ---------- Users ---------- User: type: object properties: id: { type: integer, readOnly: true, example: 1 } email: { type: string, format: email, example: admin@example.com } first_name: { type: string, example: Adam } last_name: { type: string, example: Administrator } role: { $ref: '#/components/schemas/Role' } active: { type: boolean, example: true } last_login: { type: string, format: date-time, nullable: true, readOnly: true } created: { type: string, format: date-time, readOnly: true } modified: { type: string, format: date-time, readOnly: true } UserInput: type: object properties: email: { type: string, format: email, maxLength: 255 } password: { type: string, format: password, minLength: 8, maxLength: 72 } first_name: { type: string, maxLength: 100 } last_name: { type: string, maxLength: 100 } role: { $ref: '#/components/schemas/Role' } active: { type: boolean } UserCreate: allOf: - $ref: '#/components/schemas/UserInput' - type: object required: [email, password, first_name, last_name] example: email: jan.kowalski@example.com password: Haslo123! first_name: Jan last_name: Kowalski role: user active: true ProfileInput: type: object properties: email: { type: string, format: email } password: { type: string, format: password, minLength: 8 } first_name: { type: string } last_name: { type: string } example: first_name: Jan password: NoweHaslo123! UserResponse: type: object properties: success: { type: boolean, example: true } data: { $ref: '#/components/schemas/User' } # ---------- Customers ---------- CustomerInput: type: object properties: type: type: string enum: [individual, company] default: individual description: Dla `company` wymagane jest `company_name` first_name: { type: string, maxLength: 100, example: Anna } last_name: { type: string, maxLength: 100, example: Nowak } email: { type: string, format: email, maxLength: 255, description: Unikalny, example: anna.nowak@example.com } phone: { type: string, pattern: '^\+?[0-9 ()-]{6,30}$', example: '+48 600 100 200' } company_name: { type: string, nullable: true, maxLength: 255, example: null } tax_id: { type: string, nullable: true, pattern: '^[0-9]{10}$', description: NIP (10 cyfr), example: null } street: { type: string, nullable: true, maxLength: 255, example: ul. Długa 5/12 } city: { type: string, nullable: true, maxLength: 100, example: Kraków } postal_code: { type: string, nullable: true, pattern: '^[0-9]{2}-[0-9]{3}$', example: 30-001 } country: { type: string, pattern: '^[A-Z]{2}$', default: PL, description: ISO 3166-1 alpha-2, example: PL } birth_date: { type: string, format: date, nullable: true, example: '1985-04-12' } notes: { type: string, nullable: true, maxLength: 5000, example: Preferuje kontakt telefoniczny. } CustomerCreate: allOf: - $ref: '#/components/schemas/CustomerInput' - type: object required: [first_name, last_name, email, phone] Customer: allOf: - type: object properties: id: { type: integer, readOnly: true, example: 1 } - $ref: '#/components/schemas/CustomerInput' - type: object properties: created_by: { type: integer, nullable: true, readOnly: true, description: ID użytkownika, który dodał klienta } created: { type: string, format: date-time, readOnly: true } modified: { type: string, format: date-time, readOnly: true } CustomerWithCars: allOf: - $ref: '#/components/schemas/Customer' - type: object properties: cars: type: array items: { $ref: '#/components/schemas/Car' } CustomerResponse: type: object properties: success: { type: boolean, example: true } data: { $ref: '#/components/schemas/Customer' } # ---------- Cars ---------- CarFields: type: object properties: make: { type: string, maxLength: 50, example: Toyota } model: { type: string, maxLength: 50, example: Corolla } production_year: { type: integer, minimum: 1900, example: 2020 } vin: type: string pattern: '^[A-HJ-NPR-Z0-9]{17}$' description: 17 znaków, bez liter I, O, Q. Unikalny. Zapisywany wielkimi literami. example: JTDBR32E720123456 registration_number: type: string description: 4-10 liter/cyfr. Unikalny. Spacje są usuwane, litery zamieniane na wielkie. example: KR12345 fuel_type: { $ref: '#/components/schemas/FuelType' } color: { type: string, nullable: true, maxLength: 30, example: srebrny } engine_capacity: { type: integer, nullable: true, minimum: 0, maximum: 10000, description: Pojemność w cm³, example: 1798 } power_hp: { type: integer, nullable: true, minimum: 1, maximum: 2000, description: Moc w KM, example: 122 } transmission: { type: string, nullable: true, enum: [manual, automatic], example: automatic } mileage: { type: integer, nullable: true, minimum: 0, description: Przebieg w km, example: 45000 } notes: { type: string, nullable: true, maxLength: 5000, example: null } CarInput: allOf: - type: object properties: customer_id: { type: integer, example: 1 } - $ref: '#/components/schemas/CarFields' CarCreate: allOf: - $ref: '#/components/schemas/CarInput' - type: object required: [customer_id, make, model, production_year, vin, registration_number, fuel_type] CarCreateNested: allOf: - $ref: '#/components/schemas/CarFields' - type: object required: [make, model, production_year, vin, registration_number, fuel_type] Car: allOf: - type: object properties: id: { type: integer, readOnly: true, example: 1 } - $ref: '#/components/schemas/CarInput' - type: object properties: created: { type: string, format: date-time, readOnly: true } modified: { type: string, format: date-time, readOnly: true } CarWithCustomer: allOf: - $ref: '#/components/schemas/Car' - type: object properties: customer: { $ref: '#/components/schemas/Customer' } CarResponse: type: object properties: success: { type: boolean, example: true } data: { $ref: '#/components/schemas/Car' }