Skip to content

Commit

Permalink
feat(domains): update custom domains functionality (#2920)
Browse files Browse the repository at this point in the history
* WIP add custom domain length check logic

* Add @inquirer/prompts

* WIP add prompt and logic to domains

* WIP add paginator

* WIP add paginator part 2

* WIP add paginator part 3

* WIP add paginator part 4

* WIP working request for next-range headers

* WIP update draft copy and mute logs

* Add total number of domains in prompt

* WIP add total number of domains in prompt for filtered domains

Need to update types and update logic for when this condition will be true. Currently defaulted to true verify size of filtered domains

* WIP create interface & update logic

* WIP update logic

* WIP update logic part 2

* WIP update logic part 3

* Update pagninator and domains request

* Update ux.log() and comments

* Update prompt

* Refactor keyValueParser into utility

* Update paginator arguments & comments

* Clean up paginator

* Code clean up

* Add tests

* Add tests part 2

* Update copy from CX review

* WIP last test debug

* Add last test

* Update yarn.lock

* Update test

* Add generics for any types
  • Loading branch information
zwhitfield3 committed Jul 24, 2024
1 parent f5027db commit 045eab4
Show file tree
Hide file tree
Showing 8 changed files with 395 additions and 4 deletions.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@heroku/buildpack-registry": "^1.0.1",
"@heroku/eventsource": "^1.0.7",
"@heroku/heroku-cli-util": "^8.0.13",
"@inquirer/prompts": "^5.0.5",
"@oclif/core": "^2.16.0",
"@oclif/plugin-commands": "2.2.28",
"@oclif/plugin-help": "^5",
Expand Down
60 changes: 57 additions & 3 deletions packages/cli/src/commands/domains/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import {Command, flags} from '@heroku-cli/command'
import color from '@heroku-cli/color'
import * as Heroku from '@heroku-cli/schema'
import {ux} from '@oclif/core'
import * as Uri from 'urijs'
import {confirm} from '@inquirer/prompts'
import {paginateRequest} from '../../lib/utils/paginator'
import parseKeyValue from '../../lib/utils/keyValueParser'

function isApexDomain(hostname: string) {
if (hostname.includes('*')) return false
Expand Down Expand Up @@ -70,18 +74,68 @@ www.example.com CNAME www.example.herokudns.com
return tableConfig
}

getFilteredDomains = (filterKeyValue: string, domains: Array<Heroku.Domain>) => {
const filteredInfo = {size: 0, filteredDomains: domains}
const {key: filterName, value} = parseKeyValue(filterKeyValue)

if (!value) {
throw new Error('Filter flag has an invalid value')
}

if (filterName === 'Domain Name') {
filteredInfo.filteredDomains = domains.filter(domain => domain.hostname!.includes(value))
}

if (filterName === 'DNS Record Type') {
filteredInfo.filteredDomains = domains.filter(domain => {
const kind = isApexDomain(domain.hostname!) ? 'ALIAS or ANAME' : 'CNAME'
return kind.includes(value)
})
}

if (filterName === 'DNS Target') {
filteredInfo.filteredDomains = domains.filter(domain => domain.cname!.includes(value))
}

if (filterName === 'SNI Endpoint') {
filteredInfo.filteredDomains = domains.filter(domain => {
if (!domain.sni_endpoint) domain.sni_endpoint = ''
return domain.sni_endpoint!.includes(value)
})
}

filteredInfo.size = filteredInfo.filteredDomains.length
return filteredInfo
}

async run() {
const {flags} = await this.parse(DomainsIndex)
const {body: domains} = await this.heroku.get<Array<Heroku.Domain>>(`/apps/${flags.app}/domains`)
const herokuDomain = domains.find(domain => domain.kind === 'heroku')
const customDomains = domains.filter(domain => domain.kind === 'custom')
const domains = await paginateRequest<Heroku.Domain>(this.heroku, `/apps/${flags.app}/domains`, 1000)
const herokuDomain = domains.find((domain: Heroku.Domain) => domain.kind === 'heroku')
let customDomains = domains.filter((domain: Heroku.Domain) => domain.kind === 'custom')
let displayTotalDomains = false

if (flags.filter) {
customDomains = this.getFilteredDomains(flags.filter, domains).filteredDomains
}

if (flags.json) {
ux.styledJSON(domains)
} else {
ux.styledHeader(`${flags.app} Heroku Domain`)
ux.log(herokuDomain && herokuDomain.hostname)
if (customDomains && customDomains.length > 0) {
ux.log()

if (customDomains.length > 100 && !flags.csv) {
ux.warn(`This app has over 100 domains. Your terminal may not be configured to display the total amount of domains. You can export all domains into a CSV file with: ${color.cyan('heroku domains -a example-app --csv > example-file.csv')}`)
displayTotalDomains = await confirm({default: false, message: `Display all ${customDomains.length} domains?`, theme: {prefix: '', style: {defaultAnswer: () => '(Y/N)'}}})

if (!displayTotalDomains) {
return
}
}

ux.log()
ux.styledHeader(`${flags.app} Custom Domains`)
ux.table(customDomains, this.tableConfig(true), {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/lib/utils/keyValueParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function parseKeyValue(input: string) {
let [key, value] = input.split(/=(.+)/)

key = key.trim()
value = value ? value.trim() : ''

return {key, value}
}
33 changes: 33 additions & 0 deletions packages/cli/src/lib/utils/paginator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// page size ranges from 200 - 1000 seen here
// https://devcenter.heroku.com/articles/platform-api-reference#ranges

// This paginator uses status code to determine passing the Next-Range header
import {APIClient} from '@heroku-cli/command'
import HTTP from 'http-call'

export async function paginateRequest<T = unknown>(client: APIClient, url: string, pageSize = 200): Promise<T[]> {
let isPartial = true
let isFirstRequest = true
let nextRange: string | undefined = ''
let aggregatedResponseBody: T[] = []

while (isPartial) {
const response: HTTP<T[]> = await client.get<T[]>(url, {
headers: {
Range: `${(isPartial && !isFirstRequest) ? `${nextRange}` : `id ..; max=${pageSize};`}`,
},
partial: true,
})

aggregatedResponseBody = [...response.body, ...aggregatedResponseBody]
isFirstRequest = false

if (response.statusCode === 206) {
nextRange = response.headers['next-range'] as string
} else {
isPartial = false
}
}

return aggregatedResponseBody
}
34 changes: 34 additions & 0 deletions packages/cli/test/unit/commands/domains/index.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {expect, test} from '@oclif/test'
import * as inquirer from '@inquirer/prompts'
import {unwrap} from '../../../helpers/utils/unwrap'

describe('domains', function () {
const herokuOnlyDomainsResponse = [{
Expand Down Expand Up @@ -129,4 +131,36 @@ describe('domains', function () {
expect(ctx.stdout).to.contain('Domain Name DNS Record Type DNS Target SNI Endpoint')
expect(ctx.stdout).to.contain('*.example.com CNAME buzz.herokudns.com some haiku')
})

test
.stdout()
.stderr()
.stub(inquirer, 'confirm', () => async () => process.stdin.write('\n'))
.nock('https://api.heroku.com', api => api
.get('/apps/myapp/domains')
.reply(200, () => {
const domainData = {
acm_status: null,
acm_status_reason: null,
app: {
name: 'myapp',
id: '01234567-89ab-cdef-0123-456789abcdef',
},
cname: null,
created_at: '2012-01-01T12:00:00Z',
hostname: 'example.com',
id: '11434567-89ab-cdef-0123-456789abcdef',
kind: 'custom',
updated_at: '2012-01-01T12:00:00Z',
status: 'succeeded',
}

return new Array(1000).fill(domainData) // eslint-disable-line unicorn/no-new-array
}),
)
.command(['domains', '--app', 'myapp'])
.it('shows warning message for over 100 domains', ctx => {
expect(ctx.stdout).to.contain('=== myapp Heroku Domain')
expect(unwrap(ctx.stderr)).to.contain('Warning: This app has over 100 domains. Your terminal may not be configured to display the total amount of domains.')
})
})
21 changes: 21 additions & 0 deletions packages/cli/test/unit/utils/keyValueParser.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {expect} from '@oclif/test'
import parseKeyValue from '../../../src/lib/utils/keyValueParser'

const exampleInput1 = 'Domain Name=ztestdomain7'
const exampleInput2 = 'exampleKey=value'
const exampleInput3 = 'example key=example value'

describe('keyValueParser', () => {
it('parses and extracts key/value pairs', () => {
const {key: exampleKey1, value: exampleValue1} = parseKeyValue(exampleInput1)
const {key: exampleKey2, value: exampleValue2} = parseKeyValue(exampleInput2)
const {key: exampleKey3, value: exampleValue3} = parseKeyValue(exampleInput3)

expect(exampleKey1).to.equal('Domain Name')
expect(exampleValue1).to.equal('ztestdomain7')
expect(exampleKey2).to.equal('exampleKey')
expect(exampleValue2).to.equal('value')
expect(exampleKey3).to.equal('example key')
expect(exampleValue3).to.equal('example value')
})
})
41 changes: 41 additions & 0 deletions packages/cli/test/unit/utils/paginator.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {expect} from '@oclif/test'
import {Config} from '@oclif/core'
import {paginateRequest} from '../../../src/lib/utils/paginator'
import {APIClient} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import * as nock from 'nock'

const path = require('path')
const root = path.resolve(__dirname, '../package.json')
const config = new Config({root})
const exampleAPIClient = new APIClient(config)

nock.disableNetConnect()

const requestUrl = '/apps/myapp/domains'

describe('paginator', function () {
it('paginates through 2 requests', async function () {
nock('https://api.heroku.com')
.get(requestUrl)
.reply(206, [{id: '1'}], {'next-range': 'id ..; max=200'})
.get(requestUrl)
.reply(200, [{id: '2'}])

const results = await paginateRequest<Heroku.Domain>(exampleAPIClient, requestUrl, 200)
expect(results).to.have.length(2)
expect(results[0].id).to.equal('2')
expect(results[1].id).to.equal('1')
})

it('serves single requests', async function () {
nock('https://api.heroku.com')
.get(requestUrl)
.reply(200, [{id: '1'}])

const results = await paginateRequest<Heroku.Domain>(exampleAPIClient, requestUrl, 200)
expect(results).to.have.length(1)
expect(results).to.not.have.length(2)
expect(results[0].id).to.equal('1')
})
})
Loading

0 comments on commit 045eab4

Please sign in to comment.