Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch to ValidatedNel / Validated instead of Either #369

Merged
merged 3 commits into from
Oct 7, 2016

Conversation

sihil
Copy link
Contributor

@sihil sihil commented Oct 6, 2016

This makes much of the code a bit cleaner and easier to reason about and in come cases means that we can return multiple errors. As a bonus some of the Json reads code becomes a little easier to reason about as well.

This adds an API endpoint to which YAML can be sent for validation and some very rudimentary web pages that let you do the same (your eyes will hurt!)

Would be interested in doing a code review of all of this in person with anyone who has used cats before.

@sihil sihil changed the title An initial experiement to use ValidatedNel instead of Either An initial experiment to use ValidatedNel instead of Either Oct 6, 2016
@sihil sihil force-pushed the sh-cats-and-validated branch 2 times, most recently from 29bc4e5 to 03dc907 Compare October 6, 2016 19:15
This makes much of the code a bit cleaner and easier to reason about and in come cases means that we can return multiple errors. As a bonus some of the Json reads code becomes a little easier to reason about as well.

This adds an API endpoint to which YAML can be sent for validation and some very rudimentary web pages that let you do the same (your eyes will hurt!)
@sihil sihil removed the DO NOT MERGE label Oct 6, 2016
@sihil sihil changed the title An initial experiment to use ValidatedNel instead of Either Switch to ValidatedNel / Validated instead of Either Oct 6, 2016
def :::(other: ConfigErrors) = ConfigErrors(other.errors ::: errors)
def ::(other: ConfigError) = ConfigErrors(other :: errors)
object ConfigError {
def nel(context: String, message: String) = NonEmptyList.of(ConfigError(context, message))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this useful?

Copy link
Contributor Author

@sihil sihil Oct 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No - have removed - aliasing NonEmptyList helps.

_ <- validateDependencies(label, deployment, config.deployments).right
} yield deployment
}
applyTemplates(label, rawDeployment, config.templates).andThen { templated =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not enamoured with the layout of this, makes it needlessly hard to see the steps having the andThen calls all over the place. Nice thing about for comps is they make that so clear.

:minor_point:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXED!

private[input] def resolveDeployment(label: String, templated: DeploymentOrTemplate, globalStacks: Option[List[String]], globalRegions: Option[List[String]]): ValidatedNel[ConfigError, Deployment] = {
(Validated.fromOption(templated.`type`, ConfigError.nel(label, "No type field provided")) |@|
Validated.fromOption(templated.stacks.orElse(globalStacks), ConfigError.nel(label, "No stacks provided")) |@|
Validated.fromOption(templated.regions.orElse(globalRegions), ConfigError.nel(label, "No regions provided"))) map { (deploymentType, stacks, regions) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this bit should also validate the lengths of stacks and regions are > 0 so you can match on stackHead :: stacks to avoid the unsafe NEL creation. Or some such*.

  • disclaimer: other approaches exist

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For better or worse I've pimped Option[List] to provide this niceness.

deploymentType <- availableTypes.find(_.name == deployment.`type`)
.toRight(ConfigError(deployment.name, s"Unknown type ${deployment.`type`}")).right
deploymentWithActions <- resolveDeploymentActions(deployment, deploymentType).right
deployment <- verifyDeploymentParameters(deploymentWithActions, deploymentType).right
} yield deployment
Validated.fromEither(result).leftMap(NonEmptyList.of(_))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does leftMap(NonEmptyList.of) not typecheck?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly not.

import magenta.deployment_type.DeploymentType
import magenta.graph.{DeploymentTasks, Graph}
import magenta.input._
import magenta.{DeployParameters, DeploymentResources}

object Resolver {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ow my head

val deploymentTypeEither = deploymentTypes.find(_.name == deployment.`type`).
toRight(ConfigError(deployment.name, s"Deployment type ${deployment.`type`} not found"))
val validatedDeploymentType = Validated.fromOption(deploymentTypes.find(_.name == deployment.`type`),
NEL.of(ConfigError(deployment.name, s"Deployment type ${deployment.`type`} not found")))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to me to be more sensible than ConfigError.nel (see above).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep - that was before I started aliasing NonEmptyList => NEL in all the imports. Am thinking about a global type alias.

* Two functions are pimped onto Validated for the purposes of testing and if the value is not of the expected type
* then the test will fail detailing what the unexpected value was.
*/
trait ValidatedValues {
Copy link
Contributor

@adamnfish adamnfish Oct 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice.

By analogy with EitherValues this would be .valid.value. I realise that's mainly an artifact of Either's projections but the .value is the common bit (see also OptionValues). The trait scalatest has for Future is called Futures, presumably because it doesn't have the value accessor syntax.

:minor_point:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left it as I can't think of an alternative.

case Invalid(errors) =>
Json.obj(
"response" -> Json.obj(
"status" -> "ok",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the status really "ok" here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I think so. It has successfully given you a validation result. That result might be a failure, but it is still a 200 response which in the API world is OK. See the case above where you don't even give it valid data to validate in which case you do get an error.

@(implicit request: Security.AuthenticatedRequest[AnyContent, com.gu.googleauth.UserIdentity], errors: NonEmptyList[ConfigError])

@main("Validation errors", request) {
<div class="clearfix"><p>&nbsp;</p></div>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:-0

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The frontend needs some attention. It has that everywhere.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you really need a spacer.gif to get into the flavour.

@adamnfish
Copy link
Contributor

Woah, NELly

@sihil sihil force-pushed the sh-cats-and-validated branch 2 times, most recently from d62b6bb to d1365e1 Compare October 7, 2016 14:22
Copy link
Contributor

@philwills philwills left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This generally looks good, but probably should talk through some of it in person.

def resolve(config: RiffRaffDeployConfig): List[Either[ConfigError, Deployment]] = {
config.deployments.map { case (label, rawDeployment) =>
def resolve(config: RiffRaffDeployConfig): ValidatedNel[ConfigError, List[Deployment]] = {
config.deployments.traverseU[ValidatedNel[ConfigError, Deployment]] { case (label, rawDeployment) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scala/scala#5102 means that the weird U bit will go away in 2.12.

regions <- templated.regions.orElse(globalRegions).toRight(ConfigError(label, "No regions provided")).right
} yield {
private[input] def resolveDeployment(label: String, templated: DeploymentOrTemplate, globalStacks: Option[List[String]], globalRegions: Option[List[String]]): ValidatedNel[ConfigError, Deployment] = {
(Validated.fromOption(templated.`type`, NEL.of(ConfigError(label, "No type field provided"))) |@|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a custom error type with it's own Semigroup might avoid the littering of NEL.of.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me give that a bash.

} yield {
private[input] def resolveDeployment(label: String, templated: DeploymentOrTemplate, globalStacks: Option[List[String]], globalRegions: Option[List[String]]): ValidatedNel[ConfigError, Deployment] = {
(Validated.fromOption(templated.`type`, NEL.of(ConfigError(label, "No type field provided"))) |@|
Validated.fromOption(templated.stacks.orElse(globalStacks), NEL.of(ConfigError(label, "No stacks provided"))) |@|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It tend to find mapN easier to talk about than |@|, but that's definitely personal preference.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a play with that and couldn't see how to get it to work and can't find any examples. Seems to work for higher kinded types with one type parameter rather than two or more or have I missed something?

parameters = templated.parameters.getOrElse(Map.empty)
name = label,
`type` = deploymentType,
stacks = NEL.fromListUnsafe(stacks),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't this Unsafe be avoided by lifting into the NEL within the Validated clause?

e.g.

Validated.fromOption(templated.stacks.orElse(globalStacks).flatMap(NEL.fromList), NEL.of(ConfigError(label, "No stacks provided")))


package object resolver {
implicit class RichValidated[E, A](validated: Validated[E, A]) {
def flatMap[EE >: E, B](f: A => Validated[EE, B]): Validated[EE, B] = validated.andThen(f)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there are some quite good reasons for not calling this flatMap

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason it is here is to make for comprehensions work and eliminate the uses of andThen across the codebase.

 - pimping Validated with `flatMap` means we can write much clearer code by switching to for comprehensions and avoid the use of `andThen`
 - using traverseU means that we don't have to produce singleton lists and labouriously combine them

 Leaving the use of andThen in the tests.
@sihil
Copy link
Contributor Author

sihil commented Oct 7, 2016

OK - I've made some more changes to move from ValidatedNel to Validated with a custom error class. Cleans up quite a lot of the boilerplate as @philwills guessed. I also found a couple of places I hadn't entirely converted to Validated.

@sihil sihil merged commit 5bc2184 into master Oct 7, 2016
@sihil sihil deleted the sh-cats-and-validated branch October 7, 2016 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants