(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' 'use client'
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes' import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { httpService } from '@/services/httpService' import { httpService } from '@/services/HttpService'
import { FormEvent, useEffect, useRef, useState } from 'react' import { FormEvent, useEffect, useRef, useState } from 'react'
import { import {
CustomButton, CustomButton,
@ -10,7 +10,10 @@ import {
CustomInput, CustomInput,
CustomRadioGroup CustomRadioGroup
} from '@/controls' } 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 { deepCopy, enumToArray } from '../functions'
import { CacheAccount } from '@/entities/CacheAccount' import { CacheAccount } from '@/entities/CacheAccount'
import { ChallengeTypes } from '@/entities/ChallengeTypes' import { ChallengeTypes } from '@/entities/ChallengeTypes'
@ -42,22 +45,7 @@ export default function Page() {
if (!gatAccountsResult.isSuccess) return if (!gatAccountsResult.isSuccess) return
gatAccountsResult.data?.forEach((account) => { gatAccountsResult.data?.forEach((account) => {
newAccounts.push({ newAccounts.push(toCacheAccount(account))
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
})
}) })
setAccounts(newAccounts) setAccounts(newAccounts)
@ -144,10 +132,6 @@ export default function Page() {
title="Challenge Type" title="Challenge Type"
enumType={ChallengeTypes} enumType={ChallengeTypes}
selectedValue={account.challengeType} selectedValue={account.challengeType}
onChange={(option) =>
//handleChallengeTypeChange(account.accountId, option)
console.log('')
}
disabled={true} disabled={true}
/> />
</div> </div>
@ -206,9 +190,12 @@ export default function Page() {
{editingAccount && ( {editingAccount && (
<AccountEdit <AccountEdit
account={editingAccount} account={editingAccount}
setAccount={setEditingAccount}
onCancel={() => setEditingAccount(null)} onCancel={() => setEditingAccount(null)}
onDelete={deleteAccount} onDelete={deleteAccount}
onSubmit={(account) => {
setEditingAccount(null)
handleAccountUpdate(account)
}}
/> />
)} )}
</OffCanvas> </OffCanvas>

View File

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

View File

@ -71,27 +71,41 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
return ( return (
<div className={`flex flex-col ${className}`} ref={selectBoxRef}> <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 <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 <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} onClick={handleToggle}
> >
<span className={`${disabled ? 'text-gray-500' : ''}`}>
{selectedOption ? selectedOption.label : 'Select an option'} {selectedOption ? selectedOption.label : 'Select an option'}
</span>
{isOpen ? ( {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> </div>
{isOpen && ( {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) => ( {options.map((option) => (
<li <li
key={option.value} key={option.value}
className={`p-2 hover:bg-gray-200 ${readOnly || disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`} className={'p-2'}
onClick={() => handleOptionClick(option)} onClick={() => handleOptionClick(option)}
> >
{option.label} {option.label}
@ -100,6 +114,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
</ul> </ul>
)} )}
</div> </div>
{error && ( {error && (
<p className={`text-red-500 mt-1 ${errorClassName}`}>{error}</p> <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 { CacheAccountHostname } from './CacheAccountHostname'
import { PatchAccountRequest } from '@/models/letsEncryptServer/account/requests/PatchAccountRequest'
export interface CacheAccount { export interface CacheAccount {
accountId: string accountId: string
@ -10,3 +12,29 @@ export interface CacheAccount {
isEditMode: boolean isEditMode: boolean
isStaging: 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' import { PatchAction } from '@/models/PatchAction'
import { PatchHostnameRequest } from './PatchHostnameRequest'
export interface PatchHostnameRequest {
hostname?: PatchAction<string>
isDisabled?: PatchAction<boolean>
}
export interface PatchAccountRequest { export interface PatchAccountRequest {
description?: PatchAction<string> 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 { export interface GetAccountResponse {
accountId: string accountId: string
@ -6,6 +7,27 @@ export interface GetAccountResponse {
description: string description: string
contacts: string[] contacts: string[]
challengeType?: string challengeType?: string
hostnames?: HostnameResponse[] hostnames?: GetHostnameResponse[]
isStaging: boolean 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 hostname: string
expires: string expires: string
isUpcomingExpire: boolean 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, CustomInput,
CustomRadioGroup CustomRadioGroup
} from '@/controls' } from '@/controls'
import { CacheAccount } from '@/entities/CacheAccount' import { CacheAccount, toPatchAccountRequest } from '@/entities/CacheAccount'
import { FaPlus, FaTrash } from 'react-icons/fa' import { FaPlus, FaTrash } from 'react-icons/fa'
import { ChallengeTypes } from '@/entities/ChallengeTypes' import { ChallengeTypes } from '@/entities/ChallengeTypes'
import { deepCopy } from '@/functions' import { deepCopy } from '@/functions'
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes' import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { httpService } from '@/services/httpService' import { httpService } from '@/services/HttpService'
import { PatchAccountRequest } from '@/models/letsEncryptServer/account/requests/PatchAccountRequest' import { PatchAccountRequest } from '@/models/letsEncryptServer/account/requests/PatchAccountRequest'
import { PatchOperation } from '@/models/PatchOperation' import { PatchOperation } from '@/models/PatchOperation'
import { useAppDispatch } from '@/redux/store' import { useAppDispatch } from '@/redux/store'
import { showToast } from '@/redux/slices/toastSlice' import { showToast } from '@/redux/slices/toastSlice'
import {
GetAccountResponse,
toCacheAccount
} from '@/models/letsEncryptServer/account/responses/GetAccountResponse'
interface AccountEditProps { interface AccountEditProps {
account: CacheAccount account: CacheAccount
setAccount: Dispatch<SetStateAction<CacheAccount | null>>
onCancel?: () => void onCancel?: () => void
onSubmit?: (account: CacheAccount) => void
onDelete: (accountId: string) => void onDelete: (accountId: string) => void
onSubmit: (account: CacheAccount) => void
} }
const AccountEdit: React.FC<AccountEditProps> = ({ const AccountEdit: React.FC<AccountEditProps> = (props) => {
account, const { account, onCancel, onDelete, onSubmit } = props
setAccount,
onCancel,
onSubmit,
onDelete
}) => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [newAccount, setNewAccount] = useState<PatchAccountRequest>({ const [newAccount, setNewAccount] = useState<PatchAccountRequest>(
description: { op: PatchOperation.None, value: account.description }, toPatchAccountRequest(account)
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 [newContact, setNewContact] = useState('') const [newContact, setNewContact] = useState('')
const [newHostname, setNewHostname] = useState('') const [newHostname, setNewHostname] = useState('')
useEffect(() => {
console.log(newAccount)
}, [newAccount])
const { const {
value: description, value: description,
error: descriptionError, error: descriptionError,
@ -163,6 +149,9 @@ const AccountEdit: React.FC<AccountEditProps> = ({
return c return c
}) })
.filter((c) => !(c.value === contact && c.op === PatchOperation.Add)) .filter((c) => !(c.value === contact && c.op === PatchOperation.Add))
console.log(newAccount.contacts)
return newAccount return newAccount
}) })
} }
@ -246,11 +235,24 @@ const AccountEdit: React.FC<AccountEditProps> = ({
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
httpService.patch<PatchAccountRequest, CacheAccount>( if (!newAccount) return
GetApiRoute(ApiRoutes.ACCOUNT_ID, account.accountId),
newAccount 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) => { const handleDelete = (accountId: string) => {
onDelete?.(accountId) onDelete?.(accountId)
@ -268,7 +270,7 @@ const AccountEdit: React.FC<AccountEditProps> = ({
title="Description" title="Description"
inputClassName="border p-2 rounded w-full" inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1" errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow" className="mr-2 w-full"
/> />
</div> </div>
@ -277,14 +279,16 @@ const AccountEdit: React.FC<AccountEditProps> = ({
checked={newAccount.isDisabled?.value ?? false} checked={newAccount.isDisabled?.value ?? false}
label="Disabled" label="Disabled"
onChange={handleIsDisabledChange} onChange={handleIsDisabledChange}
className="mr-2 flex-grow" className="mr-2"
/> />
</div> </div>
<div className="mb-4"> <div className="mb-4">
<h3 className="text-xl font-medium mb-2">Contacts:</h3> <h3 className="text-xl font-medium mb-2">Contacts:</h3>
<ul className="list-disc list-inside pl-4 mb-2"> <ul className="list-disc list-inside pl-4 mb-2">
{newAccount.contacts?.map((contact) => ( {newAccount.contacts
?.filter((contact) => contact.op !== PatchOperation.Remove)
.map((contact) => (
<li key={contact.value} className="text-gray-700 mb-2"> <li key={contact.value} className="text-gray-700 mb-2">
<div className="inline-flex"> <div className="inline-flex">
{contact.value} {contact.value}
@ -309,7 +313,7 @@ const AccountEdit: React.FC<AccountEditProps> = ({
title="New Contact" title="New Contact"
inputClassName="border p-2 rounded w-full" inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1" errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow" className="mr-2 w-full"
/> />
<CustomButton <CustomButton
type="button" type="button"
@ -326,7 +330,7 @@ const AccountEdit: React.FC<AccountEditProps> = ({
title="Challenge Type" title="Challenge Type"
enumType={ChallengeTypes} enumType={ChallengeTypes}
selectedValue={account.challengeType} selectedValue={account.challengeType}
className="mr-2 flex-grow" className="mr-2 w-full"
disabled={true} disabled={true}
/> />
</div> </div>
@ -334,9 +338,13 @@ const AccountEdit: React.FC<AccountEditProps> = ({
<div> <div>
<h3 className="text-xl font-medium mb-2">Hostnames:</h3> <h3 className="text-xl font-medium mb-2">Hostnames:</h3>
<ul className="list-disc list-inside pl-4 mb-2"> <ul className="list-disc list-inside pl-4 mb-2">
{newAccount.hostnames?.map((hostname) => ( {newAccount.hostnames
?.filter(
(hostname) => hostname.hostname?.op !== PatchOperation.Remove
)
.map((hostname) => (
<li key={hostname.hostname?.value} className="text-gray-700 mb-2"> <li key={hostname.hostname?.value} className="text-gray-700 mb-2">
<div className="inline-flex"> <div className="inline-flex items-center">
{hostname.hostname?.value} -{' '} {hostname.hostname?.value} -{' '}
<CustomCheckbox <CustomCheckbox
className="ml-2" className="ml-2"
@ -373,7 +381,7 @@ const AccountEdit: React.FC<AccountEditProps> = ({
title="New Hostname" title="New Hostname"
inputClassName="border p-2 rounded w-full" inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1" errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow" className="mr-2 w-full"
/> />
<CustomButton <CustomButton
type="button" type="button"
@ -392,7 +400,7 @@ const AccountEdit: React.FC<AccountEditProps> = ({
]} ]}
initialValue={account.isStaging ? 'staging' : 'production'} initialValue={account.isStaging ? 'staging' : 'production'}
title="LetsEncrypt Environment" title="LetsEncrypt Environment"
className="mr-2 flex-grow" className="mr-2 w-full"
radioClassName="" radioClassName=""
errorClassName="text-red-500 text-sm mt-1" errorClassName="text-red-500 text-sm mt-1"
disabled={true} disabled={true}
@ -402,20 +410,20 @@ const AccountEdit: React.FC<AccountEditProps> = ({
<CustomButton <CustomButton
type="button" type="button"
onClick={() => handleDelete(account.accountId)} 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 /> <FaTrash />
</CustomButton> </CustomButton>
<CustomButton <CustomButton
type="button" type="button"
onClick={handleCancel} onClick={handleCancel}
className="bg-yellow-500 text-white p-2 rounded ml-2" className="bg-yellow-500 text-white p-2 rounded"
> >
Cancel Cancel
</CustomButton> </CustomButton>
<CustomButton <CustomButton
type="submit" type="submit"
className="bg-green-500 text-white p-2 rounded ml-2" className="bg-green-500 text-white p-2 rounded"
> >
Save Save
</CustomButton> </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 { private handleRequestInterceptors(xhr: XMLHttpRequest): void {
this.requestInterceptors.forEach((interceptor) => { this.requestInterceptors.forEach((interceptor) => {
try { try {
@ -223,34 +246,12 @@ class HttpService {
return await this.request<TResponse>('PUT', url, data) 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>( public async patch<TRequest, TResponse>(
url: string, url: string,
data: TRequest data: TRequest
): Promise<HttpResponse<TResponse>> { ): 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) return await this.request<TResponse>('PATCH', url, cleanedData)
} }

View File

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

View File

@ -36,12 +36,6 @@ public class AccountController : ControllerBase {
return result.ToActionResult(); 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}")] [HttpPatch("account/{accountId:guid}")]
public async Task<IActionResult> PatchAccount(Guid accountId, [FromBody] PatchAccountRequest requestData) { public async Task<IActionResult> PatchAccount(Guid accountId, [FromBody] PatchAccountRequest requestData) {
var result = await _accountService.PatchAccountAsync(accountId, requestData); var result = await _accountService.PatchAccountAsync(accountId, requestData);
@ -55,82 +49,4 @@ public class AccountController : ControllerBase {
} }
#endregion #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)> GetAccountsAsync();
Task<(GetAccountResponse?, IDomainResult)> GetAccountAsync(Guid accountId); Task<(GetAccountResponse?, IDomainResult)> GetAccountAsync(Guid accountId);
Task<(GetAccountResponse?, IDomainResult)> PostAccountAsync(PostAccountRequest requestData); Task<(GetAccountResponse?, IDomainResult)> PostAccountAsync(PostAccountRequest requestData);
Task<(GetAccountResponse?, IDomainResult)> PutAccountAsync(Guid accountId, PutAccountRequest requestData);
Task<(GetAccountResponse?, IDomainResult)> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData); Task<(GetAccountResponse?, IDomainResult)> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData);
Task<IDomainResult> DeleteAccountAsync(Guid accountId); 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 { } public interface IAccountService : IAccountInternalService, IAccountRestService { }
@ -75,67 +68,26 @@ public class AccountService : IAccountService {
// TODO: check for overlapping hostnames in already existing accounts // TODO: check for overlapping hostnames in already existing accounts
var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging); var (accountId, newCertsResult) = await _certsFlowService.FullFlow(
if (!configureClientResult.IsSuccess || sessionId == null) { requestData.IsStaging,
//LogErrors(configureClientResult.Errors); null,
return (null, configureClientResult); requestData.Description,
} requestData.Contacts,
var sessionIdValue = sessionId.Value; requestData.ChallengeType,
requestData.Hostnames
);
var (_, initResult) = await _certsFlowService.InitAsync(sessionIdValue, null, requestData.Description, requestData.Contacts); if (!newCertsResult.IsSuccess || accountId == null)
if (!initResult.IsSuccess) { return (null, newCertsResult);
//LogErrors(initResult.Errors);
return (null, initResult);
}
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); var (cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId.Value);
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);
if (!loadResult.IsSuccess || cache == null) { if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult); return (null, loadResult);
} }
cache.Description = requestData.Description; return IDomainResult.Success(CreateGetAccountResponse(accountId.Value, cache));
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)> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData) { 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>(); var contacts = cache.Contacts?.ToList() ?? new List<string>();
foreach (var action in requestData.Contacts) { foreach (var action in requestData.Contacts) {
switch (action.Op) switch (action.Op)
@ -173,11 +133,81 @@ public class AccountService : IAccountService {
cache.Contacts = contacts.ToArray(); 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); var saveResult = await _cacheService.SaveToCacheAsync(accountId, cache);
if (!saveResult.IsSuccess) { if (!saveResult.IsSuccess) {
return (null, saveResult); 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)); return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
} }
@ -189,117 +219,10 @@ public class AccountService : IAccountService {
} }
#endregion #endregion
#region Contacts Operations #region Helper Methods
public async Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId) { private List<GetHostnameResponse> GetHostnamesFromCache(RegistrationCache cache) {
var (cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId); var hosts = cache.GetHosts().Select(x => new GetHostnameResponse {
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 {
Hostname = x.Hostname, Hostname = x.Hostname,
Expires = x.Expires, Expires = x.Expires,
IsUpcomingExpire = x.IsUpcomingExpire, IsUpcomingExpire = x.IsUpcomingExpire,
@ -309,10 +232,6 @@ public class AccountService : IAccountService {
return hosts; return hosts;
} }
#endregion
#region Helper Methods
private GetAccountResponse CreateGetAccountResponse(Guid accountId, RegistrationCache cache) { private GetAccountResponse CreateGetAccountResponse(Guid accountId, RegistrationCache cache) {
var hostnames = GetHostnamesFromCache(cache) ?? []; var hostnames = GetHostnamesFromCache(cache) ?? [];

View File

@ -23,6 +23,7 @@ public interface ICertsInternalService : ICertsCommonService {
Task<IDomainResult> GetOrderAsync(Guid sessionId, string[] hostnames); Task<IDomainResult> GetOrderAsync(Guid sessionId, string[] hostnames);
Task<IDomainResult> GetCertificatesAsync(Guid sessionId, string[] hostnames); Task<IDomainResult> GetCertificatesAsync(Guid sessionId, string[] hostnames);
Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificatesAsync(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 { public interface ICertsRestService : ICertsCommonService {
@ -188,6 +189,39 @@ public class CertsFlowService : ICertsFlowService {
return IDomainResult.Success(results); 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 #endregion
#region REST methods #region REST methods

View File

@ -1,14 +1,14 @@
using System; 
using System.Collections.Generic;
using System.Linq; namespace MaksIT.Models.LetsEncryptServer.Account.Requests;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PatchAccountRequest { public class PatchAccountRequest {
public PatchAction<string>? Description { get; set; } public PatchAction<string>? Description { get; set; }
public PatchAction<bool>? IsDisabled { get; set; }
public List<PatchAction<string>>? Contacts { 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,6 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace MaksIT.Models.LetsEncryptServer.Account.Requests { namespace MaksIT.Models.LetsEncryptServer.Account.Requests;
public class PostAccountRequest : IValidatableObject { public class PostAccountRequest : IValidatableObject {
public required string Description { get; set; } public required string Description { get; set; }
public required string[] Contacts { get; set; } public required string[] Contacts { get; set; }
@ -22,4 +22,3 @@ namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
yield return new ValidationResult("ChallengeType is required", new[] { nameof(ChallengeType) }); 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 string? ChallengeType { get; set; }
public HostnameResponse[]? Hostnames { get; set; } public GetHostnameResponse[]? Hostnames { get; set; }
public required bool IsStaging { 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; using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Responses { namespace MaksIT.Models.LetsEncryptServer.Account.Responses {
public class HostnameResponse { public class GetHostnameResponse {
public required string Hostname { get; set; } public required string Hostname { get; set; }
public DateTime Expires { get; set; } public DateTime Expires { get; set; }
public bool IsUpcomingExpire { 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; }
}
}