(feature): account patch operations init and webapi cleanup

This commit is contained in:
Maksym Sadovnychyy 2024-07-06 00:07:51 +02:00
parent 3e0c25158e
commit 8aa535447e
35 changed files with 377 additions and 597 deletions

View File

@ -1,7 +1,7 @@
'use client'
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { httpService } from '@/services/httpService'
import { httpService } from '@/services/HttpService'
import { FormEvent, useEffect, useRef, useState } from 'react'
import {
CustomButton,
@ -10,7 +10,10 @@ import {
CustomInput,
CustomRadioGroup
} from '@/controls'
import { GetAccountResponse } from '@/models/letsEncryptServer/account/responses/GetAccountResponse'
import {
GetAccountResponse,
toCacheAccount
} from '@/models/letsEncryptServer/account/responses/GetAccountResponse'
import { deepCopy, enumToArray } from '../functions'
import { CacheAccount } from '@/entities/CacheAccount'
import { ChallengeTypes } from '@/entities/ChallengeTypes'
@ -42,22 +45,7 @@ export default function Page() {
if (!gatAccountsResult.isSuccess) return
gatAccountsResult.data?.forEach((account) => {
newAccounts.push({
accountId: account.accountId,
isDisabled: account.isDisabled,
description: account.description,
contacts: account.contacts.map((contact) => contact),
challengeType: account.challengeType,
hostnames:
account.hostnames?.map((hostname) => ({
hostname: hostname.hostname,
expires: new Date(hostname.expires),
isUpcomingExpire: hostname.isUpcomingExpire,
isDisabled: hostname.isDisabled
})) ?? [],
isStaging: account.isStaging,
isEditMode: false
})
newAccounts.push(toCacheAccount(account))
})
setAccounts(newAccounts)
@ -144,10 +132,6 @@ export default function Page() {
title="Challenge Type"
enumType={ChallengeTypes}
selectedValue={account.challengeType}
onChange={(option) =>
//handleChallengeTypeChange(account.accountId, option)
console.log('')
}
disabled={true}
/>
</div>
@ -206,9 +190,12 @@ export default function Page() {
{editingAccount && (
<AccountEdit
account={editingAccount}
setAccount={setEditingAccount}
onCancel={() => setEditingAccount(null)}
onDelete={deleteAccount}
onSubmit={(account) => {
setEditingAccount(null)
handleAccountUpdate(account)
}}
/>
)}
</OffCanvas>

View File

@ -18,12 +18,12 @@ import { deepCopy } from '../../functions'
import {
PostAccountRequest,
validatePostAccountRequest
} from '@/models/letsEncryptServer/certsFlow/PostAccountRequest'
} from '@/models/letsEncryptServer/account/requests/PostAccountRequest'
import { useAppDispatch } from '@/redux/store'
import { showToast } from '@/redux/slices/toastSlice'
import { ChallengeTypes } from '@/entities/ChallengeTypes'
import { GetAccountResponse } from '@/models/letsEncryptServer/account/responses/GetAccountResponse'
import { httpService } from '@/services/httpService'
import { httpService } from '@/services/HttpService'
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { PageContainer } from '@/components/pageContainer'

View File

@ -71,27 +71,41 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
return (
<div className={`flex flex-col ${className}`} ref={selectBoxRef}>
{title && <label className="mb-1">{title}</label>}
{title && <label className="mb-1 text-gray-700">{title}</label>}
<div
className={`relative w-64 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
className={`relative w-64 ${disabled ? 'opacity-50 cursor-not-allowed' : readOnly ? 'cursor-not-allowed' : ''}`}
>
<div
className={`p-2 border ${disabled ? 'border-gray-200' : 'border-gray-300'} rounded cursor-pointer flex justify-between items-center ${disabled ? 'cursor-not-allowed' : ''} ${selectBoxClassName}`}
className={`p-2 border rounded flex justify-between items-center ${
disabled
? 'border-gray-200 bg-gray-100 text-gray-500'
: readOnly
? 'border-gray-300 bg-white cursor-not-allowed'
: 'border-gray-300 bg-white cursor-pointer hover:border-gray-400'
} ${selectBoxClassName}`}
onClick={handleToggle}
>
{selectedOption ? selectedOption.label : 'Select an option'}
<span className={`${disabled ? 'text-gray-500' : ''}`}>
{selectedOption ? selectedOption.label : 'Select an option'}
</span>
{isOpen ? (
<FaChevronUp className="ml-2" />
<FaChevronUp
className={`ml-2 ${disabled ? 'text-gray-500' : ''}`}
/>
) : (
<FaChevronDown className="ml-2" />
<FaChevronDown
className={`ml-2 ${disabled ? 'text-gray-500' : ''}`}
/>
)}
</div>
{isOpen && (
<ul className="absolute z-10 w-full mt-1 overflow-y-auto bg-white border border-gray-300 max-h-60">
<ul className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded shadow-lg max-h-60 overflow-y-auto">
{options.map((option) => (
<li
key={option.value}
className={`p-2 hover:bg-gray-200 ${readOnly || disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
className={'p-2'}
onClick={() => handleOptionClick(option)}
>
{option.label}
@ -100,6 +114,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
</ul>
)}
</div>
{error && (
<p className={`text-red-500 mt-1 ${errorClassName}`}>{error}</p>
)}

View File

@ -1,4 +1,6 @@
import { PatchOperation } from '@/models/PatchOperation'
import { CacheAccountHostname } from './CacheAccountHostname'
import { PatchAccountRequest } from '@/models/letsEncryptServer/account/requests/PatchAccountRequest'
export interface CacheAccount {
accountId: string
@ -10,3 +12,29 @@ export interface CacheAccount {
isEditMode: boolean
isStaging: boolean
}
const toPatchAccountRequest = (account: CacheAccount): PatchAccountRequest => {
return {
description: { op: PatchOperation.None, value: account.description },
isDisabled: { op: PatchOperation.None, value: account.isDisabled },
contacts: account.contacts.map((contact, index) => ({
index: index,
op: PatchOperation.None,
value: contact
})),
hostnames: account.hostnames?.map((hostname, index) => ({
hostname: {
index: index,
op: PatchOperation.None,
value: hostname.hostname
},
isDisabled: {
index: index,
op: PatchOperation.None,
value: hostname.isDisabled
}
}))
}
}
export { toPatchAccountRequest }

View File

@ -1,9 +1,5 @@
import { PatchAction } from '@/models/PatchAction'
export interface PatchHostnameRequest {
hostname?: PatchAction<string>
isDisabled?: PatchAction<boolean>
}
import { PatchHostnameRequest } from './PatchHostnameRequest'
export interface PatchAccountRequest {
description?: PatchAction<string>

View File

@ -1,5 +0,0 @@
import { PatchAction } from '@/models/PatchAction'
export interface PatchContactsRequest {
contacts: PatchAction<string>[]
}

View File

@ -0,0 +1,6 @@
import { PatchAction } from '@/models/PatchAction'
export interface PatchHostnameRequest {
hostname?: PatchAction<string>
isDisabled?: PatchAction<boolean>
}

View File

@ -1,3 +0,0 @@
export interface PostContactsRequest {
contacts: string[]
}

View File

@ -1,4 +0,0 @@
export interface PutAccountRequest {
description: string
contacts: string[]
}

View File

@ -1,3 +0,0 @@
export interface PutContactsRequest {
contacts: string[]
}

View File

@ -1,4 +1,5 @@
import { HostnameResponse } from './HostnameResponse'
import { CacheAccount } from '@/entities/CacheAccount'
import { GetHostnameResponse } from './GetHostnameResponse'
export interface GetAccountResponse {
accountId: string
@ -6,6 +7,27 @@ export interface GetAccountResponse {
description: string
contacts: string[]
challengeType?: string
hostnames?: HostnameResponse[]
hostnames?: GetHostnameResponse[]
isStaging: boolean
}
const toCacheAccount = (account: GetAccountResponse): CacheAccount => {
return {
accountId: account.accountId,
isDisabled: account.isDisabled,
description: account.description,
contacts: account.contacts.map((contact) => contact),
challengeType: account.challengeType,
hostnames:
account.hostnames?.map((hostname) => ({
hostname: hostname.hostname,
expires: new Date(hostname.expires),
isUpcomingExpire: hostname.isUpcomingExpire,
isDisabled: hostname.isDisabled
})) ?? [],
isStaging: account.isStaging,
isEditMode: false
}
}
export { toCacheAccount }

View File

@ -1,3 +0,0 @@
export interface GetContactsResponse {
contacts: string[]
}

View File

@ -1,4 +1,4 @@
export interface HostnameResponse {
export interface GetHostnameResponse {
hostname: string
expires: string
isUpcomingExpire: boolean

View File

@ -1,5 +0,0 @@
import { HostnameResponse } from './HostnameResponse'
export interface GetHostnamesResponse {
hostnames: HostnameResponse[]
}

View File

@ -14,54 +14,40 @@ import {
CustomInput,
CustomRadioGroup
} from '@/controls'
import { CacheAccount } from '@/entities/CacheAccount'
import { CacheAccount, toPatchAccountRequest } from '@/entities/CacheAccount'
import { FaPlus, FaTrash } from 'react-icons/fa'
import { ChallengeTypes } from '@/entities/ChallengeTypes'
import { deepCopy } from '@/functions'
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { httpService } from '@/services/httpService'
import { httpService } from '@/services/HttpService'
import { PatchAccountRequest } from '@/models/letsEncryptServer/account/requests/PatchAccountRequest'
import { PatchOperation } from '@/models/PatchOperation'
import { useAppDispatch } from '@/redux/store'
import { showToast } from '@/redux/slices/toastSlice'
import {
GetAccountResponse,
toCacheAccount
} from '@/models/letsEncryptServer/account/responses/GetAccountResponse'
interface AccountEditProps {
account: CacheAccount
setAccount: Dispatch<SetStateAction<CacheAccount | null>>
onCancel?: () => void
onSubmit?: (account: CacheAccount) => void
onDelete: (accountId: string) => void
onSubmit: (account: CacheAccount) => void
}
const AccountEdit: React.FC<AccountEditProps> = ({
account,
setAccount,
onCancel,
onSubmit,
onDelete
}) => {
const AccountEdit: React.FC<AccountEditProps> = (props) => {
const { account, onCancel, onDelete, onSubmit } = props
const dispatch = useAppDispatch()
const [newAccount, setNewAccount] = useState<PatchAccountRequest>({
description: { op: PatchOperation.None, value: account.description },
isDisabled: { op: PatchOperation.None, value: account.isDisabled },
contacts: account.contacts.map((contact) => ({
op: PatchOperation.None,
value: contact
})),
hostnames: account.hostnames?.map((hostname) => ({
hostname: { op: PatchOperation.None, value: hostname.hostname },
isDisabled: { op: PatchOperation.None, value: hostname.isDisabled }
}))
})
const [newAccount, setNewAccount] = useState<PatchAccountRequest>(
toPatchAccountRequest(account)
)
const [newContact, setNewContact] = useState('')
const [newHostname, setNewHostname] = useState('')
useEffect(() => {
console.log(newAccount)
}, [newAccount])
const {
value: description,
error: descriptionError,
@ -163,6 +149,9 @@ const AccountEdit: React.FC<AccountEditProps> = ({
return c
})
.filter((c) => !(c.value === contact && c.op === PatchOperation.Add))
console.log(newAccount.contacts)
return newAccount
})
}
@ -246,10 +235,23 @@ const AccountEdit: React.FC<AccountEditProps> = ({
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
httpService.patch<PatchAccountRequest, CacheAccount>(
GetApiRoute(ApiRoutes.ACCOUNT_ID, account.accountId),
newAccount
)
if (!newAccount) return
httpService
.patch<
PatchAccountRequest,
GetAccountResponse
>(GetApiRoute(ApiRoutes.ACCOUNT_ID, account.accountId), newAccount)
.then((response) => {
if (response.isSuccess && response.data) {
onSubmit?.(toCacheAccount(response.data))
} else {
// Optionally, handle the error case, e.g., show an error message
dispatch(
showToast({ message: 'Failed to update account.', type: 'error' })
)
}
})
}
const handleDelete = (accountId: string) => {
@ -268,7 +270,7 @@ const AccountEdit: React.FC<AccountEditProps> = ({
title="Description"
inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow"
className="mr-2 w-full"
/>
</div>
@ -277,27 +279,29 @@ const AccountEdit: React.FC<AccountEditProps> = ({
checked={newAccount.isDisabled?.value ?? false}
label="Disabled"
onChange={handleIsDisabledChange}
className="mr-2 flex-grow"
className="mr-2"
/>
</div>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
<ul className="list-disc list-inside pl-4 mb-2">
{newAccount.contacts?.map((contact) => (
<li key={contact.value} className="text-gray-700 mb-2">
<div className="inline-flex">
{contact.value}
<CustomButton
type="button"
onClick={() => handleDeleteContact(contact.value ?? '')}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<FaTrash />
</CustomButton>
</div>
</li>
))}
{newAccount.contacts
?.filter((contact) => contact.op !== PatchOperation.Remove)
.map((contact) => (
<li key={contact.value} className="text-gray-700 mb-2">
<div className="inline-flex">
{contact.value}
<CustomButton
type="button"
onClick={() => handleDeleteContact(contact.value ?? '')}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<FaTrash />
</CustomButton>
</div>
</li>
))}
</ul>
<div className="flex items-center mb-4">
<CustomInput
@ -309,7 +313,7 @@ const AccountEdit: React.FC<AccountEditProps> = ({
title="New Contact"
inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow"
className="mr-2 w-full"
/>
<CustomButton
type="button"
@ -326,7 +330,7 @@ const AccountEdit: React.FC<AccountEditProps> = ({
title="Challenge Type"
enumType={ChallengeTypes}
selectedValue={account.challengeType}
className="mr-2 flex-grow"
className="mr-2 w-full"
disabled={true}
/>
</div>
@ -334,34 +338,38 @@ const AccountEdit: React.FC<AccountEditProps> = ({
<div>
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
<ul className="list-disc list-inside pl-4 mb-2">
{newAccount.hostnames?.map((hostname) => (
<li key={hostname.hostname?.value} className="text-gray-700 mb-2">
<div className="inline-flex">
{hostname.hostname?.value} -{' '}
<CustomCheckbox
className="ml-2"
checked={hostname.isDisabled?.value ?? false}
label="Disabled"
onChange={(value) =>
handleHostnameDisabledChange(
hostname.hostname?.value ?? '',
value
)
}
/>
</div>
{newAccount.hostnames
?.filter(
(hostname) => hostname.hostname?.op !== PatchOperation.Remove
)
.map((hostname) => (
<li key={hostname.hostname?.value} className="text-gray-700 mb-2">
<div className="inline-flex items-center">
{hostname.hostname?.value} -{' '}
<CustomCheckbox
className="ml-2"
checked={hostname.isDisabled?.value ?? false}
label="Disabled"
onChange={(value) =>
handleHostnameDisabledChange(
hostname.hostname?.value ?? '',
value
)
}
/>
</div>
<CustomButton
type="button"
onClick={() =>
handleDeleteHostname(hostname.hostname?.value ?? '')
}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<FaTrash />
</CustomButton>
</li>
))}
<CustomButton
type="button"
onClick={() =>
handleDeleteHostname(hostname.hostname?.value ?? '')
}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<FaTrash />
</CustomButton>
</li>
))}
</ul>
<div className="flex items-center">
<CustomInput
@ -373,7 +381,7 @@ const AccountEdit: React.FC<AccountEditProps> = ({
title="New Hostname"
inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow"
className="mr-2 w-full"
/>
<CustomButton
type="button"
@ -392,7 +400,7 @@ const AccountEdit: React.FC<AccountEditProps> = ({
]}
initialValue={account.isStaging ? 'staging' : 'production'}
title="LetsEncrypt Environment"
className="mr-2 flex-grow"
className="mr-2 w-full"
radioClassName=""
errorClassName="text-red-500 text-sm mt-1"
disabled={true}
@ -402,20 +410,20 @@ const AccountEdit: React.FC<AccountEditProps> = ({
<CustomButton
type="button"
onClick={() => handleDelete(account.accountId)}
className="bg-red-500 text-white p-2 rounded ml-2"
className="bg-red-500 text-white p-2 rounded"
>
<FaTrash />
</CustomButton>
<CustomButton
type="button"
onClick={handleCancel}
className="bg-yellow-500 text-white p-2 rounded ml-2"
className="bg-yellow-500 text-white p-2 rounded"
>
Cancel
</CustomButton>
<CustomButton
type="submit"
className="bg-green-500 text-white p-2 rounded ml-2"
className="bg-green-500 text-white p-2 rounded"
>
Save
</CustomButton>

View File

@ -73,6 +73,29 @@ class HttpService {
})
}
private cleanObject = (obj: any): any => {
if (Array.isArray(obj)) {
const cleanedArray = obj
.map(this.cleanObject)
.filter((item) => item !== null && item !== undefined)
return cleanedArray.length > 0 ? cleanedArray : null
} else if (typeof obj === 'object' && obj !== null) {
if (obj.op !== undefined && obj.op === PatchOperation.None) {
return null
}
const cleanedObject: any = {}
Object.keys(obj).forEach((key) => {
const cleanedValue = this.cleanObject(obj[key])
if (cleanedValue !== null) {
cleanedObject[key] = cleanedValue
}
})
return Object.keys(cleanedObject).length > 0 ? cleanedObject : null
}
return obj
}
private handleRequestInterceptors(xhr: XMLHttpRequest): void {
this.requestInterceptors.forEach((interceptor) => {
try {
@ -223,34 +246,12 @@ class HttpService {
return await this.request<TResponse>('PUT', url, data)
}
private cleanPatchRequest(obj: any): any {
if (Array.isArray(obj)) {
const cleanedArray = obj
.map(this.cleanPatchRequest)
.filter((item) => item !== null && item !== undefined)
return cleanedArray.length > 0 ? cleanedArray : null
} else if (typeof obj === 'object' && obj !== null) {
if (obj.op !== undefined && obj.op === PatchOperation.None) {
return null
}
const cleanedObject: any = {}
Object.keys(obj).forEach((key) => {
const cleanedValue = this.cleanPatchRequest(obj[key])
if (cleanedValue !== null) {
cleanedObject[key] = cleanedValue
}
})
return Object.keys(cleanedObject).length > 0 ? cleanedObject : null
}
return obj
}
public async patch<TRequest, TResponse>(
url: string,
data: TRequest
): Promise<HttpResponse<TResponse>> {
const cleanedData = this.cleanPatchRequest(data)
// Clean the data before sending the patch request
const cleanedData = this.cleanObject(data)
return await this.request<TResponse>('PATCH', url, cleanedData)
}

View File

@ -15,11 +15,10 @@ public class RegistrationCache {
/// </summary>
public required Guid AccountId { get; set; }
public bool IsDisabled { get; set; }
public string? Description { get; set; }
public required string Description { get; set; }
public required string[] Contacts { get; set; }
public string? ChallengeType { get; set; }
public required bool IsStaging { get; set; }
public string? ChallengeType { get; set; }
#endregion

View File

@ -36,12 +36,6 @@ public class AccountController : ControllerBase {
return result.ToActionResult();
}
[HttpPut("account/{accountId:guid}")]
public async Task<IActionResult> PutAccount(Guid accountId, [FromBody] PutAccountRequest requestData) {
var result = await _accountService.PutAccountAsync(accountId, requestData);
return result.ToActionResult();
}
[HttpPatch("account/{accountId:guid}")]
public async Task<IActionResult> PatchAccount(Guid accountId, [FromBody] PatchAccountRequest requestData) {
var result = await _accountService.PatchAccountAsync(accountId, requestData);
@ -55,82 +49,4 @@ public class AccountController : ControllerBase {
}
#endregion
#region Account Contacts
[HttpGet("account/{accountId:guid}/contacts")]
public async Task<IActionResult> GetContacts(Guid accountId) {
var result = await _accountService.GetContactsAsync(accountId);
return result.ToActionResult();
}
[HttpPost("account/{accountId:guid}/contacts")]
public async Task<IActionResult> PostContacts(Guid accountId, [FromBody] PostContactsRequest requestData) {
//var result = await _accountService.PostContactsAsync(accountId, requestData);
//return result.ToActionResult();
return BadRequest("Not implemented");
}
[HttpPut("account/{accountId:guid}/contacts")]
public async Task<IActionResult> PutContacts(Guid accountId, [FromBody] PutContactsRequest requestData) {
var result = await _accountService.PutContactsAsync(accountId, requestData);
return result.ToActionResult();
}
[HttpPatch("account/{accountId:guid}/contacts")]
public async Task<IActionResult> PatchContacts(Guid accountId, [FromBody] PatchContactsRequest requestData) {
var result = await _accountService.PatchContactsAsync(accountId, requestData);
return result.ToActionResult();
}
[HttpDelete("account/{accountId:guid}/contact/{index:int}")]
public async Task<IActionResult> DeleteContact(Guid accountId, int index) {
var result = await _accountService.DeleteContactAsync(accountId, index);
return result.ToActionResult();
}
#endregion
#region Account Hostnames
[HttpGet("{accountId:guid}/hostnames")]
public async Task<IActionResult> GetHostnames(Guid accountId) {
var result = await _accountService.GetHostnames(accountId);
return result.ToActionResult();
}
[HttpPost("account/{accountId:guid}/hostnames")]
public async Task<IActionResult> PostHostname(Guid accountId, [FromBody] PostHostnamesRequest requestData) {
//var result = await _cacheService.PostHostnameAsync(accountId, requestData);
//return result.ToActionResult();
return BadRequest("Not implemented");
}
[HttpPut("account/{accountId:guid}/hostnames")]
public async Task<IActionResult> PutHostname(Guid accountId, [FromBody] PutHostnamesRequest requestData) {
//var result = await _cacheService.PutHostnameAsync(accountId, requestData);
//return result.ToActionResult();
return BadRequest("Not implemented");
}
[HttpPatch("account/{accountId:guid}/hostnames")]
public async Task<IActionResult> PatchHostname(Guid accountId, [FromBody] PatchHostnamesRequest requestData) {
//var result = await _cacheService.PatchHostnameAsync(accountId, requestData);
//return result.ToActionResult();
return BadRequest("Not implemented");
}
[HttpDelete("account/{accountId:guid}/hostname/{index:int}")]
public async Task<IActionResult> DeleteHostname(Guid accountId, int index) {
//var result = await _cacheService.DeleteHostnameAsync(accountId, index);
//return result.ToActionResult();
return BadRequest("Not implemented");
}
#endregion
}

View File

@ -17,15 +17,8 @@ public interface IAccountRestService {
Task<(GetAccountResponse[]?, IDomainResult)> GetAccountsAsync();
Task<(GetAccountResponse?, IDomainResult)> GetAccountAsync(Guid accountId);
Task<(GetAccountResponse?, IDomainResult)> PostAccountAsync(PostAccountRequest requestData);
Task<(GetAccountResponse?, IDomainResult)> PutAccountAsync(Guid accountId, PutAccountRequest requestData);
Task<(GetAccountResponse?, IDomainResult)> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData);
Task<IDomainResult> DeleteAccountAsync(Guid accountId);
Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId);
Task<(GetContactsResponse?, IDomainResult)> PostContactsAsync(Guid accountId, PostContactsRequest requestData);
Task<(GetAccountResponse?, IDomainResult)> PutContactsAsync(Guid accountId, PutContactsRequest requestData);
Task<(GetAccountResponse?, IDomainResult)> PatchContactsAsync(Guid accountId, PatchContactsRequest requestData);
Task<IDomainResult> DeleteContactAsync(Guid accountId, int index);
Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId);
}
public interface IAccountService : IAccountInternalService, IAccountRestService { }
@ -75,67 +68,26 @@ public class AccountService : IAccountService {
// TODO: check for overlapping hostnames in already existing accounts
var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging);
if (!configureClientResult.IsSuccess || sessionId == null) {
//LogErrors(configureClientResult.Errors);
return (null, configureClientResult);
}
var sessionIdValue = sessionId.Value;
var (accountId, newCertsResult) = await _certsFlowService.FullFlow(
requestData.IsStaging,
null,
requestData.Description,
requestData.Contacts,
requestData.ChallengeType,
requestData.Hostnames
);
var (_, initResult) = await _certsFlowService.InitAsync(sessionIdValue, null, requestData.Description, requestData.Contacts);
if (!initResult.IsSuccess) {
//LogErrors(initResult.Errors);
return (null, initResult);
}
if (!newCertsResult.IsSuccess || accountId == null)
return (null, newCertsResult);
var (_, newOrderResult) = await _certsFlowService.NewOrderAsync(sessionIdValue, requestData.Hostnames, requestData.ChallengeType);
if (!newOrderResult.IsSuccess) {
//LogErrors(newOrderResult.Errors);
return (null, newOrderResult);
}
var challengeResult = await _certsFlowService.CompleteChallengesAsync(sessionIdValue);
if (!challengeResult.IsSuccess) {
//LogErrors(challengeResult.Errors);
return (null, challengeResult);
}
var getOrderResult = await _certsFlowService.GetOrderAsync(sessionIdValue, requestData.Hostnames);
if (!getOrderResult.IsSuccess) {
//LogErrors(getOrderResult.Errors);
return (null, getOrderResult);
}
var certs = await _certsFlowService.GetCertificatesAsync(sessionIdValue, requestData.Hostnames);
if (!certs.IsSuccess) {
//LogErrors(certs.Errors);
return (null, certs);
}
var (_, applyCertsResult) = await _certsFlowService.ApplyCertificatesAsync(sessionIdValue, requestData.Hostnames);
if (!applyCertsResult.IsSuccess) {
//LogErrors(applyCertsResult.Errors);
return (null, applyCertsResult);
}
return IDomainResult.Success<GetAccountResponse?>(null);
}
public async Task<(GetAccountResponse?, IDomainResult)> PutAccountAsync(Guid accountId, PutAccountRequest requestData) {
var (cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId);
var (cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId.Value);
if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult);
}
cache.Description = requestData.Description;
cache.Contacts = requestData.Contacts;
var saveResult = await _cacheService.SaveToCacheAsync(accountId, cache);
if (!saveResult.IsSuccess) {
return (null, saveResult);
}
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
return IDomainResult.Success(CreateGetAccountResponse(accountId.Value, cache));
}
public async Task<(GetAccountResponse?, IDomainResult)> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData) {
@ -152,7 +104,15 @@ public class AccountService : IAccountService {
}
}
if (requestData.Contacts != null && requestData.Contacts.Any()) {
if (requestData.IsDisabled != null) {
switch (requestData.IsDisabled.Op) {
case PatchOperation.Replace:
cache.IsDisabled = requestData.IsDisabled.Value;
break;
}
}
if (requestData.Contacts?.Any() == true) {
var contacts = cache.Contacts?.ToList() ?? new List<string>();
foreach (var action in requestData.Contacts) {
switch (action.Op)
@ -173,11 +133,81 @@ public class AccountService : IAccountService {
cache.Contacts = contacts.ToArray();
}
var hostnamesToAdd = new List<string>();
var hostnamesToRemove = new List<string>();
if (requestData.Hostnames?.Any() == true) {
var hostnames = cache.GetHosts().ToList();
foreach (var action in requestData.Hostnames) {
if (action.Hostname != null) {
switch (action.Hostname.Op) {
case PatchOperation.Add:
hostnamesToAdd.Add(action.Hostname.Value);
break;
case PatchOperation.Replace:
if (action.Hostname.Index != null && action.Hostname.Index >= 0 && action.Hostname.Index < hostnames.Count)
hostnames[action.Hostname.Index.Value].Hostname = action.Hostname.Value;
break;
case PatchOperation.Remove:
hostnamesToRemove.Add(action.Hostname.Value);
break;
}
}
if (action.IsDisabled != null) {
switch (action.IsDisabled.Op) {
case PatchOperation.Replace:
break;
}
}
}
}
var saveResult = await _cacheService.SaveToCacheAsync(accountId, cache);
if (!saveResult.IsSuccess) {
return (null, saveResult);
}
if (hostnamesToAdd.Count > 0) {
var (_, newCertsResult) = await _certsFlowService.FullFlow(
cache.IsStaging,
cache.AccountId,
cache.Description,
cache.Contacts,
cache.ChallengeType,
hostnamesToAdd.ToArray()
);
if (!newCertsResult.IsSuccess)
return (null, newCertsResult);
}
if (hostnamesToRemove.Count > 0) {
hostnamesToRemove.ForEach(hostname => {
cache.CachedCerts?.Remove(hostname);
});
}
(cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult);
}
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
}
@ -189,117 +219,10 @@ public class AccountService : IAccountService {
}
#endregion
#region Contacts Operations
#region Helper Methods
public async Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId) {
var (cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult);
}
return IDomainResult.Success(new GetContactsResponse {
Contacts = cache.Contacts ?? Array.Empty<string>()
});
}
public async Task<(GetContactsResponse?, IDomainResult)> PostContactsAsync(Guid accountId, PostContactsRequest requestData) {
return IDomainResult.Failed<GetContactsResponse?>("Not implemented");
}
public async Task<(GetAccountResponse?, IDomainResult)> PutContactsAsync(Guid accountId, PutContactsRequest requestData) {
var (cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult);
}
cache.Contacts = requestData.Contacts;
var saveResult = await _cacheService.SaveToCacheAsync(accountId, cache);
if (!saveResult.IsSuccess) {
return (null, saveResult);
}
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
}
public async Task<(GetAccountResponse?, IDomainResult)> PatchContactsAsync(Guid accountId, PatchContactsRequest requestData) {
var (cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult);
}
var contacts = cache.Contacts?.ToList() ?? new List<string>();
foreach (var contact in requestData.Contacts) {
switch (contact.Op) {
case PatchOperation.Add:
if (contact.Value != null) {
contacts.Add(contact.Value);
}
break;
case PatchOperation.Replace:
if (contact.Index.HasValue && contact.Index.Value >= 0 && contact.Index.Value < contacts.Count && contact.Value != null) {
contacts[contact.Index.Value] = contact.Value;
}
break;
case PatchOperation.Remove:
if (contact.Index.HasValue && contact.Index.Value >= 0 && contact.Index.Value < contacts.Count) {
contacts.RemoveAt(contact.Index.Value);
}
break;
default:
return (null, IDomainResult.Failed("Invalid patch operation."));
}
}
cache.Contacts = contacts.ToArray();
var saveResult = await _cacheService.SaveToCacheAsync(accountId, cache);
if (!saveResult.IsSuccess) {
return (null, saveResult);
}
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
}
public async Task<IDomainResult> DeleteContactAsync(Guid accountId, int index) {
var (cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return loadResult;
}
var contacts = cache.Contacts?.ToList() ?? new List<string>();
if (index >= 0 && index < contacts.Count) {
contacts.RemoveAt(index);
}
cache.Contacts = contacts.ToArray();
var saveResult = await _cacheService.SaveToCacheAsync(accountId, cache);
if (!saveResult.IsSuccess) {
return saveResult;
}
return IDomainResult.Success();
}
#endregion
#region Hostnames Operations
public async Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId) {
var (cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache?.CachedCerts == null) {
return (null, loadResult);
}
var hostnames = GetHostnamesFromCache(cache);
return IDomainResult.Success(new GetHostnamesResponse {
Hostnames = hostnames
});
}
private List<HostnameResponse> GetHostnamesFromCache(RegistrationCache cache) {
var hosts = cache.GetHosts().Select(x => new HostnameResponse {
private List<GetHostnameResponse> GetHostnamesFromCache(RegistrationCache cache) {
var hosts = cache.GetHosts().Select(x => new GetHostnameResponse {
Hostname = x.Hostname,
Expires = x.Expires,
IsUpcomingExpire = x.IsUpcomingExpire,
@ -309,10 +232,6 @@ public class AccountService : IAccountService {
return hosts;
}
#endregion
#region Helper Methods
private GetAccountResponse CreateGetAccountResponse(Guid accountId, RegistrationCache cache) {
var hostnames = GetHostnamesFromCache(cache) ?? [];

View File

@ -23,6 +23,7 @@ public interface ICertsInternalService : ICertsCommonService {
Task<IDomainResult> GetOrderAsync(Guid sessionId, string[] hostnames);
Task<IDomainResult> GetCertificatesAsync(Guid sessionId, string[] hostnames);
Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, string[] hostnames);
Task<(Guid?, IDomainResult)> FullFlow(bool isStaging, Guid? accountId, string description, string[] contacts, string challengeType, string[] hostnames);
}
public interface ICertsRestService : ICertsCommonService {
@ -188,6 +189,39 @@ public class CertsFlowService : ICertsFlowService {
return IDomainResult.Success(results);
}
public async Task<(Guid?, IDomainResult)> FullFlow(bool isStaging, Guid? accountId, string description, string[] contacts, string challengeType, string[]hostnames) {
var (sessionId, configureClientResult) = await ConfigureClientAsync(isStaging);
if (!configureClientResult.IsSuccess || sessionId == null)
return (null, configureClientResult);
(accountId, var initResult) = await InitAsync(sessionId.Value, accountId, description, contacts);
if (!initResult.IsSuccess)
return (null, initResult);
var (_, newOrderResult) = await NewOrderAsync(sessionId.Value, hostnames, challengeType);
if (!newOrderResult.IsSuccess)
return (null, newOrderResult);
var challengeResult = await CompleteChallengesAsync(sessionId.Value);
if (!challengeResult.IsSuccess)
return (null, challengeResult);
var getOrderResult = await GetOrderAsync(sessionId.Value, hostnames);
if (!getOrderResult.IsSuccess)
return (null, getOrderResult);
var certs = await GetCertificatesAsync(sessionId.Value, hostnames);
if (!certs.IsSuccess)
return (null, certs);
var (_, applyCertsResult) = await ApplyCertificatesAsync(sessionId.Value, hostnames);
if (!applyCertsResult.IsSuccess)
return (null, applyCertsResult);
return IDomainResult.Success(accountId);
}
#endregion
#region REST methods

View File

@ -1,14 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PatchAccountRequest {
namespace MaksIT.Models.LetsEncryptServer.Account.Requests;
public PatchAction<string>? Description { get; set; }
public class PatchAccountRequest {
public List<PatchAction<string>>? Contacts { get; set; }
}
public PatchAction<string>? Description { get; set; }
public PatchAction<bool>? IsDisabled { get; set; }
public List<PatchAction<string>>? Contacts { get; set; }
public List<PatchHostnameRequest>? Hostnames { get; set; }
}

View File

@ -1,12 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PatchContactsRequest {
public List<PatchAction<string>> Contacts { get; set; }
}
}

View File

@ -0,0 +1,9 @@

namespace MaksIT.Models.LetsEncryptServer.Account.Requests;
public class PatchHostnameRequest {
public PatchAction<string> Hostname { get; set; }
public PatchAction<bool> IsDisabled { get; set; }
}

View File

@ -1,12 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PatchHostnamesRequest {
public List<PatchAction<string>> Hostnames { get; set; }
}
}

View File

@ -1,25 +1,24 @@
using System.ComponentModel.DataAnnotations;
namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PostAccountRequest : IValidatableObject {
public required string Description { get; set; }
public required string[] Contacts { get; set; }
public required string ChallengeType { get; set; }
public required string[] Hostnames { get; set; }
public required bool IsStaging { get; set; }
namespace MaksIT.Models.LetsEncryptServer.Account.Requests;
public class PostAccountRequest : IValidatableObject {
public required string Description { get; set; }
public required string[] Contacts { get; set; }
public required string ChallengeType { get; set; }
public required string[] Hostnames { get; set; }
public required bool IsStaging { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (string.IsNullOrWhiteSpace(Description))
yield return new ValidationResult("Description is required", new[] { nameof(Description) });
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (string.IsNullOrWhiteSpace(Description))
yield return new ValidationResult("Description is required", new[] { nameof(Description) });
if (Contacts == null || Contacts.Length == 0)
yield return new ValidationResult("Contacts is required", new[] { nameof(Contacts) });
if (Contacts == null || Contacts.Length == 0)
yield return new ValidationResult("Contacts is required", new[] { nameof(Contacts) });
if (Hostnames == null || Hostnames.Length == 0)
yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) });
if (Hostnames == null || Hostnames.Length == 0)
yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) });
if (string.IsNullOrWhiteSpace(ChallengeType) && ChallengeType != "http-01")
yield return new ValidationResult("ChallengeType is required", new[] { nameof(ChallengeType) });
}
if (string.IsNullOrWhiteSpace(ChallengeType) && ChallengeType != "http-01")
yield return new ValidationResult("ChallengeType is required", new[] { nameof(ChallengeType) });
}
}

View File

@ -1,19 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PostContactsRequest : IValidatableObject {
public required string[] Contacts { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (Contacts == null || Contacts.Length == 0)
yield return new ValidationResult("Contacts is required", new[] { nameof(Contacts) });
}
}
}

View File

@ -1,17 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PostHostnamesRequest : IValidatableObject {
public required string[] Hostnames { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (Hostnames == null || Hostnames.Length == 0)
yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) });
}
}
}

View File

@ -1,21 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PutAccountRequest : IValidatableObject {
public required string Description { get; set; }
public required string[] Contacts { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (string.IsNullOrWhiteSpace(Description))
yield return new ValidationResult("Description is required", new[] { nameof(Description) });
if (Contacts == null || Contacts.Length == 0)
yield return new ValidationResult("Contacts is required", new[] { nameof(Contacts) });
}
}
}

View File

@ -1,15 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PutContactsRequest : IValidatableObject {
public required string[] Contacts { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (Contacts == null || Contacts.Length == 0)
yield return new ValidationResult("Contacts is required", new[] { nameof(Contacts) });
}
}
}

View File

@ -1,17 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PutHostnamesRequest : IValidatableObject {
public string[] Hostnames { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (Hostnames == null || Hostnames.Length == 0)
yield return new ValidationResult("Hostnames is required", new[] { nameof(Hostnames) });
}
}
}

View File

@ -15,7 +15,7 @@ namespace MaksIT.Models.LetsEncryptServer.Account.Responses {
public string? ChallengeType { get; set; }
public HostnameResponse[]? Hostnames { get; set; }
public GetHostnameResponse[]? Hostnames { get; set; }
public required bool IsStaging { get; set; }
}

View File

@ -1,11 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Responses {
public class GetContactsResponse {
public string[] Contacts { get; set; }
}
}

View File

@ -5,7 +5,7 @@ using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Responses {
public class HostnameResponse {
public class GetHostnameResponse {
public required string Hostname { get; set; }
public DateTime Expires { get; set; }
public bool IsUpcomingExpire { get; set; }

View File

@ -1,12 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Responses {
public class GetHostnamesResponse {
public List<HostnameResponse> Hostnames { get; set; }
}
}